import socket
import subprocess
import sys
import time
import httpx
import pytest
from datasette.fixtures import write_fixture_database
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):
db_path = tmp_path / "fixtures.db"
write_fixture_database(str(db_path))
port = find_free_port()
process = subprocess.Popen(
[
sys.executable,
"-m",
"datasette",
str(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()
@pytest.mark.playwright
def test_datasette_homepage_contains_datasette(page, datasette_server):
page.goto(datasette_server)
assert "Datasette" in page.locator("body").inner_text()
def load_edit_tools(page, datasette_server):
page.goto(datasette_server)
page.evaluate("""
() => {
window._datasetteTableData = {
database: "data",
table: "projects",
tableUrl: "/data/projects",
};
}
""")
page.add_script_tag(url=f"{datasette_server}-/static/edit-tools.js")
@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 = [
'
Hello
"); 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): load_edit_tools(page, datasette_server) 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", ); } """)