From 6cd65cf4fb1e23e811085884e49e10501f9fc10f Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 14 Jun 2026 16:39:55 -0700 Subject: [PATCH 01/11] Initial Playwright setup plus first test Refs #2779 --- .github/workflows/playwright.yml | 48 +++++++++++++++++++ pyproject.toml | 3 ++ pytest.ini | 3 +- tests/conftest.py | 17 ++++++- tests/test_playwright.py | 79 ++++++++++++++++++++++++++++++++ 5 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/playwright.yml create mode 100644 tests/test_playwright.py diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 00000000..5275ddef --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,48 @@ +name: Playwright + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + browser: [chromium, firefox, webkit] + steps: + - uses: actions/checkout@v6 + - name: Set up Python 3.14 + uses: actions/setup-python@v6 + with: + python-version: "3.14" + allow-prereleases: true + cache: pip + cache-dependency-path: pyproject.toml + - name: Cache uv + uses: actions/cache@v5 + with: + path: ~/.cache/uv + key: ${{ runner.os }}-py3.14-uv-${{ hashFiles('pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-py3.14-uv- + - name: Cache Playwright browsers + uses: actions/cache@v5 + with: + path: ~/.cache/ms-playwright/ + key: ${{ runner.os }}-playwright-${{ matrix.browser }}-${{ hashFiles('pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-playwright-${{ matrix.browser }}- + - name: Install uv + run: python -m pip install uv + - name: Install dependencies + run: uv sync --group dev --group playwright + - name: Install ${{ matrix.browser }} + run: uv run --group dev --group playwright playwright install --with-deps ${{ matrix.browser }} + - name: Run Playwright tests + run: uv run --group dev --group playwright pytest tests/test_playwright.py --playwright --browser ${{ matrix.browser }} diff --git a/pyproject.toml b/pyproject.toml index 0d136d60..a19dc957 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,6 +81,9 @@ dev = [ "ruamel.yaml", "psutil>=5.9", ] +playwright = [ + "pytest-playwright>=0.8.0", +] [project.optional-dependencies] rich = ["rich"] diff --git a/pytest.ini b/pytest.ini index 29b84ea5..75de6925 100644 --- a/pytest.ini +++ b/pytest.ini @@ -6,4 +6,5 @@ filterwarnings= ignore:Using or importing the ABCs::bs4.element markers = serial: tests to avoid using with pytest-xdist -asyncio_mode = strict \ No newline at end of file + playwright: browser automation tests, skipped unless --playwright is passed +asyncio_mode = strict diff --git a/tests/conftest.py b/tests/conftest.py index 27d6fa77..04b6f8be 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -96,6 +96,15 @@ def pytest_report_header(config): return "SQLite: {}".format(version) +def pytest_addoption(parser): + parser.addoption( + "--playwright", + action="store_true", + default=False, + help="run Playwright browser automation tests", + ) + + def pytest_configure(config): import sys @@ -108,7 +117,13 @@ def pytest_unconfigure(config): del sys._called_from_test -def pytest_collection_modifyitems(items): +def pytest_collection_modifyitems(config, items): + if not config.getoption("--playwright"): + skip_playwright = pytest.mark.skip(reason="need --playwright option to run") + for item in items: + if "playwright" in item.keywords: + item.add_marker(skip_playwright) + # Ensure test_cli.py and test_black.py and test_inspect.py run first before any asyncio code kicks in move_to_front(items, "test_cli") move_to_front(items, "test_black") diff --git a/tests/test_playwright.py b/tests/test_playwright.py new file mode 100644 index 00000000..abe992bc --- /dev/null +++ b/tests/test_playwright.py @@ -0,0 +1,79 @@ +import socket +import subprocess +import sys +import time + +import httpx +import pytest + +from datasette.fixtures import write_fixture_database + + +def find_free_port(): + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +def wait_for_server(process, url, timeout=10): + deadline = time.monotonic() + timeout + last_error = None + while time.monotonic() < deadline: + if process.poll() is not None: + stdout, stderr = process.communicate() + raise AssertionError( + "Datasette server exited early\n" + f"stdout:\n{stdout}\n" + f"stderr:\n{stderr}" + ) + try: + response = httpx.get(url, timeout=1.0) + if response.status_code < 500: + return + last_error = f"HTTP {response.status_code}: {response.text[:200]}" + except httpx.HTTPError as ex: + last_error = repr(ex) + time.sleep(0.1) + raise AssertionError(f"Timed out waiting for {url}: {last_error}") + + +@pytest.fixture +def datasette_server(tmp_path): + db_path = tmp_path / "fixtures.db" + write_fixture_database(str(db_path)) + port = find_free_port() + process = subprocess.Popen( + [ + sys.executable, + "-m", + "datasette", + str(db_path), + "--host", + "127.0.0.1", + "--port", + str(port), + "--setting", + "num_sql_threads", + "1", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + url = f"http://127.0.0.1:{port}/" + try: + wait_for_server(process, url) + yield url + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + + +@pytest.mark.playwright +def test_datasette_homepage_contains_datasette(page, datasette_server): + page.goto(datasette_server) + assert "Datasette" in page.locator("body").inner_text() From 047b69e87f0c94bbd4e59ba151731859158e25e3 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 14 Jun 2026 16:44:17 -0700 Subject: [PATCH 02/11] Port navigation search recents to Playwright Refs #2779 --- tests/test_navigation_search_js.py | 200 ----------------------------- tests/test_playwright.py | 68 ++++++++++ 2 files changed, 68 insertions(+), 200 deletions(-) diff --git a/tests/test_navigation_search_js.py b/tests/test_navigation_search_js.py index b487357d..fc3c7a63 100644 --- a/tests/test_navigation_search_js.py +++ b/tests/test_navigation_search_js.py @@ -7,206 +7,6 @@ REPO_ROOT = Path(__file__).resolve().parents[1] STATIC_DIR = REPO_ROOT / "datasette" / "static" -def test_navigation_search_tracks_and_renders_recent_items(): - script = textwrap.dedent(""" - const fs = require("fs"); - const vm = require("vm"); - const navigationSearchJs = __NAVIGATION_SEARCH_JS__; - - class FakeElement { - constructor() { - this.innerHTML = ""; - this.value = ""; - this.dataset = {}; - this.open = false; - } - addEventListener() {} - close() { this.open = false; } - focus() {} - querySelector() { - return { scrollIntoView() {} }; - } - showModal() { this.open = true; } - } - - class FakeShadowRoot { - constructor() { - this.innerHTML = ""; - this.dialog = new FakeElement(); - this.input = new FakeElement(); - this.results = new FakeElement(); - } - querySelector(selector) { - if (selector == "dialog") return this.dialog; - if (selector == ".search-input") return this.input; - if (selector == ".results-container") return this.results; - return new FakeElement(); - } - } - - global.HTMLElement = class { - constructor() { - this.attributes = {}; - } - attachShadow() { - this.shadowRoot = new FakeShadowRoot(); - return this.shadowRoot; - } - dispatchEvent() {} - getAttribute(name) { - return this.attributes[name] || null; - } - querySelector() { - return null; - } - setAttribute(name, value) { - this.attributes[name] = value; - } - }; - global.CustomEvent = class { - constructor(name, options) { - this.name = name; - this.options = options; - } - }; - global.customElements = { - registry: new Map(), - define(name, cls) { - this.registry.set(name, cls); - }, - }; - global.document = { - addEventListener() {}, - activeElement: null, - createElement() { - return { - set textContent(value) { - this.innerHTML = String(value) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); - }, - }; - }, - }; - global.localStorage = { - store: {}, - getItem(key) { - return Object.prototype.hasOwnProperty.call(this.store, key) - ? this.store[key] - : null; - }, - setItem(key, value) { - this.store[key] = String(value); - }, - removeItem(key) { - delete this.store[key]; - }, - }; - global.window = { location: { href: "" } }; - - vm.runInThisContext( - fs.readFileSync(navigationSearchJs, "utf8"), - { filename: "navigation-search.js" } - ); - - const Component = customElements.registry.get("navigation-search"); - const element = new Component(); - const items = Array.from({ length: 6 }, (_, index) => ({ - name: `Item ${index + 1}`, - url: `/item-${index + 1}`, - type: "table", - description: "Table", - })); - items[5].name = "content: recent_datasette_releases"; - items[5].display_name = "Recent Datasette releases"; - - for (const item of items) { - element.matches = [item]; - element.renderedMatches = [item]; - element.selectedIndex = 0; - element.selectCurrentItem(); - } - - const stored = JSON.parse( - Object.values(localStorage.store).find((value) => value.includes("/item-6")) - ); - if (stored.length !== 5) { - throw new Error(`Expected 5 recent items, got ${stored.length}`); - } - if (stored[0].url !== "/item-6" || stored[4].url !== "/item-2") { - throw new Error(`Unexpected recent order: ${JSON.stringify(stored)}`); - } - if (stored[0].display_name !== "Recent Datasette releases") { - throw new Error(`Missing display_name in recent item: ${JSON.stringify(stored[0])}`); - } - - element.matches = [ - items[5], - items[4], - { - name: "Other", - url: "/other", - type: "database", - description: "Database", - }, - ]; - element.shadowRoot.input.value = ""; - element.renderResults(); - - const html = element.shadowRoot.results.innerHTML; - if (!html.includes("Recent")) { - throw new Error(`Missing Recent heading: ${html}`); - } - if (!html.includes("Recent Datasette releases") || !html.includes("Item 5")) { - throw new Error(`Missing recent items: ${html}`); - } - if (!html.includes("content: recent_datasette_releases")) { - throw new Error(`Missing canonical item name for display_name item: ${html}`); - } - if (!html.includes("Item 4") || !html.includes("Item 2")) { - throw new Error(`Expected all stored recent items in empty state: ${html}`); - } - if (html.includes("Other")) { - throw new Error(`Rendered non-recent item in empty state: ${html}`); - } - if (!html.includes("Clear recent")) { - throw new Error(`Missing Clear recent control: ${html}`); - } - - element.clearRecentItems(); - if (localStorage.getItem(element.recentItemsStorageKey()) !== null) { - throw new Error("Expected recent items to be cleared"); - } - element.renderResults(); - if (element.shadowRoot.results.innerHTML.includes("Clear recent")) { - throw new Error("Clear recent should disappear after clearing"); - } - - process.stdout.write(JSON.stringify(stored)); - """).replace( - "__NAVIGATION_SEARCH_JS__", - json.dumps(str(STATIC_DIR / "navigation-search.js")), - ) - result = subprocess.run( - ["node", "-e", script], - cwd=REPO_ROOT, - text=True, - capture_output=True, - check=False, - ) - assert result.returncode == 0, result.stderr - assert [item["url"] for item in json.loads(result.stdout)] == [ - "/item-6", - "/item-5", - "/item-4", - "/item-3", - "/item-2", - ] - assert json.loads(result.stdout)[0]["display_name"] == "Recent Datasette releases" - - def test_navigation_search_renders_jump_sections_from_javascript_plugins(): script = ( textwrap.dedent(""" diff --git a/tests/test_playwright.py b/tests/test_playwright.py index abe992bc..726536d9 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -77,3 +77,71 @@ def datasette_server(tmp_path): def test_datasette_homepage_contains_datasette(page, datasette_server): page.goto(datasette_server) assert "Datasette" in page.locator("body").inner_text() + + +@pytest.mark.playwright +def test_navigation_search_tracks_and_renders_recent_items(page, datasette_server): + page.goto(datasette_server) + result = page.evaluate(""" + async () => { + await customElements.whenDefined("navigation-search"); + const element = document.querySelector("navigation-search"); + const key = element.recentItemsStorageKey(); + localStorage.removeItem(key); + + const items = Array.from({ length: 6 }, (_, index) => ({ + name: `Item ${index + 1}`, + url: `/item-${index + 1}`, + type: "table", + description: "Table", + })); + items[5].name = "content: recent_datasette_releases"; + items[5].display_name = "Recent Datasette releases"; + + for (const item of items) { + element.saveRecentItem(item); + } + + const stored = JSON.parse(localStorage.getItem(key)); + element.matches = [ + items[5], + items[4], + { + name: "Other", + url: "/other", + type: "database", + description: "Database", + }, + ]; + element.shadowRoot.querySelector(".search-input").value = ""; + element.renderResults(); + const html = element.shadowRoot.querySelector(".results-container").innerHTML; + + element.clearRecentItems(); + const clearedValue = localStorage.getItem(key); + element.renderResults(); + const htmlAfterClear = element.shadowRoot + .querySelector(".results-container") + .innerHTML; + + return { stored, html, clearedValue, htmlAfterClear }; + } + """) + assert [item["url"] for item in result["stored"]] == [ + "/item-6", + "/item-5", + "/item-4", + "/item-3", + "/item-2", + ] + assert result["stored"][0]["display_name"] == "Recent Datasette releases" + assert "Recent" in result["html"] + assert "Recent Datasette releases" in result["html"] + assert "Item 5" in result["html"] + assert "content: recent_datasette_releases" in result["html"] + assert "Item 4" in result["html"] + assert "Item 2" in result["html"] + assert "Other" not in result["html"] + assert "Clear recent" in result["html"] + assert result["clearedValue"] is None + assert "Clear recent" not in result["htmlAfterClear"] From 6bbd33d81da6b0b599639e07f212602a3b25f48b Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 14 Jun 2026 16:44:59 -0700 Subject: [PATCH 03/11] Port navigation jump sections to Playwright Refs #2779 --- tests/test_navigation_search_js.py | 194 ----------------------------- tests/test_playwright.py | 38 ++++++ 2 files changed, 38 insertions(+), 194 deletions(-) delete mode 100644 tests/test_navigation_search_js.py diff --git a/tests/test_navigation_search_js.py b/tests/test_navigation_search_js.py deleted file mode 100644 index fc3c7a63..00000000 --- a/tests/test_navigation_search_js.py +++ /dev/null @@ -1,194 +0,0 @@ -import json -from pathlib import Path -import subprocess -import textwrap - -REPO_ROOT = Path(__file__).resolve().parents[1] -STATIC_DIR = REPO_ROOT / "datasette" / "static" - - -def test_navigation_search_renders_jump_sections_from_javascript_plugins(): - script = ( - textwrap.dedent(""" - const fs = require("fs"); - const vm = require("vm"); - const datasetteManagerJs = __DATASETTE_MANAGER_JS__; - const navigationSearchJs = __NAVIGATION_SEARCH_JS__; - - const documentListeners = {}; - - class FakeElement { - constructor(tagName = "div", parent = null) { - this._innerHTML = ""; - this.value = ""; - this.dataset = {}; - this.open = false; - this.parent = parent; - this.tagName = tagName.toUpperCase(); - } - set textContent(value) { - this.innerHTML = String(value) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); - } - get innerHTML() { - return this._innerHTML; - } - set innerHTML(value) { - this._innerHTML = String(value); - if (this.parent) { - this.parent._innerHTML += this._innerHTML; - } - } - addEventListener() {} - appendChild(child) { - this._innerHTML += child.innerHTML || ""; - return child; - } - close() { this.open = false; } - focus() {} - querySelector(selector) { - if (selector.startsWith("[data-jump-section-index=")) { - return new FakeElement("div", this); - } - return { scrollIntoView() {} }; - } - showModal() { this.open = true; } - } - - class FakeShadowRoot { - constructor() { - this.innerHTML = ""; - this.dialog = new FakeElement("dialog"); - this.input = new FakeElement("input"); - this.results = new FakeElement("div"); - } - querySelector(selector) { - if (selector == "dialog") return this.dialog; - if (selector == ".search-input") return this.input; - if (selector == ".results-container") return this.results; - return new FakeElement(); - } - } - - global.HTMLElement = class { - constructor() { - this.attributes = {}; - } - attachShadow() { - this.shadowRoot = new FakeShadowRoot(); - return this.shadowRoot; - } - dispatchEvent() {} - getAttribute(name) { - return this.attributes[name] || null; - } - querySelector() { - return null; - } - setAttribute(name, value) { - this.attributes[name] = value; - } - }; - global.CustomEvent = class { - constructor(name, options) { - this.name = name; - this.type = name; - this.detail = options ? options.detail : undefined; - } - }; - global.customElements = { - registry: new Map(), - define(name, cls) { - this.registry.set(name, cls); - }, - }; - global.document = { - addEventListener(name, callback) { - documentListeners[name] = documentListeners[name] || []; - documentListeners[name].push(callback); - }, - activeElement: null, - createElement(tagName) { - return new FakeElement(tagName); - }, - dispatchEvent(event) { - for (const callback of documentListeners[event.type] || []) { - callback(event); - } - }, - querySelectorAll() { - return []; - }, - }; - global.localStorage = { - getItem() { return null; }, - setItem() {}, - removeItem() {}, - }; - global.window = { datasetteVersion: "test", location: { href: "" } }; - - vm.runInThisContext( - fs.readFileSync(datasetteManagerJs, "utf8"), - { filename: "datasette-manager.js" } - ); - for (const callback of documentListeners.DOMContentLoaded || []) { - callback(); - } - window.__DATASETTE__.registerPlugin("agent", { - version: "0.1", - makeJumpSections() { - return [ - { - id: "agent-chat", - render(node, context) { - if (!context.navigationSearch) { - throw new Error("Expected navigationSearch in render context"); - } - node.innerHTML = [ - '
', - '', - '
', - ].join(''); - }, - }, - ]; - }, - }); - - vm.runInThisContext( - fs.readFileSync(navigationSearchJs, "utf8"), - { filename: "navigation-search.js" } - ); - - const Component = customElements.registry.get("navigation-search"); - const element = new Component(); - element.shadowRoot.input.value = ""; - element.renderResults(); - - const html = element.shadowRoot.results.innerHTML; - if (!html.includes("Start a new agent chat")) { - throw new Error(`Missing jump section content: ${html}`); - } - process.stdout.write("ok"); - """) - .replace( - "__DATASETTE_MANAGER_JS__", - json.dumps(str(STATIC_DIR / "datasette-manager.js")), - ) - .replace( - "__NAVIGATION_SEARCH_JS__", - json.dumps(str(STATIC_DIR / "navigation-search.js")), - ) - ) - result = subprocess.run( - ["node", "-e", script], - cwd=REPO_ROOT, - text=True, - capture_output=True, - check=False, - ) - assert result.returncode == 0, result.stderr - assert result.stdout.endswith("ok") diff --git a/tests/test_playwright.py b/tests/test_playwright.py index 726536d9..caea8b16 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -145,3 +145,41 @@ def test_navigation_search_tracks_and_renders_recent_items(page, datasette_serve assert "Clear recent" in result["html"] assert result["clearedValue"] is None assert "Clear recent" not in result["htmlAfterClear"] + + +@pytest.mark.playwright +def test_navigation_search_renders_jump_sections_from_javascript_plugins( + page, datasette_server +): + page.goto(datasette_server) + html = page.evaluate(""" + async () => { + await customElements.whenDefined("navigation-search"); + window.__DATASETTE__.registerPlugin("agent", { + version: "0.1", + makeJumpSections() { + return [ + { + id: "agent-chat", + render(node, context) { + if (!context.navigationSearch) { + throw new Error("Expected navigationSearch in render context"); + } + node.innerHTML = [ + '
', + '', + '
', + ].join(""); + }, + }, + ]; + }, + }); + + const element = document.querySelector("navigation-search"); + element.shadowRoot.querySelector(".search-input").value = ""; + element.renderResults(); + return element.shadowRoot.querySelector(".results-container").innerHTML; + } + """) + assert "Start a new agent chat" in html From b5fa485a9f2588aa53e98c27691ae80540000be2 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 14 Jun 2026 16:45:48 -0700 Subject: [PATCH 04/11] Port datasette manager plugin test to Playwright Refs #2779 --- tests/test_datasette_manager_js.py | 70 ------------------------------ tests/test_playwright.py | 30 +++++++++++++ 2 files changed, 30 insertions(+), 70 deletions(-) diff --git a/tests/test_datasette_manager_js.py b/tests/test_datasette_manager_js.py index 01b6bbac..1a46bb42 100644 --- a/tests/test_datasette_manager_js.py +++ b/tests/test_datasette_manager_js.py @@ -6,76 +6,6 @@ import textwrap STATIC_DIR = Path(__file__).resolve().parents[1] / "datasette" / "static" -def test_datasette_manager_make_column_field(): - script = textwrap.dedent(""" - const fs = require("fs"); - const vm = require("vm"); - const datasetteManagerJs = __DATASETTE_MANAGER_JS__; - - const documentListeners = {}; - global.CustomEvent = class { - constructor(name, options) { - this.type = name; - this.detail = options ? options.detail : undefined; - } - }; - global.document = { - addEventListener(name, callback) { - documentListeners[name] = documentListeners[name] || []; - documentListeners[name].push(callback); - }, - dispatchEvent(event) { - for (const callback of documentListeners[event.type] || []) { - callback(event); - } - }, - }; - global.window = { datasetteVersion: "test" }; - - vm.runInThisContext( - fs.readFileSync(datasetteManagerJs, "utf8"), - { filename: "datasette-manager.js" } - ); - for (const callback of documentListeners.DOMContentLoaded || []) { - callback(); - } - - window.__DATASETTE__.registerPlugin("declines", { - makeColumnField() { - return; - }, - }); - window.__DATASETTE__.registerPlugin("handles", { - makeColumnField(context) { - if (context.columnType.type !== "demo") { - return; - } - return { useTextarea: true }; - }, - }); - - const control = window.__DATASETTE__.makeColumnField({ - column: "body", - columnType: { type: "demo", config: null }, - }); - console.log(JSON.stringify(control)); - """).replace( - "__DATASETTE_MANAGER_JS__", - json.dumps(str(STATIC_DIR / "datasette-manager.js")), - ) - result = subprocess.run( - ["node", "-e", script], - text=True, - capture_output=True, - check=False, - ) - assert result.returncode == 0, result.stderr - assert json.loads(result.stdout) == { - "pluginName": "handles", - "useTextarea": True, - } - - def test_table_plugin_column_field_api(): script = textwrap.dedent(""" const fs = require("fs"); diff --git a/tests/test_playwright.py b/tests/test_playwright.py index caea8b16..353f1d94 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -183,3 +183,33 @@ def test_navigation_search_renders_jump_sections_from_javascript_plugins( } """) assert "Start a new agent chat" in html + + +@pytest.mark.playwright +def test_datasette_manager_make_column_field(page, datasette_server): + page.goto(datasette_server) + control = page.evaluate(""" + () => { + window.__DATASETTE__.registerPlugin("declines", { + makeColumnField() { + return; + }, + }); + window.__DATASETTE__.registerPlugin("handles", { + makeColumnField(context) { + if (context.columnType.type !== "demo") { + return; + } + return { useTextarea: true }; + }, + }); + return window.__DATASETTE__.makeColumnField({ + column: "body", + columnType: { type: "demo", config: null }, + }); + } + """) + assert control == { + "pluginName": "handles", + "useTextarea": True, + } From 3cfdca026ad0d8173e3094a3c4776694ee6443b4 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 14 Jun 2026 16:48:32 -0700 Subject: [PATCH 05/11] Port edit tools field API test to Playwright Refs #2779 --- tests/test_datasette_manager_js.py | 387 ----------------------------- tests/test_playwright.py | 329 ++++++++++++++++++++++++ 2 files changed, 329 insertions(+), 387 deletions(-) diff --git a/tests/test_datasette_manager_js.py b/tests/test_datasette_manager_js.py index 1a46bb42..9022d26f 100644 --- a/tests/test_datasette_manager_js.py +++ b/tests/test_datasette_manager_js.py @@ -6,393 +6,6 @@ import textwrap STATIC_DIR = Path(__file__).resolve().parents[1] / "datasette" / "static" -def test_table_plugin_column_field_api(): - script = textwrap.dedent(""" - const fs = require("fs"); - const vm = require("vm"); - const editToolsJs = __EDIT_TOOLS_JS__; - - class FakeEvent { - constructor(type, options) { - this.type = type; - this.bubbles = !!(options && options.bubbles); - } - } - - class FakeElement { - constructor(tagName = "div") { - this.nodeName = tagName.toUpperCase(); - this.nodeType = 1; - this.children = []; - this.dataset = {}; - this.attributes = {}; - this.value = ""; - this.name = ""; - this.disabled = false; - this.readOnly = false; - this.dispatchedEvents = []; - this.eventListeners = {}; - this.validationMessage = ""; - this.hidden = false; - this.textContent = ""; - this.className = ""; - this.classList = { - add: (...names) => { - const classes = new Set(this.className.split(/\\s+/).filter(Boolean)); - for (const name of names) { - classes.add(name); - } - this.className = Array.from(classes).join(" "); - }, - remove: (...names) => { - const removeNames = new Set(names); - this.className = this.className - .split(/\\s+/) - .filter((name) => name && !removeNames.has(name)) - .join(" "); - }, - contains: (name) => this.className.split(/\\s+/).includes(name), - }; - } - appendChild(child) { - this.children.push(child); - child.parentNode = this; - return child; - } - addEventListener(type, callback) { - this.eventListeners[type] = this.eventListeners[type] || []; - this.eventListeners[type].push(callback); - } - dispatchEvent(event) { - event.target = event.target || this; - this.dispatchedEvents.push(event.type); - for (const callback of this.eventListeners[event.type] || []) { - callback(event); - } - return true; - } - setAttribute(name, value) { - this.attributes[name] = String(value); - } - getAttribute(name) { - return this.attributes[name] || null; - } - removeAttribute(name) { - delete this.attributes[name]; - } - setCustomValidity(message) { - this.validationMessage = message; - } - } - - global.Event = FakeEvent; - global.document = { - addEventListener() {}, - createElement(tagName) { - return new FakeElement(tagName); - }, - createTextNode(text) { - const node = new FakeElement("#text"); - node.textContent = text; - return node; - }, - }; - global.location = { - href: "http://localhost/data/projects", - pathname: "/data/projects", - search: "", - }; - global.window = { - _datasetteTableData: { - database: "data", - table: "projects", - tableUrl: "/data/projects", - }, - }; - - vm.runInThisContext(fs.readFileSync(editToolsJs, "utf8"), { - filename: "edit-tools.js", - }); - - const context = columnFormControlContext( - "logo", - true, - { type: "file", config: null }, - { - mode: "edit", - defaultExpression: "lower(hex(randomblob(4)))", - useSqliteDefault: true, - } - ); - const expectedContextKeys = [ - "mode", - "database", - "table", - "tableUrl", - "column", - "columnType", - "sqliteType", - "notNull", - "isPk", - "defaultExpression", - "form", - "dialog", - ].join(","); - if (Object.keys(context).join(",") !== expectedContextKeys) { - throw new Error(`Unexpected context keys: ${Object.keys(context).join(",")}`); - } - if (context.defaultExpression !== "lower(hex(randomblob(4)))") { - throw new Error("context.defaultExpression was not set"); - } - if (JSON.stringify(context.columnType) !== '{"type":"file","config":{}}') { - throw new Error("context.columnType should expose type and object config"); - } - if (!context.isPk) { - throw new Error("context.isPk should say whether the column is a primary key"); - } - - const control = new FakeElement("input"); - control.name = "logo"; - control.value = "df-old"; - control.dataset.initialValue = "df-old"; - control.dataset.initialValueKind = "string"; - control.dataset.currentValueKind = "string"; - control.dataset.useSqliteDefault = "1"; - control.disabled = true; - - const field = createColumnFieldApi({ - id: "row-edit-field-0", - labelId: "row-edit-field-label-0", - descriptionId: "row-edit-field-meta-0", - control, - meta: new FakeElement("span"), - context, - }); - - let renderArgumentCount = null; - let renderField = null; - const wrapper = renderColumnField( - { - pluginName: "test-plugin", - render(field) { - renderArgumentCount = arguments.length; - renderField = field; - return document.createElement("button"); - }, - }, - field - ); - if (renderArgumentCount !== 1 || renderField !== field) { - throw new Error("plugin render should receive the field object only"); - } - if (field.root !== wrapper) { - throw new Error("field.root should be the plugin wrapper"); - } - if (wrapper.children.length !== 1 || wrapper.children[0].nodeName !== "BUTTON") { - throw new Error("plugin render should append returned DOM nodes to field.root"); - } - - field.setValue(null); - if (field.getValue() !== null) { - throw new Error("field.setValue(null) should round-trip as null"); - } - if (field.isUsingSqliteDefault()) { - throw new Error("field.setValue() should stop using the SQLite default"); - } - if (control.dataset.currentValueKind !== "null") { - throw new Error("null values should update currentValueKind"); - } - - field.setValue("df-new"); - if (field.getValue() !== "df-new") { - throw new Error("field.setValue() should update the current value"); - } - if (field.getInitialValue() !== "df-old") { - throw new Error("field.getInitialValue() should remain stable"); - } - if (!field.hasChanged()) { - throw new Error("field.hasChanged() should notice plugin value changes"); - } - if (control.dispatchedEvents.length !== 0) { - throw new Error(`field.setValue() should not dispatch events: ${control.dispatchedEvents}`); - } - - const dirtyRowField = new FakeElement("div"); - dirtyRowField._datasetteColumnFormField = field; - const dirtyState = { - hasLoaded: true, - isLoading: false, - isSaving: false, - mode: "edit", - fields: { - querySelectorAll(selector) { - return selector === ".row-edit-field" ? [dirtyRowField] : []; - }, - }, - dialog: { - closeCalled: false, - close() { - this.closeCalled = true; - }, - }, - shouldRestoreFocus: false, - }; - const confirmMessages = []; - window.confirm = (message) => { - confirmMessages.push(message); - return false; - }; - if (!rowEditDialogHasChanges(dirtyState)) { - throw new Error("row edit dialog should notice changed field values"); - } - if (closeRowEditDialogIfConfirmed(dirtyState)) { - throw new Error("dirty row edit dialog should stay open when discard is rejected"); - } - if (dirtyState.dialog.closeCalled) { - throw new Error("dirty row edit dialog should not close when discard is rejected"); - } - if (confirmMessages[0] !== "Discard unsaved changes to this row?") { - throw new Error(`Unexpected discard confirmation: ${confirmMessages[0]}`); - } - dirtyState.mode = "insert"; - window.confirm = (message) => { - confirmMessages.push(message); - return true; - }; - if (!closeRowEditDialogIfConfirmed(dirtyState)) { - throw new Error("dirty row edit dialog should close when discard is confirmed"); - } - if (!dirtyState.dialog.closeCalled || !dirtyState.shouldRestoreFocus) { - throw new Error("confirmed dirty row edit dialog should close and restore focus"); - } - if (confirmMessages[1] !== "Discard this new row?") { - throw new Error(`Unexpected insert discard confirmation: ${confirmMessages[1]}`); - } - - const cleanContext = columnFormControlContext( - "title", - false, - null, - { mode: "edit" } - ); - if (cleanContext.defaultExpression !== null) { - throw new Error("context.defaultExpression should be null without a SQLite default"); - } - const cleanControl = new FakeElement("input"); - cleanControl.name = "title"; - cleanControl.value = "clean"; - cleanControl.dataset.initialValue = "clean"; - cleanControl.dataset.initialValueKind = "string"; - cleanControl.dataset.currentValueKind = "string"; - const cleanField = createColumnFieldApi({ - id: "row-edit-field-1", - labelId: "row-edit-field-label-1", - descriptionId: "row-edit-field-meta-1", - control: cleanControl, - meta: new FakeElement("span"), - context: cleanContext, - }); - const cleanRowField = new FakeElement("div"); - cleanRowField._datasetteColumnFormField = cleanField; - const cleanState = { - hasLoaded: true, - isLoading: false, - isSaving: false, - mode: "edit", - fields: { - querySelectorAll(selector) { - return selector === ".row-edit-field" ? [cleanRowField] : []; - }, - }, - dialog: { - closeCalled: false, - close() { - this.closeCalled = true; - }, - }, - shouldRestoreFocus: false, - }; - confirmMessages.length = 0; - window.confirm = (message) => { - confirmMessages.push(message); - return false; - }; - if (rowEditDialogHasChanges(cleanState)) { - throw new Error("row edit dialog should ignore unchanged field values"); - } - if (!closeRowEditDialogIfConfirmed(cleanState)) { - throw new Error("clean row edit dialog should close without confirmation"); - } - if (!cleanState.dialog.closeCalled || !cleanState.shouldRestoreFocus) { - throw new Error("clean row edit dialog should close and restore focus"); - } - if (confirmMessages.length !== 0) { - throw new Error("clean row edit dialog should not ask for confirmation"); - } - - dirtyState.dialog.closeCalled = false; - dirtyState.shouldRestoreFocus = false; - confirmMessages.length = 0; - field.setValue("

"); - field.markClean(); - if (field.hasChanged()) { - throw new Error("field.markClean() should update the clean baseline"); - } - if (rowEditDialogHasChanges(dirtyState)) { - throw new Error("row edit dialog should ignore clean plugin normalization"); - } - if (!closeRowEditDialogIfConfirmed(dirtyState)) { - throw new Error("normalized row edit dialog should close without confirmation"); - } - if (confirmMessages.length !== 0) { - throw new Error("normalized row edit dialog should not ask for confirmation"); - } - field.setValue("

Hello

"); - if (!field.hasChanged() || !rowEditDialogHasChanges(dirtyState)) { - throw new Error("later plugin value changes should still count as dirty"); - } - - try { - field.setValue({ id: "df-object" }); - throw new Error("field.setValue() should reject object values"); - } catch (error) { - if (!String(error.message).includes("serialize objects")) { - throw error; - } - } - - field.setValidity("Pick a file"); - if (control.validationMessage !== "Pick a file") { - throw new Error("field.setValidity() should set custom validity"); - } - if (control.getAttribute("aria-invalid") !== "true") { - throw new Error("field.setValidity() should set aria-invalid"); - } - if (!field.validationMessageElement || field.validationMessageElement.hidden) { - throw new Error("field.setValidity() should show a field validation message"); - } - field.clearValidity(); - if (control.validationMessage !== "" || control.getAttribute("aria-invalid") !== null) { - throw new Error("field.clearValidity() should clear custom validity"); - } - - field.useSqliteDefault(); - if (!field.isUsingSqliteDefault() || !control.disabled) { - throw new Error("field.useSqliteDefault() should mark and disable control"); - } - - process.stdout.write("ok"); - """).replace("__EDIT_TOOLS_JS__", json.dumps(str(STATIC_DIR / "edit-tools.js"))) - result = subprocess.run( - ["node", "-e", script], - text=True, - capture_output=True, - check=False, - ) - assert result.returncode == 0, result.stderr - assert result.stdout == "ok" - - def test_builtin_json_column_field_validation(): script = textwrap.dedent(""" const fs = require("fs"); diff --git a/tests/test_playwright.py b/tests/test_playwright.py index 353f1d94..06ae826d 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -79,6 +79,20 @@ def test_datasette_homepage_contains_datasette(page, datasette_server): assert "Datasette" in page.locator("body").inner_text() +def load_edit_tools(page, datasette_server): + page.goto(datasette_server) + page.evaluate(""" + () => { + window._datasetteTableData = { + database: "data", + table: "projects", + tableUrl: "/data/projects", + }; + } + """) + page.add_script_tag(url=f"{datasette_server}-/static/edit-tools.js") + + @pytest.mark.playwright def test_navigation_search_tracks_and_renders_recent_items(page, datasette_server): page.goto(datasette_server) @@ -213,3 +227,318 @@ def test_datasette_manager_make_column_field(page, datasette_server): "pluginName": "handles", "useTextarea": True, } + + +@pytest.mark.playwright +def test_table_plugin_column_field_api(page, datasette_server): + load_edit_tools(page, datasette_server) + page.evaluate(""" + () => { + const assert = (condition, message) => { + if (!condition) { + throw new Error(message); + } + }; + + const context = columnFormControlContext( + "logo", + true, + { type: "file", config: null }, + { + mode: "edit", + defaultExpression: "lower(hex(randomblob(4)))", + useSqliteDefault: true, + }, + ); + const expectedContextKeys = [ + "mode", + "database", + "table", + "tableUrl", + "column", + "columnType", + "sqliteType", + "notNull", + "isPk", + "defaultExpression", + "form", + "dialog", + ].join(","); + assert( + Object.keys(context).join(",") === expectedContextKeys, + `Unexpected context keys: ${Object.keys(context).join(",")}`, + ); + assert( + context.defaultExpression === "lower(hex(randomblob(4)))", + "context.defaultExpression was not set", + ); + assert( + JSON.stringify(context.columnType) === '{"type":"file","config":{}}', + "context.columnType should expose type and object config", + ); + assert( + context.isPk, + "context.isPk should say whether the column is a primary key", + ); + + const control = document.createElement("input"); + control.name = "logo"; + control.value = "df-old"; + control.dataset.initialValue = "df-old"; + control.dataset.initialValueKind = "string"; + control.dataset.currentValueKind = "string"; + control.dataset.useSqliteDefault = "1"; + control.disabled = true; + const dispatchedEvents = []; + control.addEventListener("input", (event) => + dispatchedEvents.push(event.type), + ); + control.addEventListener("change", (event) => + dispatchedEvents.push(event.type), + ); + + const field = createColumnFieldApi({ + id: "row-edit-field-0", + labelId: "row-edit-field-label-0", + descriptionId: "row-edit-field-meta-0", + control, + meta: document.createElement("span"), + context, + }); + + let renderArgumentCount = null; + let renderField = null; + const wrapper = renderColumnField( + { + pluginName: "test-plugin", + render(field) { + renderArgumentCount = arguments.length; + renderField = field; + return document.createElement("button"); + }, + }, + field, + ); + assert( + renderArgumentCount === 1 && renderField === field, + "plugin render should receive the field object only", + ); + assert(field.root === wrapper, "field.root should be the plugin wrapper"); + assert( + wrapper.children.length === 1 && + wrapper.children[0].nodeName === "BUTTON", + "plugin render should append returned DOM nodes to field.root", + ); + + field.setValue(null); + assert(field.getValue() === null, "field.setValue(null) should round-trip as null"); + assert( + !field.isUsingSqliteDefault(), + "field.setValue() should stop using the SQLite default", + ); + assert( + control.dataset.currentValueKind === "null", + "null values should update currentValueKind", + ); + + field.setValue("df-new"); + assert( + field.getValue() === "df-new", + "field.setValue() should update the current value", + ); + assert( + field.getInitialValue() === "df-old", + "field.getInitialValue() should remain stable", + ); + assert( + field.hasChanged(), + "field.hasChanged() should notice plugin value changes", + ); + assert( + dispatchedEvents.length === 0, + `field.setValue() should not dispatch events: ${dispatchedEvents}`, + ); + + const dirtyRowField = document.createElement("div"); + dirtyRowField.className = "row-edit-field"; + dirtyRowField._datasetteColumnFormField = field; + const dirtyFields = document.createElement("div"); + dirtyFields.appendChild(dirtyRowField); + const dirtyState = { + hasLoaded: true, + isLoading: false, + isSaving: false, + mode: "edit", + fields: dirtyFields, + dialog: { + closeCalled: false, + close() { + this.closeCalled = true; + }, + }, + shouldRestoreFocus: false, + }; + const confirmMessages = []; + window.confirm = (message) => { + confirmMessages.push(message); + return false; + }; + assert( + rowEditDialogHasChanges(dirtyState), + "row edit dialog should notice changed field values", + ); + assert( + !closeRowEditDialogIfConfirmed(dirtyState), + "dirty row edit dialog should stay open when discard is rejected", + ); + assert( + !dirtyState.dialog.closeCalled, + "dirty row edit dialog should not close when discard is rejected", + ); + assert( + confirmMessages[0] === "Discard unsaved changes to this row?", + `Unexpected discard confirmation: ${confirmMessages[0]}`, + ); + dirtyState.mode = "insert"; + window.confirm = (message) => { + confirmMessages.push(message); + return true; + }; + assert( + closeRowEditDialogIfConfirmed(dirtyState), + "dirty row edit dialog should close when discard is confirmed", + ); + assert( + dirtyState.dialog.closeCalled && dirtyState.shouldRestoreFocus, + "confirmed dirty row edit dialog should close and restore focus", + ); + assert( + confirmMessages[1] === "Discard this new row?", + `Unexpected insert discard confirmation: ${confirmMessages[1]}`, + ); + + const cleanContext = columnFormControlContext("title", false, null, { + mode: "edit", + }); + assert( + cleanContext.defaultExpression === null, + "context.defaultExpression should be null without a SQLite default", + ); + const cleanControl = document.createElement("input"); + cleanControl.name = "title"; + cleanControl.value = "clean"; + cleanControl.dataset.initialValue = "clean"; + cleanControl.dataset.initialValueKind = "string"; + cleanControl.dataset.currentValueKind = "string"; + const cleanField = createColumnFieldApi({ + id: "row-edit-field-1", + labelId: "row-edit-field-label-1", + descriptionId: "row-edit-field-meta-1", + control: cleanControl, + meta: document.createElement("span"), + context: cleanContext, + }); + const cleanRowField = document.createElement("div"); + cleanRowField.className = "row-edit-field"; + cleanRowField._datasetteColumnFormField = cleanField; + const cleanFields = document.createElement("div"); + cleanFields.appendChild(cleanRowField); + const cleanState = { + hasLoaded: true, + isLoading: false, + isSaving: false, + mode: "edit", + fields: cleanFields, + dialog: { + closeCalled: false, + close() { + this.closeCalled = true; + }, + }, + shouldRestoreFocus: false, + }; + confirmMessages.length = 0; + window.confirm = (message) => { + confirmMessages.push(message); + return false; + }; + assert( + !rowEditDialogHasChanges(cleanState), + "row edit dialog should ignore unchanged field values", + ); + assert( + closeRowEditDialogIfConfirmed(cleanState), + "clean row edit dialog should close without confirmation", + ); + assert( + cleanState.dialog.closeCalled && cleanState.shouldRestoreFocus, + "clean row edit dialog should close and restore focus", + ); + assert( + confirmMessages.length === 0, + "clean row edit dialog should not ask for confirmation", + ); + + dirtyState.dialog.closeCalled = false; + dirtyState.shouldRestoreFocus = false; + confirmMessages.length = 0; + field.setValue("

"); + field.markClean(); + assert( + !field.hasChanged(), + "field.markClean() should update the clean baseline", + ); + assert( + !rowEditDialogHasChanges(dirtyState), + "row edit dialog should ignore clean plugin normalization", + ); + assert( + closeRowEditDialogIfConfirmed(dirtyState), + "normalized row edit dialog should close without confirmation", + ); + assert( + confirmMessages.length === 0, + "normalized row edit dialog should not ask for confirmation", + ); + field.setValue("

Hello

"); + assert( + field.hasChanged() && rowEditDialogHasChanges(dirtyState), + "later plugin value changes should still count as dirty", + ); + + try { + field.setValue({ id: "df-object" }); + throw new Error("field.setValue() should reject object values"); + } catch (error) { + if (!String(error.message).includes("serialize objects")) { + throw error; + } + } + + field.setValidity("Pick a file"); + assert( + control.validationMessage === "Pick a file", + "field.setValidity() should set custom validity", + ); + assert( + control.getAttribute("aria-invalid") === "true", + "field.setValidity() should set aria-invalid", + ); + assert( + field.validationMessageElement && !field.validationMessageElement.hidden, + "field.setValidity() should show a field validation message", + ); + field.clearValidity(); + assert( + control.validationMessage === "" && + control.getAttribute("aria-invalid") === null, + "field.clearValidity() should clear custom validity", + ); + + field.useSqliteDefault(); + assert( + field.isUsingSqliteDefault() && control.disabled, + "field.useSqliteDefault() should mark and disable control", + ); + } + """) From 387e309b3bed719b0e9b3c0ec4965cc8d8204626 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 14 Jun 2026 16:49:30 -0700 Subject: [PATCH 06/11] Port JSON column field test to Playwright Refs #2779 --- tests/test_datasette_manager_js.py | 202 ----------------------------- tests/test_playwright.py | 125 ++++++++++++++++++ 2 files changed, 125 insertions(+), 202 deletions(-) delete mode 100644 tests/test_datasette_manager_js.py diff --git a/tests/test_datasette_manager_js.py b/tests/test_datasette_manager_js.py deleted file mode 100644 index 9022d26f..00000000 --- a/tests/test_datasette_manager_js.py +++ /dev/null @@ -1,202 +0,0 @@ -import json -from pathlib import Path -import subprocess -import textwrap - -STATIC_DIR = Path(__file__).resolve().parents[1] / "datasette" / "static" - - -def test_builtin_json_column_field_validation(): - script = textwrap.dedent(""" - const fs = require("fs"); - const vm = require("vm"); - const editToolsJs = __EDIT_TOOLS_JS__; - - class FakeEvent { - constructor(type, options) { - this.type = type; - this.bubbles = !!(options && options.bubbles); - } - } - - class FakeElement { - constructor(tagName = "div") { - this.nodeName = tagName.toUpperCase(); - this.nodeType = 1; - this.children = []; - this.dataset = {}; - this.attributes = {}; - this.value = ""; - this.name = ""; - this.disabled = false; - this.hidden = false; - this.textContent = ""; - this.validationMessage = ""; - this.eventListeners = {}; - this.className = ""; - } - appendChild(child) { - this.children.push(child); - child.parentNode = this; - return child; - } - addEventListener(type, callback) { - this.eventListeners[type] = this.eventListeners[type] || []; - this.eventListeners[type].push(callback); - } - dispatchEvent(event) { - event.target = event.target || this; - for (const callback of this.eventListeners[event.type] || []) { - callback(event); - } - return true; - } - setAttribute(name, value) { - this.attributes[name] = String(value); - } - getAttribute(name) { - return this.attributes[name] || null; - } - removeAttribute(name) { - delete this.attributes[name]; - } - setCustomValidity(message) { - this.validationMessage = message; - } - } - - global.Event = FakeEvent; - global.document = { - addEventListener() {}, - createElement(tagName) { - return new FakeElement(tagName); - }, - createTextNode(text) { - const node = new FakeElement("#text"); - node.textContent = text; - return node; - }, - }; - global.location = { - href: "http://localhost/data/projects", - pathname: "/data/projects", - search: "", - }; - global.window = { - _datasetteTableData: { - database: "data", - table: "projects", - tableUrl: "/data/projects", - }, - }; - - vm.runInThisContext(fs.readFileSync(editToolsJs, "utf8"), { - filename: "edit-tools.js", - }); - - const plugins = []; - registerBuiltinColumnFieldPlugins({ - registerPlugin(name, plugin) { - plugins.push({ name, plugin }); - }, - }); - const jsonPlugin = plugins.find((entry) => entry.name === "datasette-json-column"); - if (!jsonPlugin) { - throw new Error("datasette-json-column plugin was not registered"); - } - const pluginControl = jsonPlugin.plugin.makeColumnField({ - column: "metadata", - columnType: { type: "json", config: {} }, - }); - if (!pluginControl || pluginControl.useTextarea !== true) { - throw new Error("JSON column plugin should request a textarea"); - } - - const context = columnFormControlContext( - "metadata", - false, - { type: "json", config: {} }, - { mode: "edit" } - ); - const control = new FakeElement("textarea"); - control.name = "metadata"; - control.value = '{"ok": true}'; - control.dataset.initialValue = '{"ok": true}'; - control.dataset.initialValueKind = "string"; - control.dataset.currentValueKind = "string"; - const meta = new FakeElement("span"); - - const field = createColumnFieldApi({ - id: "row-edit-field-0", - labelId: "row-edit-field-label-0", - descriptionId: "row-edit-field-meta-0", - control, - meta, - context, - }); - renderColumnField( - Object.assign({ pluginName: "datasette-json-column" }, pluginControl), - field - ); - - if (control.validationMessage !== "") { - throw new Error("Initial valid JSON should not be invalid"); - } - if (control.dataset.initialValueKind !== "string") { - throw new Error("JSON plugin should keep the original string value kind"); - } - if (control.dataset.currentValueKind !== "string") { - throw new Error("JSON plugin should keep the current string value kind"); - } - if (!field.validationMessageElement || field.validationMessageElement.hidden !== true) { - throw new Error("JSON validation message should start hidden"); - } - - control.value = "{"; - control.dispatchEvent(new Event("input", { bubbles: true })); - if (!control.validationMessage.startsWith("Invalid JSON")) { - throw new Error("Invalid JSON should set a custom validity message"); - } - if (control.getAttribute("aria-invalid") !== "true") { - throw new Error("Invalid JSON should set aria-invalid"); - } - if (field.validationMessageElement.hidden) { - throw new Error("Invalid JSON should show the validation message"); - } - - control.value = '{"ok": true}'; - control.dispatchEvent(new Event("input", { bubbles: true })); - if (control.validationMessage !== "") { - throw new Error("Valid JSON should clear the custom validity message"); - } - if (control.getAttribute("aria-invalid") !== null) { - throw new Error("Valid JSON should clear aria-invalid"); - } - if (!field.validationMessageElement.hidden) { - throw new Error("Valid JSON should hide the validation message"); - } - - control.dataset.initialValue = '{"ok":'; - control.value = '{"ok": true}'; - const values = collectRowFormValues({ - mode: "edit", - fields: { - querySelectorAll() { - return [control]; - }, - }, - }); - if (values.metadata !== '{"ok": true}') { - throw new Error("Corrected JSON should be submitted as a string value"); - } - - process.stdout.write("ok"); - """).replace("__EDIT_TOOLS_JS__", json.dumps(str(STATIC_DIR / "edit-tools.js"))) - result = subprocess.run( - ["node", "-e", script], - text=True, - capture_output=True, - check=False, - ) - assert result.returncode == 0, result.stderr - assert result.stdout == "ok" diff --git a/tests/test_playwright.py b/tests/test_playwright.py index 06ae826d..a2d05d80 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -542,3 +542,128 @@ def test_table_plugin_column_field_api(page, datasette_server): ); } """) + + +@pytest.mark.playwright +def test_builtin_json_column_field_validation(page, datasette_server): + load_edit_tools(page, datasette_server) + page.evaluate(""" + () => { + const assert = (condition, message) => { + if (!condition) { + throw new Error(message); + } + }; + + const plugins = []; + registerBuiltinColumnFieldPlugins({ + registerPlugin(name, plugin) { + plugins.push({ name, plugin }); + }, + }); + const jsonPlugin = plugins.find( + (entry) => entry.name === "datasette-json-column", + ); + assert( + jsonPlugin, + "datasette-json-column plugin was not registered", + ); + const pluginControl = jsonPlugin.plugin.makeColumnField({ + column: "metadata", + columnType: { type: "json", config: {} }, + }); + assert( + pluginControl && pluginControl.useTextarea === true, + "JSON column plugin should request a textarea", + ); + + const context = columnFormControlContext( + "metadata", + false, + { type: "json", config: {} }, + { mode: "edit" }, + ); + const control = document.createElement("textarea"); + control.className = "row-edit-input"; + control.name = "metadata"; + control.value = '{"ok": true}'; + control.dataset.initialValue = '{"ok": true}'; + control.dataset.initialValueKind = "string"; + control.dataset.currentValueKind = "string"; + const meta = document.createElement("span"); + + const field = createColumnFieldApi({ + id: "row-edit-field-0", + labelId: "row-edit-field-label-0", + descriptionId: "row-edit-field-meta-0", + control, + meta, + context, + }); + const wrapper = renderColumnField( + Object.assign({ pluginName: "datasette-json-column" }, pluginControl), + field, + ); + + assert( + control.validationMessage === "", + "Initial valid JSON should not be invalid", + ); + assert( + control.dataset.initialValueKind === "string", + "JSON plugin should keep the original string value kind", + ); + assert( + control.dataset.currentValueKind === "string", + "JSON plugin should keep the current string value kind", + ); + assert( + field.validationMessageElement && + field.validationMessageElement.hidden === true, + "JSON validation message should start hidden", + ); + + control.value = "{"; + control.dispatchEvent(new Event("input", { bubbles: true })); + assert( + control.validationMessage.startsWith("Invalid JSON"), + "Invalid JSON should set a custom validity message", + ); + assert( + control.getAttribute("aria-invalid") === "true", + "Invalid JSON should set aria-invalid", + ); + assert( + !field.validationMessageElement.hidden, + "Invalid JSON should show the validation message", + ); + + control.value = '{"ok": true}'; + control.dispatchEvent(new Event("input", { bubbles: true })); + assert( + control.validationMessage === "", + "Valid JSON should clear the custom validity message", + ); + assert( + control.getAttribute("aria-invalid") === null, + "Valid JSON should clear aria-invalid", + ); + assert( + field.validationMessageElement.hidden, + "Valid JSON should hide the validation message", + ); + + control.dataset.initialValue = '{"ok":'; + control.value = '{"ok": true}'; + const fields = document.createElement("div"); + fields.appendChild(wrapper); + const values = collectRowFormValues({ + mode: "edit", + fields, + }); + assert( + values.metadata === '{"ok": true}', + "Corrected JSON should be submitted as a string value", + ); + } + """) From 0748b561d5265da6e78a48b065fd691dea67a078 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 14 Jun 2026 16:52:07 -0700 Subject: [PATCH 07/11] Document Playwright test commands Refs #2779 --- docs/contributing.rst | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docs/contributing.rst b/docs/contributing.rst index 5a109fda..d4fcb19d 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -62,6 +62,33 @@ You can run the tests faster using multiple CPU cores with `pytest-xdist Date: Tue, 16 Jun 2026 13:35:15 -0700 Subject: [PATCH 08/11] Just recipes for running Playwright tests Plus the pytest header now reports selected Playwright browsers --- Justfile | 16 ++++++++++++++++ docs/contributing.rst | 44 ++++++++++++++++++++++++++++++------------- tests/conftest.py | 12 +++++++++++- 3 files changed, 58 insertions(+), 14 deletions(-) diff --git a/Justfile b/Justfile index 657881be..5fcd9afd 100644 --- a/Justfile +++ b/Justfile @@ -11,6 +11,22 @@ export DATASETTE_SECRET := "not_a_secret" @test *options: init uv run pytest -n auto {{options}} +# Install Playwright browser support, Chromium by default +@playwright-install browser="chromium": + uv run --group playwright playwright install {{browser}} + +# Install all Playwright browsers used by the test suite +@playwright-install-all: + uv run --group playwright playwright install chromium firefox webkit + +# Run Playwright tests, Chromium by default +@playwright browser="chromium" *options: + uv run --group playwright pytest tests/test_playwright.py --playwright --browser {{browser}} {{options}} + +# Run Playwright tests against all supported browsers +@playwright-all *options: + uv run --group playwright pytest tests/test_playwright.py --playwright --browser chromium --browser firefox --browser webkit {{options}} + @codespell: uv run codespell README.md --ignore-words docs/codespell-ignore-words.txt uv run codespell docs/*.rst --ignore-words docs/codespell-ignore-words.txt diff --git a/docs/contributing.rst b/docs/contributing.rst index d4fcb19d..0d365f3a 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -71,24 +71,42 @@ These tests are skipped by default, so you can run the main test suite with .. _Playwright: https://playwright.dev/python/ -The Playwright tests use a separate dependency group. To run them, first install -the browser engine you want to test against, for example Chromium:: +The Playwright tests use a separate dependency group. The easiest way to run +them is using ``just``. First install the browser engine you want to test +against. Chromium is used by default:: + + just playwright-install + +Then run the Playwright test module:: + + just playwright + +You can also run the same tests against Firefox or WebKit by installing that +browser engine and passing it to ``just playwright``:: + + just playwright-install firefox + just playwright firefox + + just playwright-install webkit + just playwright webkit + +To install every supported browser engine and run the tests against all of +them, use:: + + just playwright-install-all + just playwright-all + +You can pass extra ``pytest`` options after the browser name:: + + just playwright chromium -k permissions + just playwright-all -x + +If you are not using ``just``, the equivalent Chromium commands are:: uv run --group playwright playwright install chromium -Then run the Playwright test module with the explicit ``--playwright`` flag:: - uv run --group playwright pytest tests/test_playwright.py --playwright --browser chromium -You can also run the same tests against Firefox or WebKit by installing that -browser engine and passing it to ``--browser``:: - - uv run --group playwright playwright install firefox - uv run --group playwright pytest tests/test_playwright.py --playwright --browser firefox - - uv run --group playwright playwright install webkit - uv run --group playwright pytest tests/test_playwright.py --playwright --browser webkit - .. _contributing_using_fixtures: Using fixtures diff --git a/tests/conftest.py b/tests/conftest.py index 04b6f8be..55110a42 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -93,7 +93,17 @@ def pytest_report_header(config): conn = sqlite3.connect(":memory:") version = conn.execute("select sqlite_version()").fetchone()[0] conn.close() - return "SQLite: {}".format(version) + headers = ["SQLite: {}".format(version)] + if config.getoption("--playwright"): + try: + browsers = config.getoption("--browser") + except ValueError: + browsers = None + if isinstance(browsers, str): + browsers = [browsers] + if browsers: + headers.append("Playwright browsers: {}".format(", ".join(browsers))) + return headers def pytest_addoption(parser): From 2f27b0840598470b6d9c0fe026f84f214fbbec8b Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 16 Jun 2026 13:41:24 -0700 Subject: [PATCH 09/11] Use code-block:: bash for Playwright docs --- docs/contributing.rst | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index 0d365f3a..f28b9bcb 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -73,16 +73,22 @@ These tests are skipped by default, so you can run the main test suite with The Playwright tests use a separate dependency group. The easiest way to run them is using ``just``. First install the browser engine you want to test -against. Chromium is used by default:: +against. Chromium is used by default: + +.. code-block:: bash just playwright-install -Then run the Playwright test module:: +Then run the Playwright test module: + +.. code-block:: bash just playwright You can also run the same tests against Firefox or WebKit by installing that -browser engine and passing it to ``just playwright``:: +browser engine and passing it to ``just playwright``: + +.. code-block:: bash just playwright-install firefox just playwright firefox @@ -91,17 +97,23 @@ browser engine and passing it to ``just playwright``:: just playwright webkit To install every supported browser engine and run the tests against all of -them, use:: +them, use: + +.. code-block:: bash just playwright-install-all just playwright-all -You can pass extra ``pytest`` options after the browser name:: +You can pass extra ``pytest`` options after the browser name: + +.. code-block:: bash just playwright chromium -k permissions just playwright-all -x -If you are not using ``just``, the equivalent Chromium commands are:: +If you are not using ``just``, the equivalent Chromium commands are: + +.. code-block:: bash uv run --group playwright playwright install chromium From a8124172dd39b270452d74cf4bb7c2fe1cd12b64 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 16 Jun 2026 13:54:00 -0700 Subject: [PATCH 10/11] Remove load_edit_tools(), use a new fixture instead Refs https://github.com/simonw/datasette/pull/2785/changes#r3423913454 --- tests/test_playwright.py | 45 +++++++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/tests/test_playwright.py b/tests/test_playwright.py index a2d05d80..b35de8db 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -7,6 +7,7 @@ import httpx import pytest from datasette.fixtures import write_fixture_database +from datasette.utils.sqlite import sqlite3 def find_free_port(): @@ -39,15 +40,18 @@ def wait_for_server(process, url, timeout=10): @pytest.fixture def datasette_server(tmp_path): - db_path = tmp_path / "fixtures.db" - write_fixture_database(str(db_path)) + fixtures_db_path = tmp_path / "fixtures.db" + write_fixture_database(str(fixtures_db_path)) + data_db_path = tmp_path / "data.db" + write_playwright_database(str(data_db_path)) port = find_free_port() process = subprocess.Popen( [ sys.executable, "-m", "datasette", - str(db_path), + str(fixtures_db_path), + str(data_db_path), "--host", "127.0.0.1", "--port", @@ -73,26 +77,29 @@ def datasette_server(tmp_path): process.wait() +def write_playwright_database(db_path): + conn = sqlite3.connect(db_path) + try: + conn.executescript(""" + create table projects ( + id integer primary key, + title text not null, + metadata text, + logo text + ); + insert into projects (title, metadata, logo) values + ('Build Datasette', '{"ok": true}', 'df-old'); + """) + finally: + conn.close() + + @pytest.mark.playwright def test_datasette_homepage_contains_datasette(page, datasette_server): page.goto(datasette_server) assert "Datasette" in page.locator("body").inner_text() -def load_edit_tools(page, datasette_server): - page.goto(datasette_server) - page.evaluate(""" - () => { - window._datasetteTableData = { - database: "data", - table: "projects", - tableUrl: "/data/projects", - }; - } - """) - page.add_script_tag(url=f"{datasette_server}-/static/edit-tools.js") - - @pytest.mark.playwright def test_navigation_search_tracks_and_renders_recent_items(page, datasette_server): page.goto(datasette_server) @@ -231,7 +238,7 @@ def test_datasette_manager_make_column_field(page, datasette_server): @pytest.mark.playwright def test_table_plugin_column_field_api(page, datasette_server): - load_edit_tools(page, datasette_server) + page.goto(f"{datasette_server}data/projects") page.evaluate(""" () => { const assert = (condition, message) => { @@ -546,7 +553,7 @@ def test_table_plugin_column_field_api(page, datasette_server): @pytest.mark.playwright def test_builtin_json_column_field_validation(page, datasette_server): - load_edit_tools(page, datasette_server) + page.goto(f"{datasette_server}data/projects") page.evaluate(""" () => { const assert = (condition, message) => { From 3edc98a871f27fd53a068fd3c4e6a0c6a32b6274 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 16 Jun 2026 14:13:44 -0700 Subject: [PATCH 11/11] Playwright tests exercise the real UI now Refs https://github.com/simonw/datasette/pull/2785#issuecomment-4723583959 --- tests/test_playwright.py | 820 +++++++++++++-------------------------- 1 file changed, 274 insertions(+), 546 deletions(-) diff --git a/tests/test_playwright.py b/tests/test_playwright.py index b35de8db..a8c5aa4b 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -1,3 +1,4 @@ +import json import socket import subprocess import sys @@ -44,6 +45,10 @@ def datasette_server(tmp_path): write_fixture_database(str(fixtures_db_path)) data_db_path = tmp_path / "data.db" write_playwright_database(str(data_db_path)) + config_path = tmp_path / "datasette.json" + write_playwright_config(config_path) + plugins_dir = tmp_path / "plugins" + write_playwright_plugin(plugins_dir) port = find_free_port() process = subprocess.Popen( [ @@ -52,6 +57,10 @@ def datasette_server(tmp_path): "datasette", str(fixtures_db_path), str(data_db_path), + "--config", + str(config_path), + "--plugins-dir", + str(plugins_dir), "--host", "127.0.0.1", "--port", @@ -85,15 +94,181 @@ def write_playwright_database(db_path): id integer primary key, title text not null, metadata text, - logo text + logo text, + notes text, + score integer default 5 ); - insert into projects (title, metadata, logo) values - ('Build Datasette', '{"ok": true}', 'df-old'); + insert into projects (title, metadata, logo, notes, score) values + ( + 'Build Datasette', + '{"ok": true}', + 'asset-original', + 'Initial notes', + 5 + ); """) finally: conn.close() +def write_playwright_config(config_path): + config_path.write_text( + json.dumps( + { + "databases": { + "data": { + "tables": { + "projects": { + "label_column": "title", + "column_types": { + "metadata": "json", + "logo": "asset", + "notes": "textarea", + }, + "permissions": { + "insert-row": True, + "update-row": True, + "delete-row": True, + }, + }, + }, + }, + }, + } + ), + "utf-8", + ) + + +def write_playwright_plugin(plugins_dir): + plugins_dir.mkdir() + (plugins_dir / "playwright_plugin.py").write_text( + ''' +from datasette import hookimpl +from datasette.column_types import ColumnType, SQLiteType + + +class AssetColumnType(ColumnType): + name = "asset" + description = "Demo asset picker" + sqlite_types = (SQLiteType.TEXT,) + + +@hookimpl +def register_column_types(datasette): + return [AssetColumnType] + + +@hookimpl +def extra_body_script(): + return { + "module": True, + "script": """ +document.addEventListener("datasette_init", function (event) { + event.detail.registerPlugin("playwright-jump-section", { + version: "0.1", + makeJumpSections() { + return [ + { + id: "agent-chat", + render(node, context) { + if (!context.navigationSearch || !context.input) { + throw new Error("Expected navigation search context"); + } + node.innerHTML = [ + '
', + '', + '
', + ].join(""); + node.querySelector("button").addEventListener("click", function () { + window.location.href = "/-/playwright-agent"; + }); + }, + }, + ]; + }, + }); + + event.detail.registerPlugin("playwright-asset-field", { + version: "0.1", + makeColumnField(context) { + if (!context.columnType || context.columnType.type !== "asset") { + return; + } + return { + render(field) { + const wrapper = document.createElement("div"); + wrapper.className = "playwright-asset-picker"; + wrapper.dataset.column = field.context.column; + wrapper.dataset.database = field.context.database || ""; + wrapper.dataset.table = field.context.table || ""; + wrapper.dataset.tableUrl = field.context.tableUrl || ""; + wrapper.dataset.mode = field.context.mode || ""; + wrapper.dataset.columnType = field.context.columnType.type; + + field.input.type = "hidden"; + const value = document.createElement("span"); + value.className = "playwright-asset-value"; + const button = document.createElement("button"); + button.type = "button"; + button.className = "playwright-asset-select"; + button.textContent = "Use demo asset"; + + function sync() { + value.textContent = field.getValue() || "No asset selected"; + } + + button.addEventListener("click", function () { + field.setValue("asset-from-plugin"); + sync(); + }); + + wrapper.appendChild(field.input); + wrapper.appendChild(value); + wrapper.appendChild(button); + sync(); + return wrapper; + }, + focus(field) { + const button = field.root.querySelector(".playwright-asset-select"); + if (button) { + button.focus(); + } + }, + }; + }, + }); +}); +""", + } +''', + "utf-8", + ) + + +def project_rows(datasette_server, **filters): + params = { + "_shape": "objects", + **{key: str(value) for key, value in filters.items()}, + } + response = httpx.get(f"{datasette_server}data/projects.json", params=params) + response.raise_for_status() + return response.json()["rows"] + + +def project_row(datasette_server, pk): + rows = project_rows(datasette_server, id=pk) + assert len(rows) == 1 + return rows[0] + + +def open_jump_menu(page): + page.keyboard.press("/") + page.locator("navigation-search .search-input").wait_for() + + @pytest.mark.playwright def test_datasette_homepage_contains_datasette(page, datasette_server): page.goto(datasette_server) @@ -103,69 +278,24 @@ def test_datasette_homepage_contains_datasette(page, datasette_server): @pytest.mark.playwright def test_navigation_search_tracks_and_renders_recent_items(page, datasette_server): page.goto(datasette_server) - result = page.evaluate(""" - async () => { - await customElements.whenDefined("navigation-search"); - const element = document.querySelector("navigation-search"); - const key = element.recentItemsStorageKey(); - localStorage.removeItem(key); + open_jump_menu(page) + search = page.locator("navigation-search .search-input") + search.fill("projects") + result = page.locator("navigation-search .result-item", has_text="projects").first + result.wait_for() + result.click() + page.wait_for_url("**/data/projects") - const items = Array.from({ length: 6 }, (_, index) => ({ - name: `Item ${index + 1}`, - url: `/item-${index + 1}`, - type: "table", - description: "Table", - })); - items[5].name = "content: recent_datasette_releases"; - items[5].display_name = "Recent Datasette releases"; + page.goto(datasette_server) + open_jump_menu(page) + results = page.locator("navigation-search .results-container") + results.locator(".results-heading", has_text="Recent").wait_for() + assert "projects" in results.inner_text() - for (const item of items) { - element.saveRecentItem(item); - } - - const stored = JSON.parse(localStorage.getItem(key)); - element.matches = [ - items[5], - items[4], - { - name: "Other", - url: "/other", - type: "database", - description: "Database", - }, - ]; - element.shadowRoot.querySelector(".search-input").value = ""; - element.renderResults(); - const html = element.shadowRoot.querySelector(".results-container").innerHTML; - - element.clearRecentItems(); - const clearedValue = localStorage.getItem(key); - element.renderResults(); - const htmlAfterClear = element.shadowRoot - .querySelector(".results-container") - .innerHTML; - - return { stored, html, clearedValue, htmlAfterClear }; - } - """) - assert [item["url"] for item in result["stored"]] == [ - "/item-6", - "/item-5", - "/item-4", - "/item-3", - "/item-2", - ] - assert result["stored"][0]["display_name"] == "Recent Datasette releases" - assert "Recent" in result["html"] - assert "Recent Datasette releases" in result["html"] - assert "Item 5" in result["html"] - assert "content: recent_datasette_releases" in result["html"] - assert "Item 4" in result["html"] - assert "Item 2" in result["html"] - assert "Other" not in result["html"] - assert "Clear recent" in result["html"] - assert result["clearedValue"] is None - assert "Clear recent" not in result["htmlAfterClear"] + page.locator("navigation-search [data-clear-recent-items]").click() + page.locator("navigation-search .results-container", has_text="Recent").wait_for( + state="detached" + ) @pytest.mark.playwright @@ -173,504 +303,102 @@ def test_navigation_search_renders_jump_sections_from_javascript_plugins( page, datasette_server ): page.goto(datasette_server) - html = page.evaluate(""" - async () => { - await customElements.whenDefined("navigation-search"); - window.__DATASETTE__.registerPlugin("agent", { - version: "0.1", - makeJumpSections() { - return [ - { - id: "agent-chat", - render(node, context) { - if (!context.navigationSearch) { - throw new Error("Expected navigationSearch in render context"); - } - node.innerHTML = [ - '
', - '', - '
', - ].join(""); - }, - }, - ]; - }, - }); - - const element = document.querySelector("navigation-search"); - element.shadowRoot.querySelector(".search-input").value = ""; - element.renderResults(); - return element.shadowRoot.querySelector(".results-container").innerHTML; - } - """) - assert "Start a new agent chat" in html + open_jump_menu(page) + button = page.locator("navigation-search [data-playwright-agent-chat]") + button.wait_for() + assert button.inner_text() == "Start a new agent chat" + button.click() + page.wait_for_url("**/-/playwright-agent") @pytest.mark.playwright -def test_datasette_manager_make_column_field(page, datasette_server): - page.goto(datasette_server) - control = page.evaluate(""" - () => { - window.__DATASETTE__.registerPlugin("declines", { - makeColumnField() { - return; - }, - }); - window.__DATASETTE__.registerPlugin("handles", { - makeColumnField(context) { - if (context.columnType.type !== "demo") { - return; - } - return { useTextarea: true }; - }, - }); - return window.__DATASETTE__.makeColumnField({ - column: "body", - columnType: { type: "demo", config: null }, - }); - } - """) - assert control == { - "pluginName": "handles", - "useTextarea": True, - } - - -@pytest.mark.playwright -def test_table_plugin_column_field_api(page, datasette_server): +def test_insert_row_flow_uses_custom_column_field(page, datasette_server): page.goto(f"{datasette_server}data/projects") - page.evaluate(""" - () => { - const assert = (condition, message) => { - if (!condition) { - throw new Error(message); - } - }; + page.locator('button[data-table-action="insert-row"]').click() - const context = columnFormControlContext( - "logo", - true, - { type: "file", config: null }, - { - mode: "edit", - defaultExpression: "lower(hex(randomblob(4)))", - useSqliteDefault: true, - }, - ); - const expectedContextKeys = [ - "mode", - "database", - "table", - "tableUrl", - "column", - "columnType", - "sqliteType", - "notNull", - "isPk", - "defaultExpression", - "form", - "dialog", - ].join(","); - assert( - Object.keys(context).join(",") === expectedContextKeys, - `Unexpected context keys: ${Object.keys(context).join(",")}`, - ); - assert( - context.defaultExpression === "lower(hex(randomblob(4)))", - "context.defaultExpression was not set", - ); - assert( - JSON.stringify(context.columnType) === '{"type":"file","config":{}}', - "context.columnType should expose type and object config", - ); - assert( - context.isPk, - "context.isPk should say whether the column is a primary key", - ); + dialog = page.locator("#row-edit-dialog") + dialog.wait_for() + dialog.locator('input[name="title"]').fill("Launch Datasette Cloud") + dialog.locator('textarea[name="metadata"]').fill( + '{"ok": false, "source": "playwright"}' + ) + dialog.locator('textarea[name="notes"]').fill("Inserted from Playwright") - const control = document.createElement("input"); - control.name = "logo"; - control.value = "df-old"; - control.dataset.initialValue = "df-old"; - control.dataset.initialValueKind = "string"; - control.dataset.currentValueKind = "string"; - control.dataset.useSqliteDefault = "1"; - control.disabled = true; - const dispatchedEvents = []; - control.addEventListener("input", (event) => - dispatchedEvents.push(event.type), - ); - control.addEventListener("change", (event) => - dispatchedEvents.push(event.type), - ); + asset = dialog.locator(".playwright-asset-picker") + asset.wait_for() + assert asset.get_attribute("data-column") == "logo" + assert asset.get_attribute("data-database") == "data" + assert asset.get_attribute("data-table") == "projects" + assert asset.get_attribute("data-mode") == "insert" + asset.locator(".playwright-asset-select").click() + assert asset.locator(".playwright-asset-value").inner_text() == "asset-from-plugin" - const field = createColumnFieldApi({ - id: "row-edit-field-0", - labelId: "row-edit-field-label-0", - descriptionId: "row-edit-field-meta-0", - control, - meta: document.createElement("span"), - context, - }); + dialog.locator(".row-edit-save").click() + page.locator(".row-mutation-status", has_text="Inserted row 2").wait_for() + row = page.locator('tr[data-row="2"]') + row.wait_for() + assert "Launch Datasette Cloud" in row.inner_text() - let renderArgumentCount = null; - let renderField = null; - const wrapper = renderColumnField( - { - pluginName: "test-plugin", - render(field) { - renderArgumentCount = arguments.length; - renderField = field; - return document.createElement("button"); - }, - }, - field, - ); - assert( - renderArgumentCount === 1 && renderField === field, - "plugin render should receive the field object only", - ); - assert(field.root === wrapper, "field.root should be the plugin wrapper"); - assert( - wrapper.children.length === 1 && - wrapper.children[0].nodeName === "BUTTON", - "plugin render should append returned DOM nodes to field.root", - ); - - field.setValue(null); - assert(field.getValue() === null, "field.setValue(null) should round-trip as null"); - assert( - !field.isUsingSqliteDefault(), - "field.setValue() should stop using the SQLite default", - ); - assert( - control.dataset.currentValueKind === "null", - "null values should update currentValueKind", - ); - - field.setValue("df-new"); - assert( - field.getValue() === "df-new", - "field.setValue() should update the current value", - ); - assert( - field.getInitialValue() === "df-old", - "field.getInitialValue() should remain stable", - ); - assert( - field.hasChanged(), - "field.hasChanged() should notice plugin value changes", - ); - assert( - dispatchedEvents.length === 0, - `field.setValue() should not dispatch events: ${dispatchedEvents}`, - ); - - const dirtyRowField = document.createElement("div"); - dirtyRowField.className = "row-edit-field"; - dirtyRowField._datasetteColumnFormField = field; - const dirtyFields = document.createElement("div"); - dirtyFields.appendChild(dirtyRowField); - const dirtyState = { - hasLoaded: true, - isLoading: false, - isSaving: false, - mode: "edit", - fields: dirtyFields, - dialog: { - closeCalled: false, - close() { - this.closeCalled = true; - }, - }, - shouldRestoreFocus: false, - }; - const confirmMessages = []; - window.confirm = (message) => { - confirmMessages.push(message); - return false; - }; - assert( - rowEditDialogHasChanges(dirtyState), - "row edit dialog should notice changed field values", - ); - assert( - !closeRowEditDialogIfConfirmed(dirtyState), - "dirty row edit dialog should stay open when discard is rejected", - ); - assert( - !dirtyState.dialog.closeCalled, - "dirty row edit dialog should not close when discard is rejected", - ); - assert( - confirmMessages[0] === "Discard unsaved changes to this row?", - `Unexpected discard confirmation: ${confirmMessages[0]}`, - ); - dirtyState.mode = "insert"; - window.confirm = (message) => { - confirmMessages.push(message); - return true; - }; - assert( - closeRowEditDialogIfConfirmed(dirtyState), - "dirty row edit dialog should close when discard is confirmed", - ); - assert( - dirtyState.dialog.closeCalled && dirtyState.shouldRestoreFocus, - "confirmed dirty row edit dialog should close and restore focus", - ); - assert( - confirmMessages[1] === "Discard this new row?", - `Unexpected insert discard confirmation: ${confirmMessages[1]}`, - ); - - const cleanContext = columnFormControlContext("title", false, null, { - mode: "edit", - }); - assert( - cleanContext.defaultExpression === null, - "context.defaultExpression should be null without a SQLite default", - ); - const cleanControl = document.createElement("input"); - cleanControl.name = "title"; - cleanControl.value = "clean"; - cleanControl.dataset.initialValue = "clean"; - cleanControl.dataset.initialValueKind = "string"; - cleanControl.dataset.currentValueKind = "string"; - const cleanField = createColumnFieldApi({ - id: "row-edit-field-1", - labelId: "row-edit-field-label-1", - descriptionId: "row-edit-field-meta-1", - control: cleanControl, - meta: document.createElement("span"), - context: cleanContext, - }); - const cleanRowField = document.createElement("div"); - cleanRowField.className = "row-edit-field"; - cleanRowField._datasetteColumnFormField = cleanField; - const cleanFields = document.createElement("div"); - cleanFields.appendChild(cleanRowField); - const cleanState = { - hasLoaded: true, - isLoading: false, - isSaving: false, - mode: "edit", - fields: cleanFields, - dialog: { - closeCalled: false, - close() { - this.closeCalled = true; - }, - }, - shouldRestoreFocus: false, - }; - confirmMessages.length = 0; - window.confirm = (message) => { - confirmMessages.push(message); - return false; - }; - assert( - !rowEditDialogHasChanges(cleanState), - "row edit dialog should ignore unchanged field values", - ); - assert( - closeRowEditDialogIfConfirmed(cleanState), - "clean row edit dialog should close without confirmation", - ); - assert( - cleanState.dialog.closeCalled && cleanState.shouldRestoreFocus, - "clean row edit dialog should close and restore focus", - ); - assert( - confirmMessages.length === 0, - "clean row edit dialog should not ask for confirmation", - ); - - dirtyState.dialog.closeCalled = false; - dirtyState.shouldRestoreFocus = false; - confirmMessages.length = 0; - field.setValue("

"); - field.markClean(); - assert( - !field.hasChanged(), - "field.markClean() should update the clean baseline", - ); - assert( - !rowEditDialogHasChanges(dirtyState), - "row edit dialog should ignore clean plugin normalization", - ); - assert( - closeRowEditDialogIfConfirmed(dirtyState), - "normalized row edit dialog should close without confirmation", - ); - assert( - confirmMessages.length === 0, - "normalized row edit dialog should not ask for confirmation", - ); - field.setValue("

Hello

"); - assert( - field.hasChanged() && rowEditDialogHasChanges(dirtyState), - "later plugin value changes should still count as dirty", - ); - - try { - field.setValue({ id: "df-object" }); - throw new Error("field.setValue() should reject object values"); - } catch (error) { - if (!String(error.message).includes("serialize objects")) { - throw error; - } - } - - field.setValidity("Pick a file"); - assert( - control.validationMessage === "Pick a file", - "field.setValidity() should set custom validity", - ); - assert( - control.getAttribute("aria-invalid") === "true", - "field.setValidity() should set aria-invalid", - ); - assert( - field.validationMessageElement && !field.validationMessageElement.hidden, - "field.setValidity() should show a field validation message", - ); - field.clearValidity(); - assert( - control.validationMessage === "" && - control.getAttribute("aria-invalid") === null, - "field.clearValidity() should clear custom validity", - ); - - field.useSqliteDefault(); - assert( - field.isUsingSqliteDefault() && control.disabled, - "field.useSqliteDefault() should mark and disable control", - ); - } - """) + data = project_row(datasette_server, 2) + assert data["title"] == "Launch Datasette Cloud" + assert data["metadata"] == '{"ok": false, "source": "playwright"}' + assert data["logo"] == "asset-from-plugin" + assert data["notes"] == "Inserted from Playwright" + assert data["score"] == 5 @pytest.mark.playwright -def test_builtin_json_column_field_validation(page, datasette_server): +def test_edit_row_flow_validates_json_and_saves_changes(page, datasette_server): page.goto(f"{datasette_server}data/projects") - page.evaluate(""" - () => { - const assert = (condition, message) => { - if (!condition) { - throw new Error(message); - } - }; + page.locator('tr[data-row="1"] button[data-row-action="edit"]').click() - const plugins = []; - registerBuiltinColumnFieldPlugins({ - registerPlugin(name, plugin) { - plugins.push({ name, plugin }); - }, - }); - const jsonPlugin = plugins.find( - (entry) => entry.name === "datasette-json-column", - ); - assert( - jsonPlugin, - "datasette-json-column plugin was not registered", - ); - const pluginControl = jsonPlugin.plugin.makeColumnField({ - column: "metadata", - columnType: { type: "json", config: {} }, - }); - assert( - pluginControl && pluginControl.useTextarea === true, - "JSON column plugin should request a textarea", - ); + dialog = page.locator("#row-edit-dialog") + dialog.wait_for() + title = dialog.locator('input[name="title"]') + title.wait_for() + title.fill("Build Datasette, edited") - const context = columnFormControlContext( - "metadata", - false, - { type: "json", config: {} }, - { mode: "edit" }, - ); - const control = document.createElement("textarea"); - control.className = "row-edit-input"; - control.name = "metadata"; - control.value = '{"ok": true}'; - control.dataset.initialValue = '{"ok": true}'; - control.dataset.initialValueKind = "string"; - control.dataset.currentValueKind = "string"; - const meta = document.createElement("span"); + metadata = dialog.locator('textarea[name="metadata"]') + metadata.fill("{") + dialog.locator( + ".row-edit-field-validation-error", has_text="Invalid JSON" + ).wait_for() + dialog.locator(".row-edit-save").click() + assert dialog.evaluate("node => node.open") + assert project_row(datasette_server, 1)["title"] == "Build Datasette" - const field = createColumnFieldApi({ - id: "row-edit-field-0", - labelId: "row-edit-field-label-0", - descriptionId: "row-edit-field-meta-0", - control, - meta, - context, - }); - const wrapper = renderColumnField( - Object.assign({ pluginName: "datasette-json-column" }, pluginControl), - field, - ); + metadata.fill('{"ok": true, "edited": true}') + dialog.locator( + ".row-edit-field-validation-error", has_text="Invalid JSON" + ).wait_for(state="hidden") + dialog.locator('textarea[name="notes"]').fill("Edited from Playwright") + asset = dialog.locator(".playwright-asset-picker") + asset.wait_for() + assert asset.get_attribute("data-mode") == "edit" + asset.locator(".playwright-asset-select").click() - assert( - control.validationMessage === "", - "Initial valid JSON should not be invalid", - ); - assert( - control.dataset.initialValueKind === "string", - "JSON plugin should keep the original string value kind", - ); - assert( - control.dataset.currentValueKind === "string", - "JSON plugin should keep the current string value kind", - ); - assert( - field.validationMessageElement && - field.validationMessageElement.hidden === true, - "JSON validation message should start hidden", - ); + dialog.locator(".row-edit-save").click() + page.locator(".row-mutation-status", has_text="Updated row 1").wait_for() + row = page.locator('tr[data-row="1"]') + assert "Build Datasette, edited" in row.inner_text() - control.value = "{"; - control.dispatchEvent(new Event("input", { bubbles: true })); - assert( - control.validationMessage.startsWith("Invalid JSON"), - "Invalid JSON should set a custom validity message", - ); - assert( - control.getAttribute("aria-invalid") === "true", - "Invalid JSON should set aria-invalid", - ); - assert( - !field.validationMessageElement.hidden, - "Invalid JSON should show the validation message", - ); + data = project_row(datasette_server, 1) + assert data["title"] == "Build Datasette, edited" + assert data["metadata"] == '{"ok": true, "edited": true}' + assert data["logo"] == "asset-from-plugin" + assert data["notes"] == "Edited from Playwright" - control.value = '{"ok": true}'; - control.dispatchEvent(new Event("input", { bubbles: true })); - assert( - control.validationMessage === "", - "Valid JSON should clear the custom validity message", - ); - assert( - control.getAttribute("aria-invalid") === null, - "Valid JSON should clear aria-invalid", - ); - assert( - field.validationMessageElement.hidden, - "Valid JSON should hide the validation message", - ); - control.dataset.initialValue = '{"ok":'; - control.value = '{"ok": true}'; - const fields = document.createElement("div"); - fields.appendChild(wrapper); - const values = collectRowFormValues({ - mode: "edit", - fields, - }); - assert( - values.metadata === '{"ok": true}', - "Corrected JSON should be submitted as a string value", - ); - } - """) +@pytest.mark.playwright +def test_delete_row_flow_removes_row(page, datasette_server): + page.goto(f"{datasette_server}data/projects") + page.locator('tr[data-row="1"] button[data-row-action="delete"]').click() + + dialog = page.locator("#row-delete-dialog") + dialog.wait_for() + assert "Delete row 1" in dialog.inner_text() + dialog.locator(".row-delete-confirm").click() + + page.locator(".row-mutation-status", has_text="Deleted row 1").wait_for() + page.locator('tr[data-row="1"]').wait_for(state="detached") + assert project_rows(datasette_server, id=1) == []