")', "html")
- with pytest.raises(DialectError, match="unexpected end of data"):
- apply_dialects('html(f"
")', "html")
diff --git a/tests/test_server/test_common/test_per_client_state.py b/tests/test_server/test_common/test_per_client_state.py
index 197768de5..65639df01 100644
--- a/tests/test_server/test_common/test_per_client_state.py
+++ b/tests/test_server/test_common/test_per_client_state.py
@@ -65,7 +65,8 @@ def Counter():
client_counter.click()
-def test_installed_module(driver, display):
- victory = idom.install("victory@35.4.0")
- display(victory.VictoryBar)
+def test_module_from_template(driver, display):
+ victory = idom.web.module_from_template("react", "victory@35.4.0")
+ VictoryBar = idom.web.export(victory, "VictoryBar")
+ display(VictoryBar)
driver.find_element_by_class_name("VictoryContainer")
diff --git a/src/idom/client/__init__.py b/tests/test_web/__init__.py
similarity index 100%
rename from src/idom/client/__init__.py
rename to tests/test_web/__init__.py
diff --git a/tests/test_web/js_fixtures/export-resolution/index.js b/tests/test_web/js_fixtures/export-resolution/index.js
new file mode 100644
index 000000000..2f1f46a51
--- /dev/null
+++ b/tests/test_web/js_fixtures/export-resolution/index.js
@@ -0,0 +1,2 @@
+export {index as Index};
+export * from "./one.js";
diff --git a/tests/test_web/js_fixtures/export-resolution/one.js b/tests/test_web/js_fixtures/export-resolution/one.js
new file mode 100644
index 000000000..a0355241f
--- /dev/null
+++ b/tests/test_web/js_fixtures/export-resolution/one.js
@@ -0,0 +1,3 @@
+export {one as One};
+// use ../ just to check that it works
+export * from "../export-resolution/two.js";
diff --git a/tests/test_web/js_fixtures/export-resolution/two.js b/tests/test_web/js_fixtures/export-resolution/two.js
new file mode 100644
index 000000000..4e1d807c2
--- /dev/null
+++ b/tests/test_web/js_fixtures/export-resolution/two.js
@@ -0,0 +1,2 @@
+export {two as Two};
+export * from "https://some.external.url";
diff --git a/tests/test_web/js_fixtures/exports-syntax.js b/tests/test_web/js_fixtures/exports-syntax.js
new file mode 100644
index 000000000..8f9b0e612
--- /dev/null
+++ b/tests/test_web/js_fixtures/exports-syntax.js
@@ -0,0 +1,23 @@
+// Copied from: https://developer.mozilla.org/en-US/docs/web/javascript/reference/statements/export
+
+// Exporting individual features
+export let name1, name2, name3; // also var, const
+export let name4 = 4, name5 = 5, name6; // also var, const
+export function functionName(){...}
+export class ClassName {...}
+
+// Export list
+export { name7, name8, name9 };
+
+// Renaming exports
+export { variable1 as name10, variable2 as name11, name12 };
+
+// Exporting destructured assignments with renaming
+export const { name13, name14: bar } = o;
+
+// Aggregating modules
+export * from "https://source1.com"; // does not set the default export
+export * from "https://source2.com"; // does not set the default export
+export * as name15 from "https://source3.com"; // Draft ECMAScript® 2O21
+export { name16, name17 } from "https://source4.com";
+export { import1 as name18, import2 as name19, name20 } from "https://source5.com";
diff --git a/tests/test_web/js_fixtures/exports-two-components.js b/tests/test_web/js_fixtures/exports-two-components.js
new file mode 100644
index 000000000..4266a24ca
--- /dev/null
+++ b/tests/test_web/js_fixtures/exports-two-components.js
@@ -0,0 +1,18 @@
+import { h, render } from "https://unpkg.com/preact?module";
+import htm from "https://unpkg.com/htm?module";
+
+const html = htm.bind(h);
+
+export { h as createElement, render as renderElement };
+
+export function unmountElement(container) {
+ render(null, container);
+}
+
+export function Header1(props) {
+ return h("h1", {id: props.id}, props.text);
+}
+
+export function Header2(props) {
+ return h("h2", {id: props.id}, props.text);
+}
diff --git a/tests/test_client/js/set-flag-when-unmount-is-called.js b/tests/test_web/js_fixtures/set-flag-when-unmount-is-called.js
similarity index 100%
rename from tests/test_client/js/set-flag-when-unmount-is-called.js
rename to tests/test_web/js_fixtures/set-flag-when-unmount-is-called.js
diff --git a/tests/test_web/js_fixtures/simple-button.js b/tests/test_web/js_fixtures/simple-button.js
new file mode 100644
index 000000000..ab2b13788
--- /dev/null
+++ b/tests/test_web/js_fixtures/simple-button.js
@@ -0,0 +1,23 @@
+import { h, render } from "https://unpkg.com/preact?module";
+import htm from "https://unpkg.com/htm?module";
+
+const html = htm.bind(h);
+
+export { h as createElement, render as renderElement };
+
+export function unmountElement(container) {
+ render(null, container);
+}
+
+export function SimpleButton(props) {
+ return h(
+ "button",
+ {
+ id: props.id,
+ onClick(event) {
+ props.onClick({ data: props.eventResponseData });
+ },
+ },
+ "simple button"
+ );
+}
diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py
new file mode 100644
index 000000000..7e9b08200
--- /dev/null
+++ b/tests/test_web/test_module.py
@@ -0,0 +1,170 @@
+from pathlib import Path
+
+import pytest
+from sanic import Sanic
+from selenium.webdriver.common.by import By
+from selenium.webdriver.support import expected_conditions
+from selenium.webdriver.support.ui import WebDriverWait
+
+import idom
+from idom.server.sanic import PerClientStateServer
+from idom.testing import ServerMountPoint
+from idom.web.module import NAME_SOURCE, WebModule
+
+
+JS_FIXTURES_DIR = Path(__file__).parent / "js_fixtures"
+
+
+def test_that_js_module_unmount_is_called(driver, display):
+ SomeComponent = idom.web.export(
+ idom.web.module_from_file(
+ "set-flag-when-unmount-is-called",
+ JS_FIXTURES_DIR / "set-flag-when-unmount-is-called.js",
+ ),
+ "SomeComponent",
+ )
+
+ set_current_component = idom.Ref(None)
+
+ @idom.component
+ def ShowCurrentComponent():
+ current_component, set_current_component.current = idom.hooks.use_state(
+ lambda: SomeComponent({"id": "some-component", "text": "initial component"})
+ )
+ return current_component
+
+ display(ShowCurrentComponent)
+
+ driver.find_element_by_id("some-component")
+
+ set_current_component.current(
+ idom.html.h1({"id": "some-other-component"}, "some other component")
+ )
+
+ # the new component has been displayed
+ driver.find_element_by_id("some-other-component")
+
+ # the unmount callback for the old component was called
+ driver.find_element_by_id("unmount-flag")
+
+
+def test_module_from_url(driver):
+ app = Sanic(__name__)
+
+ # instead of directing the URL to a CDN, we just point it to this static file
+ app.static(
+ "/simple-button.js",
+ str(JS_FIXTURES_DIR / "simple-button.js"),
+ content_type="text/javascript",
+ )
+
+ SimpleButton = idom.web.export(
+ idom.web.module_from_url("/simple-button.js", resolve_exports=False),
+ "SimpleButton",
+ )
+
+ @idom.component
+ def ShowSimpleButton():
+ return SimpleButton({"id": "my-button"})
+
+ with ServerMountPoint(PerClientStateServer, app=app) as mount_point:
+ mount_point.mount(ShowSimpleButton)
+ driver.get(mount_point.url())
+ driver.find_element_by_id("my-button")
+
+
+def test_module_from_template_where_template_does_not_exist():
+ with pytest.raises(ValueError, match="No template for 'does-not-exist.js'"):
+ idom.web.module_from_template("does-not-exist", "something.js")
+
+
+def test_module_from_template(driver, display):
+ victory = idom.web.module_from_template("react", "victory@35.4.0")
+ VictoryBar = idom.web.export(victory, "VictoryBar")
+ display(VictoryBar)
+ wait = WebDriverWait(driver, 10)
+ wait.until(
+ expected_conditions.visibility_of_element_located(
+ (By.CLASS_NAME, "VictoryContainer")
+ )
+ )
+
+
+def test_module_from_file(driver, driver_wait, display):
+ SimpleButton = idom.web.export(
+ idom.web.module_from_file(
+ "simple-button", JS_FIXTURES_DIR / "simple-button.js"
+ ),
+ "SimpleButton",
+ )
+
+ is_clicked = idom.Ref(False)
+
+ @idom.component
+ def ShowSimpleButton():
+ return SimpleButton(
+ {"id": "my-button", "onClick": lambda event: is_clicked.set_current(True)}
+ )
+
+ display(ShowSimpleButton)
+
+ button = driver.find_element_by_id("my-button")
+ button.click()
+ driver_wait.until(lambda d: is_clicked.current)
+
+
+def test_module_from_file_source_conflict(tmp_path):
+ first_file = tmp_path / "first.js"
+
+ with pytest.raises(FileNotFoundError, match="does not exist"):
+ idom.web.module_from_file("temp", first_file)
+
+ first_file.touch()
+
+ idom.web.module_from_file("temp", first_file)
+
+ second_file = tmp_path / "second.js"
+ second_file.touch()
+
+ with pytest.raises(FileExistsError, match="already exists"):
+ idom.web.module_from_file("temp", second_file)
+
+
+def test_web_module_from_file_symlink(tmp_path):
+ file = tmp_path / "temp.js"
+ file.touch()
+
+ module = idom.web.module_from_file("temp", file, symlink=True)
+
+ assert module.file.resolve().read_text() == ""
+
+ file.write_text("hello world!")
+
+ assert module.file.resolve().read_text() == "hello world!"
+
+
+def test_module_missing_exports():
+ module = WebModule("test", NAME_SOURCE, None, {"a", "b", "c"}, None)
+
+ with pytest.raises(ValueError, match="does not export 'x'"):
+ idom.web.export(module, "x")
+
+ with pytest.raises(ValueError, match=r"does not export \['x', 'y'\]"):
+ idom.web.export(module, ["x", "y"])
+
+
+def test_module_exports_multiple_components(driver, display):
+ Header1, Header2 = idom.web.export(
+ idom.web.module_from_file(
+ "exports-two-components", JS_FIXTURES_DIR / "exports-two-components.js"
+ ),
+ ["Header1", "Header2"],
+ )
+
+ display(lambda: Header1({"id": "my-h1"}, "My Header 1"))
+
+ driver.find_element_by_id("my-h1")
+
+ display(lambda: Header2({"id": "my-h2"}, "My Header 2"))
+
+ driver.find_element_by_id("my-h2")
diff --git a/tests/test_web/test_utils.py b/tests/test_web/test_utils.py
new file mode 100644
index 000000000..dd657694a
--- /dev/null
+++ b/tests/test_web/test_utils.py
@@ -0,0 +1,129 @@
+from pathlib import Path
+
+import pytest
+import responses
+
+from idom.web.utils import (
+ resolve_module_exports_from_file,
+ resolve_module_exports_from_source,
+ resolve_module_exports_from_url,
+)
+
+
+JS_FIXTURES_DIR = Path(__file__).parent / "js_fixtures"
+
+
+@responses.activate
+def test_resolve_module_exports_from_file(caplog):
+ responses.add(
+ responses.GET,
+ "https://some.external.url",
+ body="export {something as ExternalUrl}",
+ )
+ path = JS_FIXTURES_DIR / "export-resolution" / "index.js"
+ assert resolve_module_exports_from_file(path, 4) == {
+ "Index",
+ "One",
+ "Two",
+ "ExternalUrl",
+ }
+
+
+def test_resolve_module_exports_from_file_log_on_max_depth(caplog):
+ path = JS_FIXTURES_DIR / "export-resolution" / "index.js"
+ assert resolve_module_exports_from_file(path, 0) == set()
+ assert len(caplog.records) == 1
+ assert caplog.records[0].message.endswith("max depth reached")
+
+ caplog.records.clear()
+
+ assert resolve_module_exports_from_file(path, 2) == {"Index", "One"}
+ assert len(caplog.records) == 1
+ assert caplog.records[0].message.endswith("max depth reached")
+
+
+def test_resolve_module_exports_from_file_log_on_unknown_file_location(
+ caplog, tmp_path
+):
+ file = tmp_path / "some.js"
+ file.write_text("export * from './does-not-exist.js';")
+ resolve_module_exports_from_file(file, 2)
+ assert len(caplog.records) == 1
+ assert caplog.records[0].message.startswith(
+ "Did not resolve exports for unknown file"
+ )
+
+
+@responses.activate
+def test_resolve_module_exports_from_url():
+ responses.add(
+ responses.GET,
+ "https://some.url/first.js",
+ body="export const First = 1; export * from 'https://another.url/path/second.js';",
+ )
+ responses.add(
+ responses.GET,
+ "https://another.url/path/second.js",
+ body="export const Second = 2; export * from '../third.js';",
+ )
+ responses.add(
+ responses.GET,
+ "https://another.url/third.js",
+ body="export const Third = 3; export * from './fourth.js';",
+ )
+ responses.add(
+ responses.GET,
+ "https://another.url/fourth.js",
+ body="export const Fourth = 4;",
+ )
+
+ assert resolve_module_exports_from_url("https://some.url/first.js", 4) == {
+ "First",
+ "Second",
+ "Third",
+ "Fourth",
+ }
+
+
+def test_resolve_module_exports_from_url_log_on_max_depth(caplog):
+ assert resolve_module_exports_from_url("https://some.url", 0) == set()
+ assert len(caplog.records) == 1
+ assert caplog.records[0].message.endswith("max depth reached")
+
+
+def test_resolve_module_exports_from_url_log_on_bad_response(caplog):
+ assert resolve_module_exports_from_url("https://some.url", 1) == set()
+ assert len(caplog.records) == 1
+ assert caplog.records[0].message.startswith("Did not resolve exports for url")
+
+
+@pytest.mark.parametrize(
+ "text",
+ [
+ "export default expression;",
+ "export default function (…) { … } // also class, function*",
+ "export default function name1(…) { … } // also class, function*",
+ "export { something as default };",
+ "export { default } from 'some-source';",
+ "export { something as default } from 'some-source';",
+ ],
+)
+def test_resolve_module_default_exports_from_source(text):
+ names, references = resolve_module_exports_from_source(text)
+ assert names == {"default"} and not references
+
+
+def test_resolve_module_exports_from_source():
+ fixture_file = JS_FIXTURES_DIR / "exports-syntax.js"
+ names, references = resolve_module_exports_from_source(fixture_file.read_text())
+ assert (
+ names
+ == (
+ {f"name{i}" for i in range(1, 21)}
+ | {
+ "functionName",
+ "ClassName",
+ }
+ )
+ and references == {"https://source1.com", "https://source2.com"}
+ )
diff --git a/tests/test_widgets/test_html.py b/tests/test_widgets.py
similarity index 61%
rename from tests/test_widgets/test_html.py
rename to tests/test_widgets.py
index d93567943..ca0b97090 100644
--- a/tests/test_widgets/test_html.py
+++ b/tests/test_widgets.py
@@ -1,5 +1,6 @@
import time
from base64 import b64encode
+from pathlib import Path
from selenium.webdriver.common.keys import Keys
@@ -7,26 +8,79 @@
from tests.driver_utils import send_keys
-_image_src_bytes = b"""
+HERE = Path(__file__).parent
+
+
+def test_multiview_repr():
+ assert str(idom.widgets.MultiViewMount({})) == "MultiViewMount({})"
+
+
+def test_hostwap_update_on_change(driver, display):
+ """Ensure shared hotswapping works
+
+ This basically means that previously rendered views of a hotswap component get updated
+ when a new view is mounted, not just the next time it is re-displayed
+
+ In this test we construct a scenario where clicking a button will cause a pre-existing
+ hotswap component to be updated
+ """
+
+ def make_next_count_constructor(count):
+ """We need to construct a new function so they're different when we set_state"""
+
+ def constructor():
+ count.current += 1
+ return idom.html.div({"id": f"hotswap-{count.current}"}, count.current)
+
+ return constructor
+
+ @idom.component
+ def ButtonSwapsDivs():
+ count = idom.Ref(0)
+
+ @idom.event
+ async def on_click(event):
+ mount(make_next_count_constructor(count))
+
+ incr = idom.html.button({"onClick": on_click, "id": "incr-button"}, "incr")
+
+ mount, make_hostswap = idom.widgets.hotswap(update_on_change=True)
+ mount(make_next_count_constructor(count))
+ hotswap_view = make_hostswap()
+
+ return idom.html.div(incr, hotswap_view)
+
+ display(ButtonSwapsDivs)
+
+ client_incr_button = driver.find_element_by_id("incr-button")
+
+ driver.find_element_by_id("hotswap-1")
+ client_incr_button.click()
+ driver.find_element_by_id("hotswap-2")
+ client_incr_button.click()
+ driver.find_element_by_id("hotswap-3")
+
+
+IMAGE_SRC_BYTES = b"""
"""
-_base64_image_src = b64encode(_image_src_bytes).decode()
+BASE64_IMAGE_SRC = b64encode(IMAGE_SRC_BYTES).decode()
def test_image_from_string(driver, display):
- src = _image_src_bytes.decode()
+ src = IMAGE_SRC_BYTES.decode()
display(lambda: idom.widgets.image("svg", src, {"id": "a-circle-1"}))
client_img = driver.find_element_by_id("a-circle-1")
- assert _base64_image_src in client_img.get_attribute("src")
+ assert BASE64_IMAGE_SRC in client_img.get_attribute("src")
def test_image_from_bytes(driver, display):
- src = _image_src_bytes
+ src = IMAGE_SRC_BYTES
display(lambda: idom.widgets.image("svg", src, {"id": "a-circle-1"}))
client_img = driver.find_element_by_id("a-circle-1")
- assert _base64_image_src in client_img.get_attribute("src")
+ assert BASE64_IMAGE_SRC in client_img.get_attribute("src")
def test_input_callback(driver, driver_wait, display):
diff --git a/tests/test_widgets/__init__.py b/tests/test_widgets/__init__.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/tests/test_widgets/test_utils.py b/tests/test_widgets/test_utils.py
deleted file mode 100644
index c602fbc07..000000000
--- a/tests/test_widgets/test_utils.py
+++ /dev/null
@@ -1,56 +0,0 @@
-from pathlib import Path
-
-import idom
-
-
-HERE = Path(__file__).parent
-
-
-def test_multiview_repr():
- assert str(idom.widgets.utils.MultiViewMount({})) == "MultiViewMount({})"
-
-
-def test_hostwap_update_on_change(driver, display):
- """Ensure shared hotswapping works
-
- This basically means that previously rendered views of a hotswap component get updated
- when a new view is mounted, not just the next time it is re-displayed
-
- In this test we construct a scenario where clicking a button will cause a pre-existing
- hotswap component to be updated
- """
-
- def make_next_count_constructor(count):
- """We need to construct a new function so they're different when we set_state"""
-
- def constructor():
- count.current += 1
- return idom.html.div({"id": f"hotswap-{count.current}"}, count.current)
-
- return constructor
-
- @idom.component
- def ButtonSwapsDivs():
- count = idom.Ref(0)
-
- @idom.event
- async def on_click(event):
- mount(make_next_count_constructor(count))
-
- incr = idom.html.button({"onClick": on_click, "id": "incr-button"}, "incr")
-
- mount, make_hostswap = idom.widgets.hotswap(update_on_change=True)
- mount(make_next_count_constructor(count))
- hotswap_view = make_hostswap()
-
- return idom.html.div(incr, hotswap_view)
-
- display(ButtonSwapsDivs)
-
- client_incr_button = driver.find_element_by_id("incr-button")
-
- driver.find_element_by_id("hotswap-1")
- client_incr_button.click()
- driver.find_element_by_id("hotswap-2")
- client_incr_button.click()
- driver.find_element_by_id("hotswap-3")