Test Datasette JavaScript features using Playwright

Merge pull request #2785 from simonw/playwright-tests
This commit is contained in:
Simon Willison 2026-06-16 14:16:20 -07:00 committed by GitHub
commit 39a397e5b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 557 additions and 1056 deletions

48
.github/workflows/playwright.yml vendored Normal file
View file

@ -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 }}

View file

@ -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

View file

@ -62,6 +62,63 @@ You can run the tests faster using multiple CPU cores with `pytest-xdist <https:
uv run pytest -m "serial"
Running Playwright tests
~~~~~~~~~~~~~~~~~~~~~~~~
Datasette includes a small number of browser automation tests using Playwright_.
These tests are skipped by default, so you can run the main test suite with
``uv run pytest`` without installing Playwright or any browser binaries.
.. _Playwright: https://playwright.dev/python/
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:
.. code-block:: bash
just playwright-install
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``:
.. code-block:: bash
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:
.. code-block:: bash
just playwright-install-all
just playwright-all
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:
.. code-block:: bash
uv run --group playwright playwright install chromium
uv run --group playwright pytest tests/test_playwright.py --playwright --browser chromium
.. _contributing_using_fixtures:
Using fixtures

View file

@ -81,6 +81,9 @@ dev = [
"ruamel.yaml",
"psutil>=5.9",
]
playwright = [
"pytest-playwright>=0.8.0",
]
[project.optional-dependencies]
rich = ["rich"]

View file

@ -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
playwright: browser automation tests, skipped unless --playwright is passed
asyncio_mode = strict

View file

@ -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")

View file

@ -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("<p></p>");
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("<p>Hello</p>");
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"

View file

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
},
};
},
};
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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 = [
'<section class="agent-jump-start">',
'<button>Start a new agent chat</button>',
'</section>',
].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")

404
tests/test_playwright.py Normal file
View file

@ -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 = [
'<section class="agent-jump-start">',
'<button type="button" data-playwright-agent-chat>',
'Start a new agent chat',
'</button>',
'</section>',
].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) == []