datasette/tests/test_playwright.py
Daniel Shafer 69cfd16fcf
Offer "Facet by this" for the first column of a view
The column menu skipped the facet option for whichever column came
first, which was meant to skip the special link column on tables.
Views have no link column, so their first real column lost the option.
Check data-is-link-column instead.

Closes #1785
2026-10-06 14:30:08 +00:00

2316 lines
89 KiB
Python

import base64
import json
import socket
import subprocess
import sys
import time
import httpx2
import pytest
from datasette.fixtures import write_fixture_database
from datasette.utils.sqlite import sqlite3
PNG_1X1_BYTES = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="
)
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=30):
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 = httpx2.get(url, timeout=1.0)
if response.status_code < 500:
return
last_error = f"HTTP {response.status_code}: {response.text[:200]}"
except httpx2.HTTPError as ex:
last_error = repr(ex)
time.sleep(0.1)
if process.poll() is None:
process.terminate()
try:
stdout, stderr = process.communicate(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
stdout, stderr = process.communicate()
else:
stdout, stderr = process.communicate()
raise AssertionError(
f"Timed out waiting for {url}: {last_error}\n"
f"stdout:\n{stdout}\n"
f"stderr:\n{stderr}"
)
@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 count_numbers (id integer primary key);
with recursive sequence(id) as (
select 1 union all select id + 1 from sequence where id < 10002
)
insert into count_numbers select id from sequence;
create table projects (
id integer primary key,
title text not null,
metadata text,
logo text,
notes text,
score integer default 5
);
create table defaults_demo (
id integer primary key,
created_ms integer default (CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER))
);
create table binary_files (
id integer primary key,
name text not null,
data blob
);
create table bulk_defaults (
id integer primary key,
title text not null,
status text not null default 'todo',
score integer default 5
);
create table upsert_items (
id text primary key,
title text,
metadata text
);
insert into projects (title, metadata, logo, notes, score) values
(
'Build Datasette',
'{"ok": true}',
'asset-original',
'Initial notes',
5
);
insert into upsert_items (id, title, metadata) values
('existing', 'Existing title', '{"old": true}');
""")
conn.execute(
"insert into binary_files (name, data) values (?, ?)",
("Raw bytes", b"\x00\x01\x02\x03"),
)
conn.execute(
"insert into binary_files (name, data) values (?, ?)",
("PNG image", PNG_1X1_BYTES),
)
conn.execute(
"insert into binary_files (name, data) values (?, ?)",
("Null bytes", None),
)
conn.commit()
finally:
conn.close()
def write_playwright_config(config_path):
config_path.write_text(
json.dumps(
{
"databases": {
"data": {
"permissions": {
"create-table": True,
"insert-row": True,
"set-column-type": True,
},
"tables": {
"projects": {
"label_column": "title",
"column_types": {
"metadata": "json",
"logo": "asset",
"notes": "textarea",
},
"permissions": {
"alter-table": True,
"insert-row": True,
"update-row": True,
"delete-row": True,
},
},
"defaults_demo": {
"permissions": {
"alter-table": True,
},
},
"binary_files": {
"label_column": "name",
"permissions": {
"insert-row": True,
"update-row": True,
"delete-row": True,
},
},
"bulk_defaults": {
"permissions": {
"insert-row": True,
},
},
"upsert_items": {
"permissions": {
"insert-row": True,
"update-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 = httpx2.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 binary_file_blob(datasette_server, pk):
response = httpx2.get(
f"{datasette_server}data/binary_files/{pk}.blob",
params={"_blob_column": "data"},
)
response.raise_for_status()
return response.content
def wait_for_binary_control_file(binary_control, size, name=None):
binary_control.locator(
".row-edit-binary-size", has_text=f"Binary: {size} bytes"
).wait_for()
if name:
binary_control.locator(".row-edit-binary-name", has_text=name).wait_for()
def bulk_default_rows(datasette_server, **filters):
params = {
"_shape": "objects",
**{key: str(value) for key, value in filters.items()},
}
response = httpx2.get(f"{datasette_server}data/bulk_defaults.json", params=params)
response.raise_for_status()
return response.json()["rows"]
def upsert_item_rows(datasette_server, **filters):
params = {
"_shape": "objects",
**{key: str(value) for key, value in filters.items()},
}
response = httpx2.get(f"{datasette_server}data/upsert_items.json", params=params)
response.raise_for_status()
return response.json()["rows"]
def upsert_item_row(datasette_server, pk):
rows = upsert_item_rows(datasette_server, id=pk)
assert len(rows) == 1
return rows[0]
def open_bulk_insert_dialog(page, url):
page.goto(url)
page.locator('button[data-table-action="insert-row"]').click()
dialog = page.locator("#row-edit-dialog")
dialog.wait_for()
dialog.locator(".row-edit-bulk-insert").click()
return dialog
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_create_table_flow(page, datasette_server):
page.goto(f"{datasette_server}data")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-database-action="create-table"]').click()
dialog = page.locator("#table-create-dialog")
dialog.wait_for()
assert dialog.locator(".modal-title").inner_text() == "Create a table in data"
placeholder_select = dialog.locator(".table-create-custom-column-type").nth(0)
assert placeholder_select.input_value() == ""
assert (
placeholder_select.locator("option:checked").inner_text() == "- custom type -"
)
assert "table-create-input-placeholder" in placeholder_select.get_attribute("class")
assert (
dialog.locator(".table-create-column-name").nth(0).get_attribute("placeholder")
== "column name"
)
assert dialog.locator(".table-create-column-main").first.evaluate("""node => {
const inputHeight = node.querySelector(
".table-create-column-name"
).getBoundingClientRect().height;
const selectHeight = node.querySelector(
".table-create-column-type"
).getBoundingClientRect().height;
return Math.abs(inputHeight - selectHeight) <= 1;
}""")
dialog.locator('input[name="table"]').fill("playwright_created")
dialog.locator(".table-create-column-name").nth(1).fill("title")
dialog.locator(".table-create-more-options").nth(1).click()
dialog.locator(".table-create-not-null-input").nth(1).check()
title_defaults = dialog.locator(".table-create-default-options").nth(1)
assert title_defaults.locator("summary").inner_text() == "Set a default value"
title_defaults.locator("summary").click()
assert "or default to a specific value" in title_defaults.inner_text()
title_default_expr = title_defaults.locator(".table-create-default-expr")
title_default_input = title_defaults.locator(".table-create-default")
assert (
"Current timestamp in UTC, e.g. 2026-05-01 13:34:00"
in title_default_expr.locator("option").nth(1).inner_text()
)
title_default_expr.select_option("current_timestamp")
assert title_default_input.is_enabled()
title_default_input.fill("Untitled")
assert title_default_expr.input_value() == ""
dialog.locator(".table-create-add-column").click()
dialog.locator(".table-create-column-name").nth(2).fill("score")
dialog.locator(".table-create-column-type").nth(2).select_option("integer")
dialog.locator(".table-create-add-column").click()
dialog.locator(".table-create-column-name").nth(3).fill("metadata")
dialog.locator(".table-create-column-type").nth(3).select_option("integer")
dialog.locator(".table-create-more-options").nth(3).click()
dialog.locator(".table-create-custom-column-type").nth(3).select_option("json")
assert dialog.locator(".table-create-column-type").nth(3).input_value() == "text"
assert "table-create-input-placeholder" not in dialog.locator(
".table-create-custom-column-type"
).nth(3).get_attribute("class")
dialog.locator(".table-create-save").click()
page.wait_for_url("**/data/playwright_created")
assert "playwright_created" in page.locator("h1").inner_text()
response = httpx2.get(
f"{datasette_server}data/playwright_created.json?_extra=columns,column_types"
)
response.raise_for_status()
data = response.json()
assert data["columns"] == [
"id",
"title",
"score",
"metadata",
]
assert data["column_types"] == {
"metadata": {"type": "json", "config": None},
}
schema_response = httpx2.get(
f"{datasette_server}data/-/query.json",
params={
"sql": (
"select sql from sqlite_master where type = 'table' "
"and name = 'playwright_created'"
)
},
)
schema_response.raise_for_status()
schema = schema_response.json()["rows"][0]["sql"]
assert "title" in schema
assert "NOT NULL DEFAULT 'Untitled'" in schema
@pytest.mark.playwright
def test_create_table_from_data_flow(page, datasette_server):
page.goto(f"{datasette_server}data")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-database-action="create-table"]').click()
dialog = page.locator("#table-create-dialog")
dialog.wait_for()
from_data = dialog.locator(".table-create-from-data")
assert from_data.inner_text() == "Create table from data"
from_data.click()
assert dialog.locator(".table-create-columns").is_hidden()
assert dialog.locator(".table-create-data").is_visible()
assert dialog.locator(".table-create-save").inner_text() == "Preview rows"
assert (
dialog.locator(".table-create-data-note").inner_text()
== "Paste TSV, CSV, or JSON. You can also open a file or drop it onto this textarea"
)
assert dialog.locator(".table-create-data-open-file").inner_text() == "open a file"
assert (
dialog.locator(".table-create-data-editor").evaluate(
"""node => Array.from(node.children)
.filter((child) => !child.hidden)
.map((child) => child.className)
.join(" ")"""
)
== ("table-create-data-note table-create-input table-create-data-textarea")
)
assert dialog.locator(".table-create-manual").inner_text() == (
"Create table manually"
)
assert (
dialog.get_by_label("Paste TSV, CSV, or JSON").get_attribute("id")
== "table-create-data-textarea"
)
textarea = dialog.locator(".table-create-data-textarea")
dropped_value = textarea.evaluate("""node => new Promise((resolve) => {
node.addEventListener("input", () => resolve(node.value), { once: true });
const file = new File(["short_id,name\\nx,Ada"], "Repo Export 2026!!.CSV", { type: "text/csv" });
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
node.dispatchEvent(new DragEvent("dragenter", { bubbles: true, cancelable: true, dataTransfer }));
node.dispatchEvent(new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer }));
node.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer }));
})""")
assert dropped_value == "short_id,name\nx,Ada"
assert dialog.locator(".table-create-table-name").input_value() == (
"repo_export_2026"
)
dialog.locator(".table-create-table-name").fill("playwright_from_data")
textarea.fill(
json.dumps(
{
"metadata": [{"short_id": "a", "name": "Ignored", "score": 9}],
"people": [
{"short_id": "b", "name": "Ada", "score": 1},
{"short_id": "c", "name": "Bob", "score": 2.5},
],
}
)
)
dialog.locator(".table-create-save").click()
assert dialog.locator(".table-create-data-textarea").is_hidden()
assert dialog.locator(".table-create-save").inner_text() == "Create table"
assert dialog.locator(".table-create-cancel").inner_text() == "Back"
assert dialog.locator(".table-create-data-preview-summary").inner_text() == (
"Previewing 2 rows."
)
assert dialog.locator(".table-create-data-primary-key").input_value() == "short_id"
preview_text = dialog.locator(".table-create-data-preview-table").inner_text()
assert "short_id" in preview_text
assert "Ada" in preview_text
assert "2.5" in preview_text
preview_cell_style = dialog.locator(
".table-create-data-preview-table td"
).first.evaluate(
"""node => ({
overflowWrap: getComputedStyle(node).overflowWrap,
whiteSpace: getComputedStyle(node).whiteSpace
})"""
)
assert preview_cell_style == {
"overflowWrap": "anywhere",
"whiteSpace": "normal",
}
dialog.locator(".table-create-cancel").click()
assert dialog.evaluate("node => node.open")
assert dialog.locator(".table-create-data-textarea").is_visible()
assert '"people"' in dialog.locator(".table-create-data-textarea").input_value()
assert dialog.locator(".table-create-save").inner_text() == "Preview rows"
dialog.locator(".table-create-save").click()
assert dialog.locator(".table-create-save").inner_text() == "Create table"
dialog.locator(".table-create-save").click()
page.wait_for_url("**/data/playwright_from_data")
response = httpx2.get(
f"{datasette_server}data/playwright_from_data.json?_shape=objects"
)
response.raise_for_status()
data = response.json()
assert data["rows"] == [
{"short_id": "b", "name": "Ada", "score": 1},
{"short_id": "c", "name": "Bob", "score": 2.5},
]
@pytest.mark.playwright
def test_create_table_from_csv_keeps_numeric_type_when_values_are_blank(
page, datasette_server
):
page.goto(f"{datasette_server}data")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-database-action="create-table"]').click()
dialog = page.locator("#table-create-dialog")
dialog.wait_for()
dialog.locator(".table-create-from-data").click()
dialog.locator(".table-create-table-name").fill("playwright_numeric_blanks")
dialog.locator(".table-create-data-textarea").fill("name,score\nA,1\nB,")
dialog.locator(".table-create-save").click()
assert dialog.locator(".table-create-save").inner_text() == "Create table"
preview_text = dialog.locator(".table-create-data-preview-table").inner_text()
assert "A" in preview_text
assert "1" in preview_text
assert "B" in preview_text
assert "null" in preview_text
dialog.locator(".table-create-save").click()
page.wait_for_url("**/data/playwright_numeric_blanks")
response = httpx2.get(
f"{datasette_server}data/playwright_numeric_blanks.json?_shape=objects"
)
response.raise_for_status()
assert response.json()["rows"] == [
{"name": "A", "score": 1},
{"name": "B", "score": None},
]
schema_response = httpx2.get(
f"{datasette_server}data/-/query.json",
params={
"sql": (
"select type from pragma_table_info('playwright_numeric_blanks') "
"where name = 'score'"
)
},
)
schema_response.raise_for_status()
assert schema_response.json()["rows"] == [{"type": "INTEGER"}]
@pytest.mark.playwright
def test_create_table_foreign_key_selection_updates_column_type(page, datasette_server):
page.goto(f"{datasette_server}data")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-database-action="create-table"]').click()
dialog = page.locator("#table-create-dialog")
dialog.wait_for()
dialog.locator(".table-create-more-options").nth(1).click()
column_name = dialog.locator(".table-create-column-name").nth(1)
type_select = dialog.locator(".table-create-column-type").nth(1)
foreign_key_select = dialog.locator(".table-create-foreign-key-target").nth(1)
assert column_name.input_value() == ""
assert type_select.input_value() == "text"
foreign_key_select.select_option("projects\u001fid")
assert column_name.input_value() == "projects_id"
assert type_select.input_value() == "integer"
assert foreign_key_select.input_value() == "projects\u001fid"
@pytest.mark.playwright
def test_create_table_unix_default_expression_updates_column_type(
page, datasette_server
):
page.goto(f"{datasette_server}data")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-database-action="create-table"]').click()
dialog = page.locator("#table-create-dialog")
dialog.wait_for()
row = dialog.locator(".table-create-column-row").nth(1)
row.locator(".table-create-more-options").click()
row.locator(".table-create-default-options summary").click()
type_select = row.locator(".table-create-column-type")
default_expr = row.locator(".table-create-default-expr")
assert type_select.input_value() == "text"
assert (
"Current Unix time, integer milliseconds since the epoch"
in default_expr.locator("option").last.inner_text()
)
default_expr.select_option("current_unixtime_ms")
assert type_select.input_value() == "integer"
@pytest.mark.playwright
def test_alter_table_foreign_key_selection_updates_blank_column(page, datasette_server):
page.goto(f"{datasette_server}data/projects")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-table-action="alter-table"]').click()
dialog = page.locator("#table-alter-dialog")
dialog.wait_for()
dialog.locator(".table-alter-add-column").click()
column_name = dialog.locator(".table-alter-column-name").last
type_select = dialog.locator(".table-alter-column-type").last
foreign_key_select = dialog.locator(".table-alter-foreign-key-target").last
assert column_name.input_value() == ""
assert type_select.input_value() == "text"
foreign_key_select.select_option("projects\u001fid")
assert column_name.input_value() == "projects_id"
assert type_select.input_value() == "integer"
assert foreign_key_select.input_value() == "projects\u001fid"
@pytest.mark.playwright
def test_alter_table_unix_default_expression_updates_column_type(
page, datasette_server
):
page.goto(f"{datasette_server}data/projects")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-table-action="alter-table"]').click()
dialog = page.locator("#table-alter-dialog")
dialog.wait_for()
dialog.locator(".table-alter-add-column").click()
row = dialog.locator(".table-alter-column-row").last
row.locator(".table-alter-default-options summary").click()
type_select = row.locator(".table-alter-column-type")
default_expr = row.locator(".table-alter-default-expr")
assert type_select.input_value() == "text"
assert (
"Current Unix time, integer seconds since the epoch"
in default_expr.locator("option").all_inner_texts()
)
default_expr.select_option("current_unixtime")
assert type_select.input_value() == "integer"
@pytest.mark.playwright
def test_alter_table_existing_default_expression_populates_select(
page, datasette_server
):
page.goto(f"{datasette_server}data/defaults_demo")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-table-action="alter-table"]').click()
dialog = page.locator("#table-alter-dialog")
dialog.wait_for()
row = dialog.locator(".table-alter-column-row").nth(1)
row.locator(".table-alter-more-options").click()
row.locator(".table-alter-default-options summary").click()
assert row.locator(".table-alter-default-expr").input_value() == (
"current_unixtime_ms"
)
assert row.locator(".table-alter-default").input_value() == ""
@pytest.mark.playwright
def test_alter_table_flow(page, datasette_server):
page.goto(f"{datasette_server}data/projects")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-table-action="alter-table"]').click()
dialog = page.locator("#table-alter-dialog")
dialog.wait_for()
assert dialog.locator(".modal-title").inner_text() == "Alter table projects"
assert dialog.locator(".table-alter-save").is_disabled()
assert (
dialog.locator(".table-alter-column-name").first.get_attribute("placeholder")
== "column name"
)
assert dialog.locator(".table-alter-column-main").first.evaluate("""node => {
const inputHeight = node.querySelector(
".table-alter-column-name"
).getBoundingClientRect().height;
const selectHeight = node.querySelector(
".table-alter-column-type"
).getBoundingClientRect().height;
return Math.abs(inputHeight - selectHeight) <= 1;
}""")
type_options = dialog.locator(".table-alter-column-type").first.locator("option")
assert type_options.all_inner_texts() == [
"text",
"integer",
"floating point number",
"blob - binary data",
]
first_more_options = dialog.locator(".table-alter-more-options").first
assert first_more_options.inner_text() == "> Advanced options"
first_more_options.click()
assert first_more_options.inner_text() == "v Hide options"
expanded_options_text = dialog.locator(
".table-alter-column-details"
).first.inner_text()
assert dialog.locator(".table-alter-fields").evaluate(
"node => node.scrollWidth <= node.clientWidth + 1"
)
assert "Not null" in expanded_options_text
assert "This value cannot be left unset" in expanded_options_text
assert "Set a default value" in expanded_options_text
assert "Primary key" in expanded_options_text
assert "This ID uniquely identifies the record" in expanded_options_text
assert "Foreign key" in expanded_options_text
first_defaults = dialog.locator(".table-alter-default-options").first
first_defaults.locator("summary").click()
assert "or default to a specific value" in first_defaults.inner_text()
first_default_expr = first_defaults.locator(".table-alter-default-expr")
first_default_input = first_defaults.locator(".table-alter-default")
assert (
"Current timestamp in UTC, e.g. 2026-05-01 13:34:00"
in first_default_expr.locator("option").nth(1).inner_text()
)
first_default_expr.select_option("current_timestamp")
assert first_default_input.is_enabled()
first_default_input.fill("manual")
assert first_default_expr.input_value() == ""
dialog.locator(".table-alter-add-column").click()
assert dialog.locator(".table-alter-save").is_enabled()
dialog.locator(".table-alter-column-name").last.fill("status")
dialog.locator(".table-alter-column-type").last.select_option("text")
dialog.locator(".table-alter-default-options").last.locator("summary").click()
dialog.locator(".table-alter-default").last.fill("planned")
dialog.locator(".table-alter-save").click()
review = dialog.locator(".table-alter-review")
review.wait_for()
assert not dialog.locator(".table-alter-column-list").is_visible()
review_text = review.inner_text()
assert "Add column status as text, with default value planned." in review_text
assert "Set column order to" not in review_text
assert dialog.locator(".table-alter-back").is_visible()
assert dialog.locator(".table-alter-save").inner_text() == "Apply changes"
dialog.locator(".table-alter-save").click()
columns = []
for _ in range(20):
response = httpx2.get(f"{datasette_server}data/projects.json?_extra=columns")
response.raise_for_status()
columns = response.json()["columns"]
if "status" in columns:
break
time.sleep(0.1)
assert "status" in columns
@pytest.mark.playwright
def test_alter_table_primary_key_columns_stay_at_top(page, datasette_server):
page.goto(f"{datasette_server}data/projects")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-table-action="alter-table"]').click()
dialog = page.locator("#table-alter-dialog")
dialog.wait_for()
rows = dialog.locator(".table-alter-column-row")
assert rows.nth(0).locator(".table-alter-column-name").input_value() == "id"
first_row_move_buttons = rows.nth(0).locator(".table-alter-move-controls button")
for i in range(first_row_move_buttons.count()):
assert first_row_move_buttons.nth(i).is_disabled()
assert (
first_row_move_buttons.nth(i).get_attribute("title")
== "Primary key columns are always listed first"
)
assert rows.nth(1).locator(".table-alter-move-up").is_disabled()
assert rows.nth(1).locator(".table-alter-move-top").get_attribute("title") == (
"Primary key columns are always listed first"
)
assert rows.nth(1).locator(".table-alter-move-up").get_attribute("title") == (
"Primary key columns are always listed first"
)
last_row = rows.nth(rows.count() - 1)
assert last_row.locator(".table-alter-column-name").input_value() == "score"
last_row.locator(".table-alter-move-top").click()
assert rows.nth(0).locator(".table-alter-column-name").input_value() == "id"
assert rows.nth(1).locator(".table-alter-column-name").input_value() == "score"
@pytest.mark.playwright
def test_alter_table_review_rename_primary_key_column(page, datasette_server):
page.goto(f"{datasette_server}data/projects")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-table-action="alter-table"]').click()
dialog = page.locator("#table-alter-dialog")
dialog.wait_for()
save = dialog.locator(".table-alter-save")
assert save.is_disabled()
dialog.locator(".table-alter-column-name").first.fill("id3")
assert save.is_enabled()
save.click()
review = dialog.locator(".table-alter-review")
review.wait_for()
review_text = review.inner_text()
assert "Rename column id to id3." in review_text
assert "Set primary key to" not in review_text
assert dialog.locator(".table-alter-review-name").all_inner_texts() == [
"id",
"id3",
]
@pytest.mark.playwright
def test_alter_table_review_rename_table(page, datasette_server):
page.goto(f"{datasette_server}data/projects")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-table-action="alter-table"]').click()
dialog = page.locator("#table-alter-dialog")
dialog.wait_for()
save = dialog.locator(".table-alter-save")
rename_details = dialog.locator(".table-alter-table-options")
assert rename_details.locator("summary").inner_text() == "Rename table"
assert not dialog.locator(".table-alter-table-name").is_visible()
assert save.is_disabled()
rename_details.locator("summary").click()
table_name = dialog.locator(".table-alter-table-name")
assert table_name.input_value() == "projects"
assert table_name.get_attribute("placeholder") == "table name"
table_name.fill("projects_archive")
assert save.is_enabled()
save.click()
review = dialog.locator(".table-alter-review")
review.wait_for()
assert "Rename table to projects_archive." in review.inner_text()
assert dialog.locator(".table-alter-review-name").all_inner_texts() == [
"projects_archive",
]
@pytest.mark.playwright
def test_alter_table_review_not_null_wording(page, datasette_server):
page.goto(f"{datasette_server}data/projects")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-table-action="alter-table"]').click()
dialog = page.locator("#table-alter-dialog")
dialog.wait_for()
dialog.locator(".table-alter-more-options").first.click()
dialog.locator(".table-alter-not-null-input").first.check()
dialog.locator(".table-alter-save").click()
review = dialog.locator(".table-alter-review")
review.wait_for()
assert "Change column id: not null (require values)." in review.inner_text()
@pytest.mark.playwright
def test_alter_table_review_warns_when_dropping_column(page, datasette_server):
page.goto(f"{datasette_server}data/projects")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-table-action="alter-table"]').click()
dialog = page.locator("#table-alter-dialog")
dialog.wait_for()
remove_buttons = dialog.locator(".table-alter-remove-column")
remove_buttons.nth(remove_buttons.count() - 1).click()
dialog.locator(".table-alter-save").click()
review = dialog.locator(".table-alter-review")
review.wait_for()
assert not dialog.locator(".table-alter-column-list").is_visible()
review_text = review.inner_text()
assert "Warning: data in dropped columns will be permanently lost." in review_text
assert "Drop column score." in review_text
assert "Set column order to" not in review_text
assert dialog.locator(".table-alter-review-damaging").inner_text() == (
"Drop column score."
)
dialog.locator(".table-alter-back").click()
assert dialog.locator(".table-alter-column-list").is_visible()
assert dialog.locator(".table-alter-save").inner_text() == "Review changes"
@pytest.mark.playwright
def test_alter_table_cancel_skips_discard_prompt(page, datasette_server):
def open_alter_dialog():
page.locator("details.actions-menu-links").evaluate("node => node.open = true")
page.locator('button[data-table-action="alter-table"]').click()
dialog = page.locator("#table-alter-dialog")
dialog.wait_for()
return dialog
page.goto(f"{datasette_server}data/projects")
page.evaluate("""
() => {
window.__discardConfirmMessages = [];
window.confirm = (message) => {
window.__discardConfirmMessages.push(message);
return false;
};
}
""")
dialog = open_alter_dialog()
dialog.locator(".table-alter-add-column").click()
dialog.locator(".table-alter-column-name").last.fill("cancel_me")
dialog.locator(".table-alter-cancel").click()
assert dialog.evaluate("node => node.open") is False
assert page.evaluate("() => window.__discardConfirmMessages") == []
dialog = open_alter_dialog()
dialog.locator(".table-alter-add-column").click()
dialog.locator(".table-alter-column-name").last.fill("escape_me")
page.keyboard.press("Escape")
page.wait_for_function("window.__discardConfirmMessages.length === 1")
assert page.evaluate("() => window.__discardConfirmMessages") == [
"Discard table changes?"
]
assert dialog.evaluate("node => node.open") is True
page.evaluate("() => window.__discardConfirmMessages = []")
page.mouse.click(2, 2)
assert page.evaluate("() => window.__discardConfirmMessages") == [
"Discard table changes?"
]
assert dialog.evaluate("node => node.open") is True
@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_navigation_search_created_from_javascript(page, datasette_server):
from playwright.sync_api import expect
page.goto(datasette_server)
page.evaluate("""() => {
const search = document.createElement('navigation-search');
search.id = 'additional-search';
search.setAttribute('items', JSON.stringify([
{name: 'Projects', url: '/data/projects'}
]));
document.body.append(search);
const unrelated = document.createElement('div');
unrelated.className = 'search-container';
unrelated.id = 'outside-search';
document.body.append(unrelated);
search.openMenu();
}""")
search = page.locator("#additional-search")
dialog = search.get_by_role("dialog", name="Jump to", exact=True)
expect(dialog).to_be_visible()
# Page styles and ordinary DOM queries can reach the component's controls.
page.add_style_tag(
content="#additional-search .search-input { border-top-color: rgb(1, 2, 3); }"
)
field = dialog.get_by_role("combobox", name="Jump to", exact=True)
expect(field).to_have_css("border-top-color", "rgb(1, 2, 3)")
assert field.evaluate("node => document.getElementById(node.id) === node")
expect(page.locator("#outside-search")).to_have_css("display", "block")
field.fill("projects")
expect(dialog.get_by_role("option")).to_contain_text("Projects")
field.press("Enter")
page.wait_for_url("**/data/projects")
@pytest.mark.playwright
def test_column_chooser_selection_and_drag_in_document(page, datasette_server):
from playwright.sync_api import expect
page.goto(datasette_server + "data/projects")
page.emulate_media(reduced_motion="reduce")
page.evaluate("""() => {
const chooser = document.createElement('column-chooser');
chooser.id = 'additional-chooser';
document.body.append(chooser);
window.appliedColumns = null;
chooser.open({
columns: ['title', 'notes', 'score'],
selected: ['title', 'notes'],
onApply: columns => { window.appliedColumns = columns; }
});
}""")
chooser = page.locator("#additional-chooser")
dialog = chooser.get_by_role("dialog", name="Choose columns")
expect(dialog).to_be_visible()
assert dialog.evaluate("""node => {
const id = node.getAttribute('aria-labelledby');
return document.querySelectorAll(`#${id}`).length === 1 &&
node.contains(document.getElementById(id));
}""")
expect(dialog.locator(".modal-meta")).to_have_text("2 of 3 selected")
dialog.get_by_role("button", name="Deselect all", exact=True).click()
expect(dialog.locator(".modal-meta")).to_have_text("0 of 3 selected")
dialog.get_by_role("button", name="Select all", exact=True).click()
expect(dialog.locator(".modal-meta")).to_have_text("3 of 3 selected")
# Move title after score using the same pointer events as mouse/touch dragging.
handle = dialog.locator(".drag-handle").first.bounding_box()
target = dialog.locator(".drag-item").last.bounding_box()
page.mouse.move(handle["x"] + handle["width"] / 2, handle["y"] + 24)
page.mouse.down()
page.mouse.move(target["x"] + 24, target["y"] + target["height"] - 4, steps=5)
expect(dialog.locator(".drag-ghost")).to_be_visible()
page.mouse.up()
expect(dialog.locator(".drag-item-label")).to_have_text(["notes", "score", "title"])
dialog.get_by_role("button", name="Apply", exact=True).click()
expect(dialog).not_to_be_visible()
assert page.evaluate("appliedColumns") == ["notes", "score", "title"]
chooser.evaluate(
"node => node.open({columns: ['title', 'notes'], selected: ['title']})"
)
dialog.get_by_role("button", name="Deselect all", exact=True).click()
dialog.get_by_role("button", name="Cancel", exact=True).click()
expect(dialog).not_to_be_visible()
assert page.evaluate("appliedColumns") == ["notes", "score", "title"]
@pytest.mark.playwright
def test_insert_row_flow_uses_custom_column_field(page, datasette_server):
page.add_init_script("""
(() => {
let clipboardText = "";
Object.defineProperty(navigator, "clipboard", {
configurable: true,
get: () => ({
writeText: async (text) => {
clipboardText = String(text);
},
readText: async () => clipboardText,
}),
});
})();
""")
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()
bulk_insert = dialog.locator(".row-edit-bulk-insert")
bulk_insert.wait_for()
assert bulk_insert.inner_text() == "Insert multiple rows"
current_url = page.url
bulk_insert.click()
assert page.url == current_url
assert dialog.evaluate("node => node.open")
assert dialog.locator(".row-edit-fields").is_hidden()
assert dialog.locator(".row-edit-bulk").is_visible()
assert dialog.locator(".row-edit-save").inner_text() == "Preview rows"
assert (
dialog.locator(".row-edit-bulk-note").inner_text()
== "Paste TSV, CSV, or JSON. You can also open a file or drop it onto this textarea"
)
assert dialog.locator(".row-edit-bulk-open-file").inner_text() == "open a file"
assert (
dialog.locator(".row-edit-bulk-editor").evaluate(
"""node => Array.from(node.children)
.filter((child) => !child.hidden)
.map((child) => child.className)
.join(" ")"""
)
== (
"row-edit-bulk-note row-edit-input row-edit-bulk-textarea "
"row-edit-bulk-actions"
)
)
copy_template = dialog.locator(".row-edit-copy-template")
assert copy_template.inner_text() == "Copy spreadsheet template"
assert (
dialog.get_by_label("Paste TSV, CSV, or JSON").get_attribute("id")
== "row-edit-bulk-textarea"
)
assert dialog.locator(".row-edit-copy-template-label-narrow").text_content() == (
"Copy template"
)
assert dialog.locator(".row-edit-bulk-template-note").inner_text() == (
"You can paste the template into Google Sheets or Excel."
)
assert dialog.locator(".row-edit-bulk-template-note-narrow").text_content() == (
"Paste into Google Sheets or Excel"
)
copy_template.click()
page.wait_for_function(
"""() => document.querySelector(".row-edit-copy-template").textContent === "Copied" """
)
assert page.evaluate("navigator.clipboard.readText()") == (
"title\tmetadata\tlogo\tnotes\tscore"
)
textarea = dialog.locator(".row-edit-bulk-textarea")
textarea.fill("title\tmetadata\nFrom TSV\t{}")
assert textarea.input_value() == "title\tmetadata\nFrom TSV\t{}"
dropped_value = textarea.evaluate("""node => new Promise((resolve) => {
node.addEventListener("input", () => resolve(node.value), { once: true });
const file = new File(["\\n\\ntitle,metadata\\nFrom CSV,{}\\n,\\n , \\n"], "rows.csv", { type: "text/csv" });
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
node.dispatchEvent(new DragEvent("dragenter", { bubbles: true, cancelable: true, dataTransfer }));
node.dispatchEvent(new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer }));
node.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer }));
})""")
assert dropped_value == "\n\ntitle,metadata\nFrom CSV,{}\n,\n , \n"
dialog.locator(".row-edit-save").click()
assert dialog.evaluate("node => node.open")
assert dialog.locator(".row-edit-bulk-textarea").is_hidden()
assert dialog.locator(".row-edit-save").inner_text() == "Insert these rows"
assert dialog.locator(".row-edit-bulk-preview-summary").inner_text() == (
"Previewing 1 row."
)
preview_text = dialog.locator(".row-edit-bulk-preview-table").inner_text()
assert "id" in preview_text
assert "title" in preview_text
assert "metadata" in preview_text
assert "From CSV" in preview_text
assert dialog.locator(".row-edit-bulk-preview-auto").first.text_content() == "auto"
assert "null" not in preview_text
assert "undefined" not in preview_text
preview_cell_style = dialog.locator(
".row-edit-bulk-preview-table td"
).first.evaluate(
"""node => ({
overflowWrap: getComputedStyle(node).overflowWrap,
whiteSpace: getComputedStyle(node).whiteSpace
})"""
)
assert preview_cell_style == {
"overflowWrap": "anywhere",
"whiteSpace": "normal",
}
assert dialog.locator(".row-edit-cancel").inner_text() == "Back"
dialog.locator(".row-edit-cancel").click()
assert dialog.evaluate("node => node.open")
assert dialog.locator(".row-edit-bulk-textarea").is_visible()
assert dialog.locator(".row-edit-bulk-textarea").input_value() == (
"\n\ntitle,metadata\nFrom CSV,{}\n,\n , \n"
)
assert dialog.locator(".row-edit-save").inner_text() == "Preview rows"
single_insert = dialog.locator(".row-edit-single-insert")
assert single_insert.inner_text() == "Insert single row"
single_insert.click()
assert dialog.locator(".row-edit-bulk").is_hidden()
assert dialog.locator(".row-edit-fields").is_visible()
assert dialog.locator(".row-edit-save").inner_text() == "Insert row"
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_bulk_insert_preview_inserts_rows(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(".row-edit-bulk-insert").click()
dialog.locator(".row-edit-bulk-textarea").fill(
json.dumps(
{
"metadata": [{"title": "Ignored", "metadata": "{}"}],
"projects": [
{"title": "Bulk one", "metadata": "{}"},
{"title": "Bulk two", "metadata": "{}"},
],
}
)
)
dialog.locator(".row-edit-save").click()
assert dialog.locator(".row-edit-save").inner_text() == "Insert these rows"
dialog.locator(".row-edit-save").click()
dialog.locator(
".row-edit-bulk-progress-status", has_text="2 rows inserted."
).wait_for()
assert dialog.locator(".row-edit-cancel").inner_text() == "Close and view table"
dialog.locator(".row-edit-cancel").click()
page.wait_for_load_state("domcontentloaded")
assert page.url == f"{datasette_server}data/projects"
assert project_rows(datasette_server, title="Bulk one")
assert project_rows(datasette_server, title="Bulk two")
@pytest.mark.playwright
def test_bulk_insert_upsert_option_updates_existing_and_inserts_new(
page, datasette_server
):
dialog = open_bulk_insert_dialog(page, f"{datasette_server}data/upsert_items")
textarea = dialog.locator(".row-edit-bulk-textarea")
conflict_field = dialog.locator(".row-edit-bulk-conflict")
conflict_select = dialog.locator(".row-edit-bulk-conflict-mode")
assert conflict_field.is_hidden()
textarea.fill("title\nNo primary key")
assert conflict_field.is_hidden()
textarea.fill(
"id,title,metadata\nexisting,Updated by upsert,{}\nnew,Inserted by upsert,{}"
)
assert conflict_field.is_visible()
assert (
dialog.locator(".row-edit-bulk-conflict-label").inner_text()
== "If the row exists already"
)
assert conflict_select.input_value() == "ignore"
assert (
conflict_select.evaluate("""node => Array.from(node.options)
.filter((option) => !option.hidden)
.map((option) => option.textContent.trim())""")
== [
"Stop with an error",
"Skip existing rows",
"Update existing and insert new",
]
)
conflict_select.select_option("upsert")
assert conflict_select.input_value() == "upsert"
dialog.locator(".row-edit-save").click()
assert dialog.locator(".row-edit-save").inner_text() == "Update or insert rows"
preview_text = dialog.locator(".row-edit-bulk-preview-table").inner_text()
assert "Updated by upsert" in preview_text
assert "Inserted by upsert" in preview_text
dialog.locator(".row-edit-save").click()
dialog.locator(
".row-edit-bulk-progress-status", has_text="2 rows upserted."
).wait_for()
assert upsert_item_row(datasette_server, "existing")["title"] == (
"Updated by upsert"
)
assert upsert_item_row(datasette_server, "new")["title"] == "Inserted by upsert"
@pytest.mark.playwright
def test_bulk_insert_conflicts_hide_upsert_without_update_permission(
page, datasette_server
):
dialog = open_bulk_insert_dialog(page, f"{datasette_server}data/bulk_defaults")
textarea = dialog.locator(".row-edit-bulk-textarea")
conflict_field = dialog.locator(".row-edit-bulk-conflict")
conflict_select = dialog.locator(".row-edit-bulk-conflict-mode")
textarea.fill("title\nOnly title")
assert conflict_field.is_hidden()
textarea.fill("id,title\n1,Only title")
assert conflict_field.is_visible()
assert conflict_select.input_value() == "ignore"
assert (
conflict_select.evaluate("""node => Array.from(node.options)
.filter((option) => !option.hidden)
.map((option) => option.textContent.trim())""")
== [
"Stop with an error",
"Skip existing rows",
]
)
assert conflict_select.locator('option[value="upsert"]').evaluate(
"node => node.hidden && node.disabled"
)
@pytest.mark.playwright
def test_bulk_insert_live_validation_reports_unknown_columns(page, datasette_server):
dialog = open_bulk_insert_dialog(page, f"{datasette_server}data/projects")
textarea = dialog.locator(".row-edit-bulk-textarea")
error = dialog.locator(".row-edit-error")
save = dialog.locator(".row-edit-save")
textarea.fill(json.dumps([{"id2": 1, "title": "Unknown column"}]))
error.wait_for()
assert error.inner_text() == "JSON row 1 has unknown column id2."
assert save.is_disabled()
assert textarea.evaluate("node => document.activeElement === node")
textarea.fill("id2,title\n1,Unknown column")
assert error.inner_text() == "Unknown column id2 in header row."
assert save.is_disabled()
textarea.fill("[")
assert error.is_hidden()
assert not save.is_disabled()
textarea.fill(json.dumps([{"id": 1, "title": "Known column"}]))
assert error.is_hidden()
assert not save.is_disabled()
assert dialog.locator(".row-edit-bulk-conflict").is_visible()
assert dialog.locator(".row-edit-bulk-conflict-mode").input_value() == "ignore"
@pytest.mark.playwright
def test_bulk_insert_omits_columns_absent_from_pasted_input(page, datasette_server):
page.goto(f"{datasette_server}data/bulk_defaults")
page.locator('button[data-table-action="insert-row"]').click()
dialog = page.locator("#row-edit-dialog")
dialog.wait_for()
dialog.locator(".row-edit-bulk-insert").click()
dialog.locator(".row-edit-bulk-textarea").fill("title\nOnly title")
dialog.locator(".row-edit-save").click()
assert dialog.locator(".row-edit-save").inner_text() == "Insert these rows"
preview_text = dialog.locator(".row-edit-bulk-preview-table").inner_text()
assert "Only title" in preview_text
assert "undefined" not in preview_text
assert dialog.locator(".row-edit-bulk-preview-auto").inner_text() == "auto"
dialog.locator(".row-edit-save").click()
dialog.locator(
".row-edit-bulk-progress-status", has_text="1 row inserted."
).wait_for()
rows = bulk_default_rows(datasette_server, title="Only title")
assert rows == [
{
"id": 1,
"title": "Only title",
"status": "todo",
"score": 5,
}
]
@pytest.mark.playwright
def test_bulk_insert_preview_accepts_single_column_input(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(".row-edit-bulk-insert").click()
dialog.locator(".row-edit-bulk-textarea").fill("title\none\ntwo\nthree")
dialog.locator(".row-edit-save").click()
assert dialog.locator(".row-edit-save").inner_text() == "Insert these rows"
assert dialog.locator(".row-edit-bulk-preview-summary").inner_text() == (
"Previewing 3 rows."
)
preview_text = dialog.locator(".row-edit-bulk-preview-table").inner_text()
assert "one" in preview_text
assert "two" in preview_text
assert "three" in preview_text
assert "null" not in preview_text
assert "undefined" not in preview_text
@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()
assert dialog.locator(".row-edit-bulk-insert").is_hidden()
assert dialog.locator(".row-edit-single-insert").is_hidden()
assert dialog.locator(".row-edit-bulk").is_hidden()
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_edit_row_binary_control_shows_size_and_image_preview(page, datasette_server):
page.goto(f"{datasette_server}data/binary_files")
page.locator('tr[data-row="1"] button[data-row-action="edit"]').click()
dialog = page.locator("#row-edit-dialog")
dialog.wait_for()
raw_control = dialog.locator('.row-edit-binary-control[data-column="data"]')
raw_control.wait_for()
assert (
raw_control.locator(".row-edit-binary-size").inner_text() == "Binary: 4 bytes"
)
assert raw_control.locator(".row-edit-binary-preview img").count() == 0
assert dialog.locator('textarea[name="data"]').count() == 0
assert dialog.locator('input[type="hidden"][name="data"]').count() == 1
dialog.locator(".row-edit-cancel").click()
page.locator('tr[data-row="2"] button[data-row-action="edit"]').click()
image_control = dialog.locator('.row-edit-binary-control[data-column="data"]')
image_control.wait_for()
assert (
image_control.locator(".row-edit-binary-size").inner_text()
== f"Binary: {len(PNG_1X1_BYTES)} bytes"
)
image = image_control.locator(".row-edit-binary-preview img")
image.wait_for()
assert image.get_attribute("src").startswith("blob:")
@pytest.mark.playwright
def test_edit_row_binary_control_replaces_blob_from_file(page, datasette_server):
replacement = b"Replacement \x00 bytes"
page.goto(f"{datasette_server}data/binary_files")
page.locator('tr[data-row="1"] button[data-row-action="edit"]').click()
dialog = page.locator("#row-edit-dialog")
binary_control = dialog.locator('.row-edit-binary-control[data-column="data"]')
binary_control.wait_for()
binary_control.locator('input[type="file"]').set_input_files(
{
"name": "replacement.bin",
"mimeType": "application/octet-stream",
"buffer": replacement,
}
)
wait_for_binary_control_file(binary_control, len(replacement), "replacement.bin")
dialog.locator(".row-edit-save").click()
page.locator(".row-mutation-status", has_text="Updated row 1").wait_for()
assert binary_file_blob(datasette_server, 1) == replacement
@pytest.mark.playwright
def test_edit_row_binary_control_handles_null_blob(page, datasette_server):
replacement = b"From NULL"
page.goto(f"{datasette_server}data/binary_files")
page.locator('tr[data-row="3"] button[data-row-action="edit"]').click()
dialog = page.locator("#row-edit-dialog")
binary_control = dialog.locator('.row-edit-binary-control[data-column="data"]')
binary_control.wait_for()
assert (
binary_control.locator(".row-edit-binary-size").inner_text() == "No binary data"
)
binary_control.locator('input[type="file"]').set_input_files(
{
"name": "from-null.bin",
"mimeType": "application/octet-stream",
"buffer": replacement,
}
)
wait_for_binary_control_file(binary_control, len(replacement), "from-null.bin")
dialog.locator(".row-edit-save").click()
page.locator(".row-mutation-status", has_text="Updated row 3").wait_for()
assert binary_file_blob(datasette_server, 3) == replacement
@pytest.mark.playwright
def test_insert_row_binary_control_accepts_pasted_file(page, datasette_server):
pasted = b"Pasted \x00 bytes"
page.goto(f"{datasette_server}data/binary_files")
page.locator('button[data-table-action="insert-row"]').click()
dialog = page.locator("#row-edit-dialog")
dialog.wait_for()
dialog.locator('input[name="name"]').fill("Pasted bytes")
binary_control = dialog.locator('.row-edit-binary-control[data-column="data"]')
binary_control.wait_for()
binary_control.evaluate(
"""(node, bytes) => {
const transfer = new DataTransfer();
transfer.items.add(
new File([new Uint8Array(bytes)], "pasted.bin", {
type: "application/octet-stream"
})
);
const event = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(event, "clipboardData", { value: transfer });
node.dispatchEvent(event);
}""",
list(pasted),
)
wait_for_binary_control_file(binary_control, len(pasted), "pasted.bin")
dialog.locator(".row-edit-save").click()
page.locator(".row-mutation-status", has_text="Inserted row 4").wait_for()
assert binary_file_blob(datasette_server, 4) == pasted
@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").press("Enter")
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) == []
@pytest.mark.playwright
def test_count_all(page, datasette_server):
page.goto(datasette_server + "data/count_numbers?id__gt=1&_sort=id")
assert page.locator(".table-count").inner_text() == "10,000+ rows"
with page.expect_response("**/count_numbers/-/count?*") as response:
page.get_by_role("button", name="count all", exact=True).click()
assert response.value.request.method == "POST"
assert response.value.request.post_data is None
assert "content-type" not in response.value.request.headers
assert response.value.json() == {"ok": True, "count": 10001}
page.wait_for_function(
'document.querySelector(".table-count").textContent === "10,001 rows"'
)
assert page.locator(".count-all").count() == 0
assert "id" in page.locator("h3").first.inner_text()
@pytest.mark.playwright
def test_count_all_error_retry(page, datasette_server):
page.goto(datasette_server + "data/count_numbers?id__gt=1")
page.route(
"**/count_numbers/-/count?*",
lambda route: route.fulfill(
status=400,
content_type="application/json",
body=json.dumps({"ok": False, "errors": ["Count query timed out"]}),
),
)
button = page.get_by_role("button", name="count all", exact=True)
button.click()
page.wait_for_function(
'document.querySelector(".count-error").textContent === "Count query timed out"'
)
assert button.is_enabled()
page.unroute("**/count_numbers/-/count?*")
button.click()
page.wait_for_function(
'document.querySelector(".table-count").textContent === "10,001 rows"'
)
assert page.locator(".count-error").inner_text() == ""
@pytest.mark.playwright
def test_modal_lifecycle(page, datasette_server):
from playwright.sync_api import expect
page.goto(datasette_server)
page.evaluate(
"""() => {
const trigger = document.createElement('button');
trigger.id = 'modal-trigger';
trigger.textContent = 'Open test modal';
document.body.append(trigger);
window.testModal = DatasetteModal.create();
const dialog = testModal.dialog;
dialog.id = 'test-modal';
dialog.setAttribute('aria-labelledby', 'test-modal-title');
dialog.innerHTML = `
<h2 id="test-modal-title">Test modal</h2>
<input aria-label="First field">
<input aria-label="Second field">
<button id="test-modal-cancel">Cancel</button>`;
// Padding is part of the dialog, never a backdrop dismissal.
dialog.style.padding = '30px';
document.body.append(testModal);
window.closeSources = [];
testModal.beforeClose = source => {
closeSources.push(source);
return window.allowClose;
};
window.allowClose = false;
trigger.onclick = () => testModal.show({
returnFocusTo: trigger, initialFocus: dialog.querySelector('input')
});
dialog.querySelector('button').onclick = () => testModal.requestClose('cancel');
}""",
)
trigger = page.locator("#modal-trigger")
trigger.click()
dialog = page.get_by_role("dialog", name="Test modal", exact=True)
expect(dialog.get_by_role("textbox", name="First field")).to_be_focused()
assert dialog.evaluate("node => node instanceof HTMLDialogElement")
expect(dialog).to_have_css("display", "flex")
# Native modality keeps background content inert and keyboard focus inside.
page.keyboard.press("Tab")
expect(dialog.get_by_role("textbox", name="Second field")).to_be_focused()
page.keyboard.press("Shift+Tab")
expect(dialog.get_by_role("textbox", name="First field")).to_be_focused()
trigger.evaluate("node => node.focus()")
expect(dialog.get_by_role("textbox", name="First field")).to_be_focused()
page.keyboard.down("Escape")
assert page.evaluate("closeSources") == []
page.keyboard.up("Escape")
page.wait_for_function("closeSources.length === 1")
assert page.evaluate("closeSources") == ["escape"]
expect(dialog).to_be_visible()
dialog.click(position={"x": 3, "y": 3})
assert page.evaluate("closeSources") == ["escape"]
# A drag which starts inside and ends on the backdrop must not dismiss.
box = dialog.bounding_box()
page.mouse.move(box["x"] + 3, box["y"] + 3)
page.mouse.down()
page.mouse.move(2, 2)
page.mouse.up()
assert page.evaluate("closeSources") == ["escape"]
page.mouse.click(2, 2)
assert page.evaluate("closeSources") == ["escape", "backdrop"]
page.evaluate("testModal.busy = true; allowClose = true")
expect(dialog).to_have_attribute("aria-busy", "true")
page.keyboard.press("Escape")
page.mouse.click(2, 2)
dialog.get_by_role("button", name="Cancel").click()
expect(dialog).to_be_visible()
assert page.evaluate("closeSources") == ["escape", "backdrop"]
page.evaluate("testModal.busy = false")
dialog.get_by_role("button", name="Cancel").click()
expect(dialog).not_to_be_visible()
expect(trigger).to_be_focused()
assert page.evaluate("closeSources") == ["escape", "backdrop", "cancel"]
# Reopening, including an extra show() call, preserves the original return-focus target.
trigger.click()
page.evaluate("testModal.show()")
page.keyboard.press("Escape")
expect(dialog).not_to_be_visible()
expect(trigger).to_be_focused()
# Completion bypasses busy/confirmation and must not steal a caller's focus.
trigger.click()
page.evaluate("""() => new Promise(resolve => {
const next = document.createElement('button');
next.id = 'after-save';
next.textContent = 'Next action';
document.body.append(next);
testModal.dialog.addEventListener('close', resolve, {once: true});
testModal.busy = true;
testModal.close({restoreFocus: false});
next.focus();
})""")
expect(dialog).not_to_be_visible()
expect(page.locator("#after-save")).to_be_focused()
@pytest.mark.playwright
def test_modal_nested_escape_and_cleanup(page, datasette_server):
from playwright.sync_api import expect
page.goto(datasette_server + "data/projects")
trigger = page.locator('tr[data-row="1"] button[data-row-action="edit"]')
trigger.click()
dialog = page.locator("#row-edit-dialog")
field = dialog.locator('input[name="title"]')
expect(field).to_be_visible()
field.fill("Unsaved title")
page.evaluate("""() => {
window.confirmations = [];
window.confirm = message => { confirmations.push(message); return false; };
}""")
# Plugin controls can consume Escape without closing their containing form.
field.evaluate("""node => node.addEventListener('keydown', event => {
if (event.key === 'Escape') event.preventDefault();
}, {once: true})""")
field.press("Escape")
assert page.evaluate("confirmations") == []
expect(dialog).to_be_visible()
field.press("Escape")
page.wait_for_function("confirmations.length === 1")
assert page.evaluate("confirmations") == ["Discard unsaved changes to this row?"]
# A nested native modal closes independently, then returns focus to its field.
field.evaluate("""node => {
node.focus();
window.nestedModal = DatasetteModal.create();
nestedModal.dialog.setAttribute('aria-label', 'Nested picker');
nestedModal.dialog.innerHTML = '<button>Choose</button>';
node.closest('dialog').append(nestedModal);
nestedModal.show();
}""")
nested = page.get_by_role("dialog", name="Nested picker")
page.keyboard.press("Escape")
expect(nested).not_to_be_visible()
expect(dialog).to_be_visible()
expect(field).to_be_focused()
assert page.evaluate("confirmations.length") == 1
# Closing before keyup cancels the pending confirmation, including on reopen.
page.keyboard.down("Escape")
dialog.locator(".row-edit-cancel").click()
expect(dialog).not_to_be_visible()
trigger.click()
page.keyboard.up("Escape")
expect(field).to_be_visible()
assert page.evaluate("confirmations.length") == 1
expect(dialog).to_be_visible()
# Native cancel (e.g. an accessibility action) does not wait for keyboard input.
field.fill("Another edit")
dialog.evaluate(
"node => node.dispatchEvent(new Event('cancel', {cancelable: true}))"
)
assert page.evaluate("confirmations.length") == 2
dialog.locator(".row-edit-cancel").click()
expect(trigger).to_be_focused()
@pytest.mark.playwright
@pytest.mark.parametrize("name", ["jump", "columns", "type", "mobile"])
def test_modal_consumers_dismiss_and_restore_focus(page, datasette_server, name):
from playwright.sync_api import expect
page_errors = []
page.on("pageerror", lambda error: page_errors.append(str(error)))
if name == "mobile":
page.set_viewport_size({"width": 390, "height": 844})
page.emulate_media(reduced_motion="reduce")
page.goto(datasette_server + "data/projects")
if name == "jump":
trigger = page.locator("details.nav-menu summary")
trigger.click()
page.locator("[data-navigation-search-open]").click()
dialog = page.locator("navigation-search dialog")
elif name == "columns":
# Open through its public API with a real, focused page control.
trigger = page.locator("details.actions-menu-links summary")
trigger.focus()
page.evaluate(
"document.querySelector('column-chooser').open({columns: ['id', 'title'], selected: ['id']})"
)
dialog = page.locator("column-chooser dialog")
elif name == "type":
trigger = page.locator("details.actions-menu-links summary")
trigger.focus()
page.evaluate(
"openSetColumnTypeDialog(document.querySelector('th[data-column=title]'))"
)
dialog = page.locator("#set-column-type-dialog")
else:
trigger = page.locator(".column-actions-mobile")
trigger.click()
dialog = page.locator("#mobile-column-actions-dialog")
expect(dialog).to_be_visible()
expect(dialog).to_have_css("border-radius", "8px" if name == "mobile" else "12px")
expect(dialog).to_have_css("animation-name", "none")
assert dialog.evaluate("node => node.parentElement.localName") == "datasette-modal"
assert dialog.evaluate("node => node.getRootNode() === document")
page.keyboard.press("Escape")
expect(dialog).not_to_be_visible()
expect(trigger).to_be_focused()
assert page_errors == []
@pytest.mark.playwright
def test_modal_disconnect_cleans_up_pending_escape(page, datasette_server):
from playwright.sync_api import expect
page.goto(datasette_server)
page.evaluate("""() => {
window.detachable = DatasetteModal.create();
detachable.dialog.setAttribute('aria-label', 'Detachable');
detachable.dialog.innerHTML = '<button>Focus</button>';
window.closeAttempts = 0;
detachable.beforeClose = () => { closeAttempts++; return false; };
document.body.append(detachable);
detachable.show();
}""")
dialog = page.get_by_role("dialog", name="Detachable")
page.keyboard.down("Escape")
page.evaluate("detachable.remove()")
page.keyboard.up("Escape")
assert page.evaluate("closeAttempts") == 0
assert page.evaluate("detachable.dialog.open") is False
page.evaluate("document.body.append(detachable); detachable.show()")
expect(dialog).to_be_visible()
page.keyboard.press("Escape")
page.wait_for_function("closeAttempts === 1")
expect(dialog).to_be_visible()
@pytest.mark.playwright
@pytest.mark.parametrize("kind", ["create", "alter"])
def test_schema_modal_escape_confirmation_and_focus(page, datasette_server, kind):
from playwright.sync_api import expect
path = "data" if kind == "create" else "data/projects"
page.goto(datasette_server + path)
menu = page.locator("details.actions-menu-links")
menu.locator("summary").click()
selector = "data-database-action" if kind == "create" else "data-table-action"
menu.locator(f'button[{selector}="{kind}-table"]').click()
dialog = page.locator(f"#table-{kind}-dialog")
if kind == "create":
dialog.locator('input[name="table"]').fill("unsaved_table")
else:
dialog.locator(".table-alter-add-column").click()
# Real browser confirms, including WebKit, should appear once and stay usable.
confirmations = []
def reject(prompt):
confirmations.append(prompt.message)
prompt.dismiss()
page.on("dialog", reject)
with page.expect_event("dialog"):
page.keyboard.press("Escape")
expect(dialog).to_be_visible()
assert len(confirmations) == 1
page.remove_listener("dialog", reject)
page.on("dialog", lambda prompt: prompt.accept())
page.keyboard.press("Escape")
expect(dialog).not_to_be_visible()
expect(menu.locator("summary")).to_be_focused()
MENU_CASES = [
("app", "", "details.nav-menu summary", "#app-menu-panel"),
("database", "data", "details.actions-menu-links summary", "#actions-menu-panel"),
(
"column",
"data/projects",
'th[data-column="title"] .column-menu-trigger',
"#column-actions-menu",
),
]
@pytest.mark.playwright
@pytest.mark.parametrize("width", [390, 1280])
def test_menu_patterns_use_shared_menus(page, datasette_server, width):
from playwright.sync_api import expect
page.set_viewport_size({"width": width, "height": 1000})
page.goto(datasette_server + "-/patterns/menus")
samples = page.locator('.menu-pattern-open [role="menu"]')
expect(samples).to_have_count(3)
for sample in samples.all():
expect(sample).to_be_visible()
for kind in ("app", "database", "column"):
panel_id = f"closed-{kind}-panel"
trigger = page.locator(f'[aria-controls="{panel_id}"]')
trigger.click()
panel = page.locator(f"#{panel_id}")
expect(panel).to_be_visible()
assert panel.evaluate(
"node => node.classList.contains('datasette-menu-floating')"
)
expect(trigger).to_be_focused()
page.keyboard.press("ArrowDown")
expect(panel.get_by_role("menuitem").first).to_be_focused()
panel.get_by_role("menuitem").first.click()
expect(panel).not_to_be_visible()
expect(page).to_have_url(datasette_server + "-/patterns/menus")
trigger = page.locator(".menu-pattern-closed .column-menu-trigger")
assert trigger.bounding_box()["height"] == 24
expect(page.locator(".menu-pattern-closed details[open]")).to_have_count(0)
@pytest.mark.playwright
@pytest.mark.parametrize("width", [390, 1280])
def test_portfolio_uses_shared_menus(page, datasette_server, width):
from playwright.sync_api import expect
page.set_viewport_size({"width": width, "height": 1000})
page.goto(datasette_server + "-/patterns")
for panel_id in (
"app-menu-panel",
"pattern-database-actions",
"pattern-table-actions",
):
trigger = page.locator(f'[aria-controls="{panel_id}"]')
trigger.click()
panel = page.locator(f"#{panel_id}")
expect(panel).to_be_visible()
assert panel.evaluate(
"node => node.classList.contains('datasette-menu-floating')"
)
expect(trigger).to_be_focused()
page.keyboard.press("ArrowDown")
expect(panel.get_by_role("menuitem").first).to_be_focused()
page.keyboard.press("Escape")
expect(panel).not_to_be_visible()
expect(trigger).to_be_focused()
@pytest.mark.playwright
@pytest.mark.parametrize("width", [390, 590])
@pytest.mark.parametrize(
"path,trigger_selector,panel_selector",
[
("", "details.nav-menu summary", "#app-menu-panel"),
("data", "details.actions-menu-links summary", "#actions-menu-panel"),
("data/projects", "details.actions-menu-links summary", "#actions-menu-panel"),
],
)
def test_narrow_menus_stay_near_trigger(
page, datasette_server, width, path, trigger_selector, panel_selector
):
from playwright.sync_api import expect
page.set_viewport_size({"width": width, "height": 1000})
page.goto(datasette_server + path)
trigger = page.locator(trigger_selector)
trigger.click()
panel = page.locator(panel_selector)
expect(panel).to_be_visible()
anchor = trigger.bounding_box()
menu = panel.bounding_box()
gap = 0 if panel_selector == "#app-menu-panel" else 8
assert menu["y"] - (anchor["y"] + anchor["height"]) == pytest.approx(gap, abs=1)
assert menu["x"] >= 12
assert menu["x"] + menu["width"] <= width - 12
@pytest.mark.playwright
@pytest.mark.parametrize("kind,path,trigger_selector,panel_selector", MENU_CASES)
@pytest.mark.parametrize("width,height", [(1280, 800), (590, 500), (320, 240)])
def test_menus_fit_viewport(
page, datasette_server, kind, path, trigger_selector, panel_selector, width, height
):
from playwright.sync_api import expect
errors = []
page.on("pageerror", lambda error: errors.append(str(error)))
# Open before resizing: at phone widths the table uses cards and a separate
# column picker, while an already-open column menu should still fit.
page.set_viewport_size({"width": 1280, "height": 800})
page.goto(datasette_server + path)
trigger = page.locator(trigger_selector)
trigger.click()
panel = page.locator(panel_selector)
expect(panel).to_be_visible()
page.set_viewport_size({"width": width, "height": height})
page.wait_for_function(
"""selector => {
const r = document.querySelector(selector).getBoundingClientRect();
return r.left >= 11 && r.top >= 11 &&
r.right <= innerWidth - 11 && r.bottom <= innerHeight - 11;
}""",
arg=panel_selector,
)
assert panel.evaluate("node => node.scrollWidth <= node.clientWidth + 1")
if kind == "column" and height == 240:
assert panel.evaluate("node => node.scrollHeight > node.clientHeight")
page.keyboard.press("End")
assert panel.evaluate("node => node.contains(document.activeElement)")
assert panel.evaluate("node => node.scrollTop > 0")
if kind != "column":
# These controls are available directly on phones, too.
page.keyboard.press("Escape")
trigger.click()
expect(panel).to_be_visible()
if width <= 600:
minimum_height = 44 if kind == "app" else 48
assert panel.locator("[role=menuitem]").first.evaluate(
"(node, minimum) => node.getBoundingClientRect().height >= minimum",
minimum_height,
)
page.keyboard.press("Escape")
expect(panel).not_to_be_visible()
expect(trigger).to_have_attribute("aria-expanded", "false")
assert errors == []
@pytest.mark.playwright
@pytest.mark.parametrize("kind,path,trigger_selector,panel_selector", MENU_CASES)
@pytest.mark.parametrize("key", ["ArrowDown", "ArrowUp"])
def test_menu_pointer_open_waits_for_keyboard_navigation(
page, datasette_server, kind, path, trigger_selector, panel_selector, key
):
from playwright.sync_api import expect
page.goto(datasette_server + path)
trigger = page.locator(trigger_selector)
panel = page.locator(panel_selector)
trigger.click()
expect(panel).to_be_visible()
expect(trigger).to_be_focused()
assert not panel.evaluate("node => node.contains(document.activeElement)")
page.keyboard.press(key)
items = panel.locator("[role=menuitem]")
expect(items.first if key == "ArrowDown" else items.last).to_be_focused()
page.keyboard.press("Escape")
expect(panel).not_to_be_visible()
expect(trigger).to_be_focused()
@pytest.mark.playwright
@pytest.mark.parametrize("kind,path,trigger_selector,panel_selector", MENU_CASES)
def test_menu_keyboard_and_dismissal(
page, datasette_server, kind, path, trigger_selector, panel_selector
):
from playwright.sync_api import expect
page.goto(datasette_server + path)
trigger = page.locator(trigger_selector)
panel = page.locator(panel_selector)
trigger.focus()
page.keyboard.press("ArrowDown")
expect(panel).to_be_visible()
items = panel.locator("[role=menuitem]")
expect(items.first).to_be_focused()
page.keyboard.press("End")
expect(items.last).to_be_focused()
page.keyboard.press("ArrowDown")
expect(items.first).to_be_focused()
page.keyboard.press("Escape")
expect(panel).not_to_be_visible()
expect(trigger).to_be_focused()
page.keyboard.press("ArrowUp")
expect(items.last).to_be_focused()
trigger.click()
expect(panel).not_to_be_visible()
expect(trigger).to_be_focused()
trigger.click()
page.locator("h1").click()
expect(panel).not_to_be_visible()
trigger.click()
page.keyboard.press("Tab")
expect(panel).not_to_be_visible()
@pytest.mark.playwright
def test_column_menu_actions_and_dialog_focus(page, datasette_server):
from playwright.sync_api import expect
page.goto(datasette_server + "data/projects")
trigger = page.locator('th[data-column="title"] .column-menu-trigger')
trigger.click()
panel = page.locator("#column-actions-menu")
panel.get_by_role("menuitem", name="Choose columns", exact=True).click()
dialog = page.locator("column-chooser dialog")
expect(dialog).to_be_visible()
expect(panel).not_to_be_visible()
page.keyboard.press("Escape")
expect(dialog).not_to_be_visible()
expect(trigger).to_be_focused()
trigger.click()
panel.get_by_role("menuitem", name="Sort descending", exact=True).click()
expect(page).to_have_url(datasette_server + "data/projects?_sort_desc=title")
trigger.click()
panel.get_by_role("menuitem", name="Hide this column", exact=True).click()
expect(page.locator('th[data-column="title"]')).to_have_count(0)
@pytest.mark.playwright
def test_menu_switching_and_header_alignment(page, datasette_server):
from playwright.sync_api import expect
page.goto(datasette_server + "data/projects")
# The signed-in label uses the same header layout as the anonymous fixture.
page.locator("header.app-header nav").evaluate("""node => {
const actor = document.createElement('div');
actor.className = 'actor';
actor.innerHTML = '<strong>root</strong>';
node.append(actor);
}""")
assert page.locator("header.app-header").evaluate("""node => {
const actor = node.querySelector('.actor').getBoundingClientRect();
const icon = node.querySelector('summary svg').getBoundingClientRect();
return Math.abs(actor.top + actor.height / 2 - icon.top - icon.height / 2) < 1;
}""")
app = page.locator("details.nav-menu summary")
actions = page.locator("details.actions-menu-links summary")
app.click()
actions.click()
expect(page.locator("#app-menu-panel")).not_to_be_visible()
expect(page.locator("#actions-menu-panel")).to_be_visible()
assert page.locator(".datasette-menu-floating").count() == 1
page.keyboard.press("Escape")
assert page.locator("header.app-header").evaluate("""node =>
Math.abs(node.getBoundingClientRect().height -
2.6 * parseFloat(getComputedStyle(document.documentElement).fontSize)) < 1
""")
@pytest.mark.playwright
def test_menu_long_plugin_content_and_no_popover_fallback(page, datasette_server):
from playwright.sync_api import expect
page.add_init_script("HTMLElement.prototype.showPopover = undefined")
page.set_viewport_size({"width": 390, "height": 360})
page.goto(datasette_server)
# Emulate plugin links with long, unbroken names and enough items to scroll.
page.locator("#app-menu-panel ul").evaluate("""node => {
for (let i = 0; i < 20; i++) {
const li = document.createElement('li');
const a = document.createElement('a');
a.href = '#plugin';
a.role = 'menuitem';
a.textContent = 'Plugin-' + i + '-' + 'x'.repeat(100);
li.append(a);
node.append(li);
}
}""")
page.locator("details.nav-menu summary").click()
panel = page.locator("#app-menu-panel")
expect(panel).to_be_visible()
assert panel.evaluate("node => node.scrollWidth <= node.clientWidth + 1")
assert panel.evaluate("node => node.scrollHeight > node.clientHeight")
page.keyboard.press("End")
expect(panel.get_by_role("menuitem").last).to_be_focused()
assert panel.evaluate("node => node.scrollTop > 0")
page.keyboard.press("Escape")
expect(panel).not_to_be_visible()
@pytest.mark.playwright
def test_column_menu_facet_by_first_column_of_view(page, datasette_server):
# https://github.com/simonw/datasette/issues/1785
from playwright.sync_api import expect
panel = page.locator("#column-actions-menu")
page.goto(datasette_server + "fixtures/paginated_view")
page.locator('th[data-column="content"] .column-menu-trigger').click()
expect(
panel.get_by_role("menuitem", name="Facet by this", exact=True)
).to_be_visible()