From 2cf727a204dd6e58422f6f8fce653599660c4749 Mon Sep 17 00:00:00 2001
From: thekevinbot <248217931+thekevinbot@users.noreply.github.com>
Date: Tue, 13 Oct 2026 17:23:38 -0400
Subject: [PATCH 1/4] test: frontend table browsing and configless onboarding
---
packages/rust/tests/cli_e2e.rs | 21 +++
packages/rust/tests/frontend/browse.test.mjs | 172 ++++++++++++++++++
.../rust/tests/frontend/fake-document.mjs | 49 +++++
packages/rust/tests/frontend/query.test.mjs | 44 +----
packages/rust/tests/frontend_js.rs | 1 +
5 files changed, 244 insertions(+), 43 deletions(-)
create mode 100644 packages/rust/tests/frontend/browse.test.mjs
create mode 100644 packages/rust/tests/frontend/fake-document.mjs
diff --git a/packages/rust/tests/cli_e2e.rs b/packages/rust/tests/cli_e2e.rs
index ac0c190fc..ac2d99d97 100644
--- a/packages/rust/tests/cli_e2e.rs
+++ b/packages/rust/tests/cli_e2e.rs
@@ -258,6 +258,27 @@ fn frontend_page_has_query_controls_and_serves_its_script() {
kill_and_wait(server);
}
+#[test]
+fn frontend_script_offers_table_browsing_and_path_onboarding() {
+ let root = TempDir::new().unwrap();
+ let port = free_port();
+ let server = spawn_dirsql_with_args(root.path(), port, &["--frontend"]);
+ wait_until_ready(port, Duration::from_secs(5));
+ let script = Client::new()
+ .get(format!("http://localhost:{port}/frontend.js"))
+ .send()
+ .unwrap()
+ .text()
+ .unwrap();
+ for name in ["loadState", "renderState", "previewQuery"] {
+ assert!(
+ script.contains(name),
+ "{name} missing from the frontend script"
+ );
+ }
+ kill_and_wait(server);
+}
+
#[test]
fn frontend_still_loads_and_reports_an_invalid_config() {
let root = TempDir::new().unwrap();
diff --git a/packages/rust/tests/frontend/browse.test.mjs b/packages/rust/tests/frontend/browse.test.mjs
new file mode 100644
index 000000000..ce9a247b4
--- /dev/null
+++ b/packages/rust/tests/frontend/browse.test.mjs
@@ -0,0 +1,172 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+
+import { STARTER_QUERY, loadState, previewQuery, renderState } from '../../src/cli/frontend.js';
+import { fakeDocument } from './fake-document.mjs';
+
+function response(status, body) {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ json: async () => {
+ if (body === undefined) throw new SyntaxError('no body');
+ return body;
+ },
+ };
+}
+
+function render(state) {
+ const doc = fakeDocument();
+ const container = doc.createElement('section');
+ const chosen = [];
+ renderState(doc, container, state, (sql) => chosen.push(sql));
+ return { container, chosen };
+}
+
+const STAT_COLUMNS = ['path', 'basename', 'dir', 'ext', 'size', 'mtime', 'ctime'];
+
+const buttonNamed = (container, label) => container.find('button').find((b) => b.textContent === label);
+
+describe('loadState', () => {
+ it('reads only /frontend/state and never opens /events', async () => {
+ const urls = [];
+ const state = await loadState(async (url) => {
+ urls.push(url);
+ return response(200, { error: null, root: '/data', tables: [] });
+ });
+ assert.deepEqual(urls, ['/frontend/state']);
+ assert.deepEqual(state, { status: 'ready', root: '/data', tables: [] });
+ });
+
+ it('reports a config failure with its diagnostic', async () => {
+ const state = await loadState(async () => response(503, { error: 'failed to resolve missing.toml' }));
+ assert.deepEqual(state, { status: 'unavailable', message: 'failed to resolve missing.toml' });
+ });
+
+ it('reports any other failure as unloaded', async () => {
+ assert.deepEqual(await loadState(async () => response(500, { error: 'catalog read failed' })), {
+ status: 'failed',
+ message: 'catalog read failed',
+ });
+ assert.deepEqual(await loadState(async () => response(502)), { status: 'failed', message: 'HTTP 502' });
+ assert.deepEqual(
+ await loadState(async () => {
+ throw new TypeError('Failed to fetch');
+ }),
+ { status: 'failed', message: 'Failed to fetch' },
+ );
+ });
+});
+
+describe('previewQuery', () => {
+ it('quotes the table name as an identifier and bounds the preview', () => {
+ assert.equal(previewQuery('posts'), 'SELECT * FROM "posts" LIMIT 100');
+ assert.equal(previewQuery('order'), 'SELECT * FROM "order" LIMIT 100');
+ assert.equal(previewQuery('two "words"'), 'SELECT * FROM "two ""words""" LIMIT 100');
+ });
+});
+
+describe('configless start', () => {
+ const state = { status: 'ready', root: '/home/me/notes', tables: [] };
+
+ it('shows the query root and offers the editable path-table example', () => {
+ const { container, chosen } = render(state);
+ assert.match(container.textContent, /\/home\/me\/notes/);
+ assert.match(container.textContent, /No declared tables/);
+ const example = buttonNamed(container, STARTER_QUERY);
+ assert.ok(example, 'path-table example button');
+ example.click();
+ assert.deepEqual(chosen, [STARTER_QUERY]);
+ });
+
+ it('documents the seven stat columns and the lazy content column', () => {
+ const { container } = render(state);
+ const codes = container.find('code').map((c) => c.textContent);
+ for (const column of [...STAT_COLUMNS, 'content']) assert.ok(codes.includes(column), column);
+ assert.match(container.textContent, /SELECT \*/);
+ assert.match(container.textContent, /name it/i);
+ });
+
+ it('explains editing the path or glob in SQL', () => {
+ const { container } = render(state);
+ const codes = container.find('code').map((c) => c.textContent);
+ assert.ok(codes.includes("'./**/*.md'"));
+ assert.match(container.textContent, /no configuration/i);
+ });
+
+ it('explains refresh by rerunning', () => {
+ assert.match(render(state).container.textContent, /Run again/);
+ });
+});
+
+describe('declared tables', () => {
+ const state = {
+ status: 'ready',
+ root: '/srv/blog',
+ tables: [
+ {
+ name: 'posts',
+ columns: [
+ { name: 'title', type: 'TEXT' },
+ { name: 'size', type: 'INTEGER' },
+ ],
+ },
+ { name: 'two "words"', columns: [{ name: 'select', type: '' }] },
+ ],
+ };
+
+ it('lists each table with its columns and types', () => {
+ const { container } = render(state);
+ const text = container.textContent;
+ for (const fragment of ['posts', 'title', 'TEXT', 'size', 'INTEGER', 'two "words"', 'select']) {
+ assert.ok(text.includes(fragment), fragment);
+ }
+ });
+
+ it('populates a quoted bounded preview without running it', () => {
+ const { container, chosen } = render(state);
+ buttonNamed(container, 'two "words"').click();
+ buttonNamed(container, 'posts').click();
+ assert.deepEqual(chosen, ['SELECT * FROM "two ""words""" LIMIT 100', 'SELECT * FROM "posts" LIMIT 100']);
+ });
+
+ it('keeps the path-table example and explains the two anchors', () => {
+ const { container, chosen } = render(state);
+ buttonNamed(container, STARTER_QUERY).click();
+ assert.deepEqual(chosen, [STARTER_QUERY]);
+ assert.doesNotMatch(container.textContent, /No declared tables/);
+ assert.match(container.textContent, /config file's directory/);
+ assert.match(container.textContent, /\/srv\/blog/);
+ assert.match(container.textContent, /reflect the server's index/);
+ });
+});
+
+describe('config failure', () => {
+ it('shows the diagnostic and restart guidance as an alert', () => {
+ const { container } = render({ status: 'unavailable', message: 'failed to resolve missing.toml' });
+ assert.match(container.textContent, /Configuration could not load/);
+ assert.match(container.textContent, /failed to resolve missing\.toml/);
+ assert.match(container.textContent, /restart/i);
+ assert.match(container.textContent, /dirsql server --frontend -c/);
+ assert.equal(container.find('h2')[0].attrs.role, 'alert');
+ assert.equal(container.find('button').length, 0);
+ });
+
+ it('shows a failure to load the state', () => {
+ const { container } = render({ status: 'failed', message: 'Failed to fetch' });
+ assert.match(container.textContent, /Could not load the server state: Failed to fetch/);
+ assert.equal(container.find('button').length, 0);
+ });
+
+ it('renders diagnostics, roots and names as text only', () => {
+ const evil = '';
+ for (const state of [
+ { status: 'unavailable', message: evil },
+ { status: 'ready', root: evil, tables: [{ name: evil, columns: [{ name: evil, type: evil }] }] },
+ ]) {
+ const { container } = render(state);
+ assert.equal(container.find('img').length, 0);
+ assert.ok(container.textContent.includes(evil));
+ }
+ });
+});
diff --git a/packages/rust/tests/frontend/fake-document.mjs b/packages/rust/tests/frontend/fake-document.mjs
new file mode 100644
index 000000000..bd8acb379
--- /dev/null
+++ b/packages/rust/tests/frontend/fake-document.mjs
@@ -0,0 +1,49 @@
+export function fakeDocument() {
+ function element(tag) {
+ const el = {
+ tag,
+ children: [],
+ attrs: {},
+ listeners: {},
+ className: '',
+ _text: '',
+ get textContent() {
+ return el._text + el.children.map((c) => c.textContent).join('');
+ },
+ set textContent(v) {
+ el._text = String(v);
+ el.children = [];
+ },
+ set innerHTML(_) {
+ throw new Error('innerHTML must never be used');
+ },
+ setAttribute(k, v) {
+ el.attrs[k] = String(v);
+ },
+ addEventListener(type, fn) {
+ el.listeners[type] = fn;
+ },
+ click() {
+ el.listeners.click?.({ preventDefault() {} });
+ },
+ append(...nodes) {
+ el.children.push(...nodes);
+ },
+ replaceChildren(...nodes) {
+ el._text = '';
+ el.children = nodes;
+ },
+ find(tagName) {
+ const out = [];
+ const walk = (n) => {
+ if (n.tag === tagName) out.push(n);
+ n.children.forEach(walk);
+ };
+ walk(el);
+ return out;
+ },
+ };
+ return el;
+ }
+ return { createElement: element };
+}
diff --git a/packages/rust/tests/frontend/query.test.mjs b/packages/rust/tests/frontend/query.test.mjs
index 408f5cbbb..d0282740c 100644
--- a/packages/rust/tests/frontend/query.test.mjs
+++ b/packages/rust/tests/frontend/query.test.mjs
@@ -8,6 +8,7 @@ import {
formatCell,
renderResult,
} from '../../src/cli/frontend.js';
+import { fakeDocument } from './fake-document.mjs';
function response(status, body) {
return {
@@ -33,49 +34,6 @@ function deferred() {
return { promise, resolve };
}
-function fakeDocument() {
- function element(tag) {
- const el = {
- tag,
- children: [],
- attrs: {},
- className: '',
- _text: '',
- get textContent() {
- return el._text + el.children.map((c) => c.textContent).join('');
- },
- set textContent(v) {
- el._text = String(v);
- el.children = [];
- },
- set innerHTML(_) {
- throw new Error('innerHTML must never be used');
- },
- setAttribute(k, v) {
- el.attrs[k] = String(v);
- },
- append(...nodes) {
- el.children.push(...nodes);
- },
- replaceChildren(...nodes) {
- el._text = '';
- el.children = nodes;
- },
- find(tagName) {
- const out = [];
- const walk = (n) => {
- if (n.tag === tagName) out.push(n);
- n.children.forEach(walk);
- };
- walk(el);
- return out;
- },
- };
- return el;
- }
- return { createElement: element };
-}
-
function run(state) {
const doc = fakeDocument();
const container = doc.createElement('div');
diff --git a/packages/rust/tests/frontend_js.rs b/packages/rust/tests/frontend_js.rs
index e20857b5d..fff77f103 100644
--- a/packages/rust/tests/frontend_js.rs
+++ b/packages/rust/tests/frontend_js.rs
@@ -9,6 +9,7 @@ fn frontend_script_passes_its_node_tests() {
let output = Command::new("node")
.arg("--test")
.arg(dir.join("query.test.mjs"))
+ .arg(dir.join("browse.test.mjs"))
.output()
.expect("node must be installed to test the bundled frontend");
assert!(
From 59221551ed292a404b0342d8455c18d2a946d7d6 Mon Sep 17 00:00:00 2001
From: thekevinbot <248217931+thekevinbot@users.noreply.github.com>
Date: Tue, 13 Oct 2026 17:29:45 -0400
Subject: [PATCH 2/4] feat: frontend table browsing and configless onboarding
---
PARITY.md | 3 +-
docs/reference/cli.md | 13 ++
.../changelog.d/2026-10-13-frontend-browse.md | 1 +
.../2026-10-13-frontend-browse.md | 21 +++
packages/rust/src/cli/frontend.html | 30 +---
packages/rust/src/cli/frontend.js | 136 ++++++++++++++++++
.../rust/tests/frontend/fake-document.mjs | 4 +-
7 files changed, 179 insertions(+), 29 deletions(-)
create mode 100644 packages/rust/changelog.d/2026-10-13-frontend-browse.md
create mode 100644 packages/rust/migrations.d/2026-10-13-frontend-browse.md
diff --git a/PARITY.md b/PARITY.md
index 4571d786b..d5caf8163 100644
--- a/PARITY.md
+++ b/PARITY.md
@@ -371,7 +371,8 @@ query root, and declared tables and columns through `GET /frontend/state`
(HTTP 503 when unavailable). The page needs no
runtime asset download; the option adds no SDK binding API. Without the flag,
the existing HTTP routes are unchanged. The page runs SQL through `POST
-/query` (script at `/frontend.js`). Distribution validation is tracked in a
+/query` (script at `/frontend.js`) and lists declared tables with a
+quoted preview query beside the path-table guide. Distribution validation is tracked in a
subsequent frontend issue.
**Parity by construction, no drift; no SDK API change (#662).** Query is the
diff --git a/docs/reference/cli.md b/docs/reference/cli.md
index e6da14635..6024f6318 100644
--- a/docs/reference/cli.md
+++ b/docs/reference/cli.md
@@ -249,6 +249,19 @@ returned, and errors. Values render as text (`NULL` is marked), and only the
first 1000 rows are displayed. An empty result says so and shows no column
names, since the response carries none.
+Above the SQL box the page shows the query root and the path-table columns
+(`path`, `basename`, `dir`, `ext`, `size`, `mtime`, `ctime`, plus `content`,
+read only when named), with a button that loads the starter query. A config
+with declared tables adds each table and its columns; choosing one loads
+`SELECT * FROM "