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) == []