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/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 5a109fda..f28b9bcb 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -62,6 +62,63 @@ You can run the tests faster using multiple CPU cores with `pytest-xdist =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..55110a42 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -93,7 +93,26 @@ 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): + parser.addoption( + "--playwright", + action="store_true", + default=False, + help="run Playwright browser automation tests", + ) def pytest_configure(config): @@ -108,7 +127,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_datasette_manager_js.py b/tests/test_datasette_manager_js.py deleted file mode 100644 index 01b6bbac..00000000 --- a/tests/test_datasette_manager_js.py +++ /dev/null @@ -1,659 +0,0 @@ -import json -from pathlib import Path -import subprocess -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"); - 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"); - 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_navigation_search_js.py b/tests/test_navigation_search_js.py deleted file mode 100644 index b487357d..00000000 --- a/tests/test_navigation_search_js.py +++ /dev/null @@ -1,394 +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_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(""" - 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 new file mode 100644 index 00000000..a8c5aa4b --- /dev/null +++ b/tests/test_playwright.py @@ -0,0 +1,404 @@ +import json +import socket +import subprocess +import sys +import time + +import httpx +import pytest + +from datasette.fixtures import write_fixture_database +from datasette.utils.sqlite import sqlite3 + + +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): + 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)) + 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( + [ + sys.executable, + "-m", + "datasette", + str(fixtures_db_path), + str(data_db_path), + "--config", + str(config_path), + "--plugins-dir", + str(plugins_dir), + "--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() + + +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, + notes text, + score integer default 5 + ); + 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) + 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) + 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") + + 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() + + 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 +def test_navigation_search_renders_jump_sections_from_javascript_plugins( + page, datasette_server +): + page.goto(datasette_server) + 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_insert_row_flow_uses_custom_column_field(page, datasette_server): + page.goto(f"{datasette_server}data/projects") + page.locator('button[data-table-action="insert-row"]').click() + + 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") + + 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" + + 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() + + 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_edit_row_flow_validates_json_and_saves_changes(page, datasette_server): + page.goto(f"{datasette_server}data/projects") + page.locator('tr[data-row="1"] button[data-row-action="edit"]').click() + + dialog = page.locator("#row-edit-dialog") + dialog.wait_for() + title = dialog.locator('input[name="title"]') + title.wait_for() + title.fill("Build Datasette, edited") + + 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" + + 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() + + 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() + + 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" + + +@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) == []