From 047b69e87f0c94bbd4e59ba151731859158e25e3 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 14 Jun 2026 16:44:17 -0700 Subject: [PATCH] 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"]