datasette/tests/test_playwright.py

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

1606 lines
60 KiB
Python
Raw Normal View History

2026-07-03 16:09:27 -07:00
import base64
import json
import socket
import subprocess
import sys
import time
import httpx
import pytest
from datasette.fixtures import write_fixture_database
from datasette.utils.sqlite import sqlite3
2026-07-03 16:09:27 -07:00
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 = 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)
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 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))
);
2026-07-03 16:09:27 -07:00
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}');
""")
2026-07-03 16:09:27 -07:00
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,
},
},
2026-07-03 16:09:27 -07:00
"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 = httpx.get(f"{datasette_server}data/projects.json", params=params)
response.raise_for_status()
return response.json()["rows"]
def project_row(datasette_server, pk):
rows = project_rows(datasette_server, id=pk)
assert len(rows) == 1
return rows[0]
2026-07-03 16:09:27 -07:00
def binary_file_blob(datasette_server, pk):
response = httpx.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 = httpx.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 = httpx.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")
2026-06-22 09:59:29 -07:00
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 = httpx.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 = httpx.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()
2026-06-29 16:02:56 -07:00
== "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"
)
2026-06-29 16:02:56 -07:00
assert (
dialog.get_by_label("Paste TSV, CSV, or JSON").get_attribute("id")
== "table-create-data-textarea"
)
textarea = dialog.locator(".table-create-data-textarea")
2026-06-29 16:02:56 -07:00
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
2026-06-29 16:02:56 -07:00
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 = httpx.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 = httpx.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 = httpx.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
2026-06-22 09:59:29 -07:00
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 = httpx.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")
assert page.evaluate("() => window.__discardConfirmMessages") == [
"Discard table changes?"
]
assert dialog.evaluate("node => node.open") is True
page.evaluate("() => window.__discardConfirmMessages = []")
dialog.evaluate(
"""node => node.dispatchEvent(new MouseEvent("click", {bubbles: true}))"""
)
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_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"
2026-06-29 16:02:56 -07:00
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
2026-06-29 16:02:56 -07:00
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"
2026-07-03 16:09:27 -07:00
@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")
2026-07-03 16:09:27 -07:00
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")
2026-07-03 16:09:27 -07:00
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")
2026-07-03 16:09:27 -07:00
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").click()
page.locator(".row-mutation-status", has_text="Deleted row 1").wait_for()
page.locator('tr[data-row="1"]').wait_for(state="detached")
assert project_rows(datasette_server, id=1) == []