mirror of
https://github.com/simonw/datasette.git
synced 2026-06-18 06:47:50 +02:00
Test Datasette JavaScript features using Playwright
Merge pull request #2785 from simonw/playwright-tests
This commit is contained in:
commit
39a397e5b7
9 changed files with 557 additions and 1056 deletions
48
.github/workflows/playwright.yml
vendored
Normal file
48
.github/workflows/playwright.yml
vendored
Normal 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 }}
|
||||
16
Justfile
16
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -81,6 +81,9 @@ dev = [
|
|||
"ruamel.yaml",
|
||||
"psutil>=5.9",
|
||||
]
|
||||
playwright = [
|
||||
"pytest-playwright>=0.8.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
rich = ["rich"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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, ">")
|
||||
.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, ">")
|
||||
.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 = [
|
||||
'<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
404
tests/test_playwright.py
Normal 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) == []
|
||||
Loading…
Add table
Add a link
Reference in a new issue