mirror of
https://github.com/simonw/datasette.git
synced 2026-06-18 14:57:48 +02:00
676 lines
20 KiB
Python
676 lines
20 KiB
Python
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))
|
|
port = find_free_port()
|
|
process = subprocess.Popen(
|
|
[
|
|
sys.executable,
|
|
"-m",
|
|
"datasette",
|
|
str(fixtures_db_path),
|
|
str(data_db_path),
|
|
"--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
|
|
);
|
|
insert into projects (title, metadata, logo) values
|
|
('Build Datasette', '{"ok": true}', 'df-old');
|
|
""")
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@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)
|
|
result = page.evaluate("""
|
|
async () => {
|
|
await customElements.whenDefined("navigation-search");
|
|
const element = document.querySelector("navigation-search");
|
|
const key = element.recentItemsStorageKey();
|
|
localStorage.removeItem(key);
|
|
|
|
const items = Array.from({ length: 6 }, (_, index) => ({
|
|
name: `Item ${index + 1}`,
|
|
url: `/item-${index + 1}`,
|
|
type: "table",
|
|
description: "Table",
|
|
}));
|
|
items[5].name = "content: recent_datasette_releases";
|
|
items[5].display_name = "Recent Datasette releases";
|
|
|
|
for (const item of items) {
|
|
element.saveRecentItem(item);
|
|
}
|
|
|
|
const stored = JSON.parse(localStorage.getItem(key));
|
|
element.matches = [
|
|
items[5],
|
|
items[4],
|
|
{
|
|
name: "Other",
|
|
url: "/other",
|
|
type: "database",
|
|
description: "Database",
|
|
},
|
|
];
|
|
element.shadowRoot.querySelector(".search-input").value = "";
|
|
element.renderResults();
|
|
const html = element.shadowRoot.querySelector(".results-container").innerHTML;
|
|
|
|
element.clearRecentItems();
|
|
const clearedValue = localStorage.getItem(key);
|
|
element.renderResults();
|
|
const htmlAfterClear = element.shadowRoot
|
|
.querySelector(".results-container")
|
|
.innerHTML;
|
|
|
|
return { stored, html, clearedValue, htmlAfterClear };
|
|
}
|
|
""")
|
|
assert [item["url"] for item in result["stored"]] == [
|
|
"/item-6",
|
|
"/item-5",
|
|
"/item-4",
|
|
"/item-3",
|
|
"/item-2",
|
|
]
|
|
assert result["stored"][0]["display_name"] == "Recent Datasette releases"
|
|
assert "Recent" in result["html"]
|
|
assert "Recent Datasette releases" in result["html"]
|
|
assert "Item 5" in result["html"]
|
|
assert "content: recent_datasette_releases" in result["html"]
|
|
assert "Item 4" in result["html"]
|
|
assert "Item 2" in result["html"]
|
|
assert "Other" not in result["html"]
|
|
assert "Clear recent" in result["html"]
|
|
assert result["clearedValue"] is None
|
|
assert "Clear recent" not in result["htmlAfterClear"]
|
|
|
|
|
|
@pytest.mark.playwright
|
|
def test_navigation_search_renders_jump_sections_from_javascript_plugins(
|
|
page, datasette_server
|
|
):
|
|
page.goto(datasette_server)
|
|
html = page.evaluate("""
|
|
async () => {
|
|
await customElements.whenDefined("navigation-search");
|
|
window.__DATASETTE__.registerPlugin("agent", {
|
|
version: "0.1",
|
|
makeJumpSections() {
|
|
return [
|
|
{
|
|
id: "agent-chat",
|
|
render(node, context) {
|
|
if (!context.navigationSearch) {
|
|
throw new Error("Expected navigationSearch in render context");
|
|
}
|
|
node.innerHTML = [
|
|
'<section class="agent-jump-start">',
|
|
'<button>Start a new agent chat</button>',
|
|
'</section>',
|
|
].join("");
|
|
},
|
|
},
|
|
];
|
|
},
|
|
});
|
|
|
|
const element = document.querySelector("navigation-search");
|
|
element.shadowRoot.querySelector(".search-input").value = "";
|
|
element.renderResults();
|
|
return element.shadowRoot.querySelector(".results-container").innerHTML;
|
|
}
|
|
""")
|
|
assert "Start a new agent chat" in html
|
|
|
|
|
|
@pytest.mark.playwright
|
|
def test_datasette_manager_make_column_field(page, datasette_server):
|
|
page.goto(datasette_server)
|
|
control = page.evaluate("""
|
|
() => {
|
|
window.__DATASETTE__.registerPlugin("declines", {
|
|
makeColumnField() {
|
|
return;
|
|
},
|
|
});
|
|
window.__DATASETTE__.registerPlugin("handles", {
|
|
makeColumnField(context) {
|
|
if (context.columnType.type !== "demo") {
|
|
return;
|
|
}
|
|
return { useTextarea: true };
|
|
},
|
|
});
|
|
return window.__DATASETTE__.makeColumnField({
|
|
column: "body",
|
|
columnType: { type: "demo", config: null },
|
|
});
|
|
}
|
|
""")
|
|
assert control == {
|
|
"pluginName": "handles",
|
|
"useTextarea": True,
|
|
}
|
|
|
|
|
|
@pytest.mark.playwright
|
|
def test_table_plugin_column_field_api(page, datasette_server):
|
|
page.goto(f"{datasette_server}data/projects")
|
|
page.evaluate("""
|
|
() => {
|
|
const assert = (condition, message) => {
|
|
if (!condition) {
|
|
throw new Error(message);
|
|
}
|
|
};
|
|
|
|
const context = columnFormControlContext(
|
|
"logo",
|
|
true,
|
|
{ type: "file", config: null },
|
|
{
|
|
mode: "edit",
|
|
defaultExpression: "lower(hex(randomblob(4)))",
|
|
useSqliteDefault: true,
|
|
},
|
|
);
|
|
const expectedContextKeys = [
|
|
"mode",
|
|
"database",
|
|
"table",
|
|
"tableUrl",
|
|
"column",
|
|
"columnType",
|
|
"sqliteType",
|
|
"notNull",
|
|
"isPk",
|
|
"defaultExpression",
|
|
"form",
|
|
"dialog",
|
|
].join(",");
|
|
assert(
|
|
Object.keys(context).join(",") === expectedContextKeys,
|
|
`Unexpected context keys: ${Object.keys(context).join(",")}`,
|
|
);
|
|
assert(
|
|
context.defaultExpression === "lower(hex(randomblob(4)))",
|
|
"context.defaultExpression was not set",
|
|
);
|
|
assert(
|
|
JSON.stringify(context.columnType) === '{"type":"file","config":{}}',
|
|
"context.columnType should expose type and object config",
|
|
);
|
|
assert(
|
|
context.isPk,
|
|
"context.isPk should say whether the column is a primary key",
|
|
);
|
|
|
|
const control = document.createElement("input");
|
|
control.name = "logo";
|
|
control.value = "df-old";
|
|
control.dataset.initialValue = "df-old";
|
|
control.dataset.initialValueKind = "string";
|
|
control.dataset.currentValueKind = "string";
|
|
control.dataset.useSqliteDefault = "1";
|
|
control.disabled = true;
|
|
const dispatchedEvents = [];
|
|
control.addEventListener("input", (event) =>
|
|
dispatchedEvents.push(event.type),
|
|
);
|
|
control.addEventListener("change", (event) =>
|
|
dispatchedEvents.push(event.type),
|
|
);
|
|
|
|
const field = createColumnFieldApi({
|
|
id: "row-edit-field-0",
|
|
labelId: "row-edit-field-label-0",
|
|
descriptionId: "row-edit-field-meta-0",
|
|
control,
|
|
meta: document.createElement("span"),
|
|
context,
|
|
});
|
|
|
|
let renderArgumentCount = null;
|
|
let renderField = null;
|
|
const wrapper = renderColumnField(
|
|
{
|
|
pluginName: "test-plugin",
|
|
render(field) {
|
|
renderArgumentCount = arguments.length;
|
|
renderField = field;
|
|
return document.createElement("button");
|
|
},
|
|
},
|
|
field,
|
|
);
|
|
assert(
|
|
renderArgumentCount === 1 && renderField === field,
|
|
"plugin render should receive the field object only",
|
|
);
|
|
assert(field.root === wrapper, "field.root should be the plugin wrapper");
|
|
assert(
|
|
wrapper.children.length === 1 &&
|
|
wrapper.children[0].nodeName === "BUTTON",
|
|
"plugin render should append returned DOM nodes to field.root",
|
|
);
|
|
|
|
field.setValue(null);
|
|
assert(field.getValue() === null, "field.setValue(null) should round-trip as null");
|
|
assert(
|
|
!field.isUsingSqliteDefault(),
|
|
"field.setValue() should stop using the SQLite default",
|
|
);
|
|
assert(
|
|
control.dataset.currentValueKind === "null",
|
|
"null values should update currentValueKind",
|
|
);
|
|
|
|
field.setValue("df-new");
|
|
assert(
|
|
field.getValue() === "df-new",
|
|
"field.setValue() should update the current value",
|
|
);
|
|
assert(
|
|
field.getInitialValue() === "df-old",
|
|
"field.getInitialValue() should remain stable",
|
|
);
|
|
assert(
|
|
field.hasChanged(),
|
|
"field.hasChanged() should notice plugin value changes",
|
|
);
|
|
assert(
|
|
dispatchedEvents.length === 0,
|
|
`field.setValue() should not dispatch events: ${dispatchedEvents}`,
|
|
);
|
|
|
|
const dirtyRowField = document.createElement("div");
|
|
dirtyRowField.className = "row-edit-field";
|
|
dirtyRowField._datasetteColumnFormField = field;
|
|
const dirtyFields = document.createElement("div");
|
|
dirtyFields.appendChild(dirtyRowField);
|
|
const dirtyState = {
|
|
hasLoaded: true,
|
|
isLoading: false,
|
|
isSaving: false,
|
|
mode: "edit",
|
|
fields: dirtyFields,
|
|
dialog: {
|
|
closeCalled: false,
|
|
close() {
|
|
this.closeCalled = true;
|
|
},
|
|
},
|
|
shouldRestoreFocus: false,
|
|
};
|
|
const confirmMessages = [];
|
|
window.confirm = (message) => {
|
|
confirmMessages.push(message);
|
|
return false;
|
|
};
|
|
assert(
|
|
rowEditDialogHasChanges(dirtyState),
|
|
"row edit dialog should notice changed field values",
|
|
);
|
|
assert(
|
|
!closeRowEditDialogIfConfirmed(dirtyState),
|
|
"dirty row edit dialog should stay open when discard is rejected",
|
|
);
|
|
assert(
|
|
!dirtyState.dialog.closeCalled,
|
|
"dirty row edit dialog should not close when discard is rejected",
|
|
);
|
|
assert(
|
|
confirmMessages[0] === "Discard unsaved changes to this row?",
|
|
`Unexpected discard confirmation: ${confirmMessages[0]}`,
|
|
);
|
|
dirtyState.mode = "insert";
|
|
window.confirm = (message) => {
|
|
confirmMessages.push(message);
|
|
return true;
|
|
};
|
|
assert(
|
|
closeRowEditDialogIfConfirmed(dirtyState),
|
|
"dirty row edit dialog should close when discard is confirmed",
|
|
);
|
|
assert(
|
|
dirtyState.dialog.closeCalled && dirtyState.shouldRestoreFocus,
|
|
"confirmed dirty row edit dialog should close and restore focus",
|
|
);
|
|
assert(
|
|
confirmMessages[1] === "Discard this new row?",
|
|
`Unexpected insert discard confirmation: ${confirmMessages[1]}`,
|
|
);
|
|
|
|
const cleanContext = columnFormControlContext("title", false, null, {
|
|
mode: "edit",
|
|
});
|
|
assert(
|
|
cleanContext.defaultExpression === null,
|
|
"context.defaultExpression should be null without a SQLite default",
|
|
);
|
|
const cleanControl = document.createElement("input");
|
|
cleanControl.name = "title";
|
|
cleanControl.value = "clean";
|
|
cleanControl.dataset.initialValue = "clean";
|
|
cleanControl.dataset.initialValueKind = "string";
|
|
cleanControl.dataset.currentValueKind = "string";
|
|
const cleanField = createColumnFieldApi({
|
|
id: "row-edit-field-1",
|
|
labelId: "row-edit-field-label-1",
|
|
descriptionId: "row-edit-field-meta-1",
|
|
control: cleanControl,
|
|
meta: document.createElement("span"),
|
|
context: cleanContext,
|
|
});
|
|
const cleanRowField = document.createElement("div");
|
|
cleanRowField.className = "row-edit-field";
|
|
cleanRowField._datasetteColumnFormField = cleanField;
|
|
const cleanFields = document.createElement("div");
|
|
cleanFields.appendChild(cleanRowField);
|
|
const cleanState = {
|
|
hasLoaded: true,
|
|
isLoading: false,
|
|
isSaving: false,
|
|
mode: "edit",
|
|
fields: cleanFields,
|
|
dialog: {
|
|
closeCalled: false,
|
|
close() {
|
|
this.closeCalled = true;
|
|
},
|
|
},
|
|
shouldRestoreFocus: false,
|
|
};
|
|
confirmMessages.length = 0;
|
|
window.confirm = (message) => {
|
|
confirmMessages.push(message);
|
|
return false;
|
|
};
|
|
assert(
|
|
!rowEditDialogHasChanges(cleanState),
|
|
"row edit dialog should ignore unchanged field values",
|
|
);
|
|
assert(
|
|
closeRowEditDialogIfConfirmed(cleanState),
|
|
"clean row edit dialog should close without confirmation",
|
|
);
|
|
assert(
|
|
cleanState.dialog.closeCalled && cleanState.shouldRestoreFocus,
|
|
"clean row edit dialog should close and restore focus",
|
|
);
|
|
assert(
|
|
confirmMessages.length === 0,
|
|
"clean row edit dialog should not ask for confirmation",
|
|
);
|
|
|
|
dirtyState.dialog.closeCalled = false;
|
|
dirtyState.shouldRestoreFocus = false;
|
|
confirmMessages.length = 0;
|
|
field.setValue("<p></p>");
|
|
field.markClean();
|
|
assert(
|
|
!field.hasChanged(),
|
|
"field.markClean() should update the clean baseline",
|
|
);
|
|
assert(
|
|
!rowEditDialogHasChanges(dirtyState),
|
|
"row edit dialog should ignore clean plugin normalization",
|
|
);
|
|
assert(
|
|
closeRowEditDialogIfConfirmed(dirtyState),
|
|
"normalized row edit dialog should close without confirmation",
|
|
);
|
|
assert(
|
|
confirmMessages.length === 0,
|
|
"normalized row edit dialog should not ask for confirmation",
|
|
);
|
|
field.setValue("<p>Hello</p>");
|
|
assert(
|
|
field.hasChanged() && rowEditDialogHasChanges(dirtyState),
|
|
"later plugin value changes should still count as dirty",
|
|
);
|
|
|
|
try {
|
|
field.setValue({ id: "df-object" });
|
|
throw new Error("field.setValue() should reject object values");
|
|
} catch (error) {
|
|
if (!String(error.message).includes("serialize objects")) {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
field.setValidity("Pick a file");
|
|
assert(
|
|
control.validationMessage === "Pick a file",
|
|
"field.setValidity() should set custom validity",
|
|
);
|
|
assert(
|
|
control.getAttribute("aria-invalid") === "true",
|
|
"field.setValidity() should set aria-invalid",
|
|
);
|
|
assert(
|
|
field.validationMessageElement && !field.validationMessageElement.hidden,
|
|
"field.setValidity() should show a field validation message",
|
|
);
|
|
field.clearValidity();
|
|
assert(
|
|
control.validationMessage === "" &&
|
|
control.getAttribute("aria-invalid") === null,
|
|
"field.clearValidity() should clear custom validity",
|
|
);
|
|
|
|
field.useSqliteDefault();
|
|
assert(
|
|
field.isUsingSqliteDefault() && control.disabled,
|
|
"field.useSqliteDefault() should mark and disable control",
|
|
);
|
|
}
|
|
""")
|
|
|
|
|
|
@pytest.mark.playwright
|
|
def test_builtin_json_column_field_validation(page, datasette_server):
|
|
page.goto(f"{datasette_server}data/projects")
|
|
page.evaluate("""
|
|
() => {
|
|
const assert = (condition, message) => {
|
|
if (!condition) {
|
|
throw new Error(message);
|
|
}
|
|
};
|
|
|
|
const plugins = [];
|
|
registerBuiltinColumnFieldPlugins({
|
|
registerPlugin(name, plugin) {
|
|
plugins.push({ name, plugin });
|
|
},
|
|
});
|
|
const jsonPlugin = plugins.find(
|
|
(entry) => entry.name === "datasette-json-column",
|
|
);
|
|
assert(
|
|
jsonPlugin,
|
|
"datasette-json-column plugin was not registered",
|
|
);
|
|
const pluginControl = jsonPlugin.plugin.makeColumnField({
|
|
column: "metadata",
|
|
columnType: { type: "json", config: {} },
|
|
});
|
|
assert(
|
|
pluginControl && pluginControl.useTextarea === true,
|
|
"JSON column plugin should request a textarea",
|
|
);
|
|
|
|
const context = columnFormControlContext(
|
|
"metadata",
|
|
false,
|
|
{ type: "json", config: {} },
|
|
{ mode: "edit" },
|
|
);
|
|
const control = document.createElement("textarea");
|
|
control.className = "row-edit-input";
|
|
control.name = "metadata";
|
|
control.value = '{"ok": true}';
|
|
control.dataset.initialValue = '{"ok": true}';
|
|
control.dataset.initialValueKind = "string";
|
|
control.dataset.currentValueKind = "string";
|
|
const meta = document.createElement("span");
|
|
|
|
const field = createColumnFieldApi({
|
|
id: "row-edit-field-0",
|
|
labelId: "row-edit-field-label-0",
|
|
descriptionId: "row-edit-field-meta-0",
|
|
control,
|
|
meta,
|
|
context,
|
|
});
|
|
const wrapper = renderColumnField(
|
|
Object.assign({ pluginName: "datasette-json-column" }, pluginControl),
|
|
field,
|
|
);
|
|
|
|
assert(
|
|
control.validationMessage === "",
|
|
"Initial valid JSON should not be invalid",
|
|
);
|
|
assert(
|
|
control.dataset.initialValueKind === "string",
|
|
"JSON plugin should keep the original string value kind",
|
|
);
|
|
assert(
|
|
control.dataset.currentValueKind === "string",
|
|
"JSON plugin should keep the current string value kind",
|
|
);
|
|
assert(
|
|
field.validationMessageElement &&
|
|
field.validationMessageElement.hidden === true,
|
|
"JSON validation message should start hidden",
|
|
);
|
|
|
|
control.value = "{";
|
|
control.dispatchEvent(new Event("input", { bubbles: true }));
|
|
assert(
|
|
control.validationMessage.startsWith("Invalid JSON"),
|
|
"Invalid JSON should set a custom validity message",
|
|
);
|
|
assert(
|
|
control.getAttribute("aria-invalid") === "true",
|
|
"Invalid JSON should set aria-invalid",
|
|
);
|
|
assert(
|
|
!field.validationMessageElement.hidden,
|
|
"Invalid JSON should show the validation message",
|
|
);
|
|
|
|
control.value = '{"ok": true}';
|
|
control.dispatchEvent(new Event("input", { bubbles: true }));
|
|
assert(
|
|
control.validationMessage === "",
|
|
"Valid JSON should clear the custom validity message",
|
|
);
|
|
assert(
|
|
control.getAttribute("aria-invalid") === null,
|
|
"Valid JSON should clear aria-invalid",
|
|
);
|
|
assert(
|
|
field.validationMessageElement.hidden,
|
|
"Valid JSON should hide the validation message",
|
|
);
|
|
|
|
control.dataset.initialValue = '{"ok":';
|
|
control.value = '{"ok": true}';
|
|
const fields = document.createElement("div");
|
|
fields.appendChild(wrapper);
|
|
const values = collectRowFormValues({
|
|
mode: "edit",
|
|
fields,
|
|
});
|
|
assert(
|
|
values.metadata === '{"ok": true}',
|
|
"Corrected JSON should be submitted as a string value",
|
|
);
|
|
}
|
|
""")
|