duckfn-docs-kit 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -49,6 +49,26 @@ export type RuntimeState = 'idle' | 'loading' | 'ready' | 'error';
49
49
  export interface RuntimeOptions {
50
50
  /** Let `LOAD` accept an extension whose signature does not verify. */
51
51
  allowUnsignedExtensions?: boolean;
52
+ /**
53
+ * Serve the DuckDB-Wasm engine from explicit same-origin URLs instead of the
54
+ * jsDelivr CDN. Used by the offline SQL verifier (`sql/browserRunner`): the
55
+ * harness passes the locally-served `duckdb-*.wasm` / worker script so a CI
56
+ * run never reaches the network. When set, `#create()` skips
57
+ * `getJsDelivrBundles()`/`selectBundle()` and the cross-origin blob-worker
58
+ * wrapper (the worker is same-origin here, so it is constructed directly).
59
+ */
60
+ bundle?: LocalBundle;
61
+ }
62
+ /**
63
+ * A locally-served DuckDB-Wasm bundle: absolute same-origin URLs for the
64
+ * engine wasm and its worker script. `pthreadWorker` is only needed for the
65
+ * cross-origin-isolated (COI) bundle; the default non-COI `eh`/`mvp` bundles run
66
+ * single-threaded and leave it unset.
67
+ */
68
+ export interface LocalBundle {
69
+ mainModule: string;
70
+ mainWorker: string;
71
+ pthreadWorker?: string;
52
72
  }
53
73
  /** Options for {@link DuckDBRuntime.loadExtension}. */
54
74
  export interface LoadExtensionOptions {
@@ -1,4 +1,4 @@
1
- import { type WasmPlatform } from './nodeRunner';
1
+ import { type WasmPlatform } from './browserRunner';
2
2
  export interface VerifyOptions {
3
3
  /** Docs site root; defaults to the working directory. */
4
4
  siteDir?: string;
@@ -16,11 +16,10 @@ export interface VerifyOptions {
16
16
  /** Per-block timeout in milliseconds; a hang is reported instead of blocking CI. */
17
17
  timeoutMs?: number;
18
18
  /**
19
- * Directory the blocks run in. Defaults to a fresh temporary directory that
20
- * is removed afterwards: a block may `COPY … TO 'a.csv'`, and on Node
21
- * DuckDB's file system is the real one, relative to the working directory.
19
+ * The browser executable that runs the blocks. Defaults to a detected
20
+ * Chrome/Edge; the `DFK_BROWSER` environment variable is the same override.
22
21
  */
23
- workingDir?: string;
22
+ browser?: string;
24
23
  /** Write the full result list here as JSON. */
25
24
  reportFile?: string;
26
25
  }
@@ -1,52 +1,39 @@
1
1
  import { collectRunnableSql as e, expectsError as t } from "./collect.js";
2
- import { WasmSqlRunner as n } from "./nodeRunner.js";
2
+ import { BrowserSqlRunner as n } from "./browserRunner.js";
3
3
  import { join as r, resolve as i } from "node:path";
4
- import { mkdirSync as a, mkdtempSync as o, readdirSync as s, rmSync as c, statSync as l, writeFileSync as u } from "node:fs";
5
- import { tmpdir as d } from "node:os";
4
+ import { readdirSync as a, statSync as o, writeFileSync as s } from "node:fs";
6
5
  //#region src/sql/verify.ts
7
- var f = ["docs", "i18n"], p = 3e4;
8
- async function m(t = {}) {
9
- let n = i(t.siteDir ?? process.cwd()), s = e({
6
+ var c = ["docs", "i18n"], l = 3e4;
7
+ async function u(t = {}) {
8
+ let n = i(t.siteDir ?? process.cwd()), r = e({
10
9
  siteDir: n,
11
- contentDirs: t.contentDirs ?? v(n)
12
- }), l = t.extension, f = l ? /^https?:\/\//i.test(l) ? l : i(l) : y(n), m = t.timeoutMs ?? p, g = t.workingDir ? i(t.workingDir) : o(r(d(), "duckfn-sql-verify-"));
13
- a(g, { recursive: !0 });
14
- let _ = process.cwd();
15
- process.chdir(g);
16
- let b;
17
- try {
18
- b = await h(s, f, t, m);
19
- } finally {
20
- process.chdir(_), t.workingDir || c(g, {
21
- recursive: !0,
22
- force: !0
23
- });
24
- }
25
- let x = {
26
- blocks: b,
27
- asDeclared: b.filter((e) => e.outcome === "ok" || e.outcome === "error-as-expected"),
28
- unexpected: b.filter((e) => e.outcome === "unexpected-error" || e.outcome === "unexpected-success")
10
+ contentDirs: t.contentDirs ?? m(n)
11
+ }), a = t.extension, o = await d(r, a ? /^https?:\/\//i.test(a) ? a : i(a) : h(n), t, t.timeoutMs ?? l), c = {
12
+ blocks: o,
13
+ asDeclared: o.filter((e) => e.outcome === "ok" || e.outcome === "error-as-expected"),
14
+ unexpected: o.filter((e) => e.outcome === "unexpected-error" || e.outcome === "unexpected-success")
29
15
  };
30
- return t.reportFile && u(t.reportFile, `${JSON.stringify(x, null, 2)}\n`), x;
16
+ return t.reportFile && s(t.reportFile, `${JSON.stringify(c, null, 2)}\n`), c;
31
17
  }
32
- async function h(e, t, r, i) {
18
+ async function d(e, t, r, i) {
33
19
  let a = await n.create({
34
20
  extension: t,
35
21
  platform: r.platform,
36
- engine: r.engine
22
+ engine: r.engine,
23
+ browser: r.browser
37
24
  }), o = [];
38
25
  try {
39
26
  let t = null;
40
- for (let n of e) n.file !== t && (t = n.file, await a.newPage()), o.push(await g(a, n, i));
27
+ for (let n of e) n.file !== t && (t = n.file, await a.newPage()), o.push(await f(a, n, i));
41
28
  } finally {
42
29
  await a.close();
43
30
  }
44
31
  return o;
45
32
  }
46
- async function g(e, n, r) {
33
+ async function f(e, n, r) {
47
34
  let i = t(n.config);
48
35
  try {
49
- let t = await _(e.run(n.sql), r);
36
+ let t = await p(e.run(n.sql), r);
50
37
  return {
51
38
  file: n.file,
52
39
  line: n.line,
@@ -63,43 +50,43 @@ async function g(e, n, r) {
63
50
  };
64
51
  }
65
52
  }
66
- function _(e, t) {
53
+ function p(e, t) {
67
54
  let n, r = new Promise((e, r) => {
68
55
  n = setTimeout(() => r(/* @__PURE__ */ Error(`timed out after ${t}ms`)), t);
69
56
  });
70
57
  return Promise.race([e, r]).finally(() => clearTimeout(n));
71
58
  }
72
- function v(e) {
59
+ function m(e) {
73
60
  let t = [];
74
- b(r(e, f[0])) && t.push(f[0]);
75
- let n = r(e, f[1]);
76
- if (b(n)) for (let e of s(n)) b(r(n, e, "docusaurus-plugin-content-docs", "current")) && t.push(r("i18n", e, "docusaurus-plugin-content-docs", "current"));
61
+ g(r(e, c[0])) && t.push(c[0]);
62
+ let n = r(e, c[1]);
63
+ if (g(n)) for (let e of a(n)) g(r(n, e, "docusaurus-plugin-content-docs", "current")) && t.push(r("i18n", e, "docusaurus-plugin-content-docs", "current"));
77
64
  return t.length > 0 ? t : ["."];
78
65
  }
79
- function y(e) {
80
- let t = r(e, "static", "duckdb-extensions"), n = b(t) ? s(t).filter((e) => e.endsWith(".duckdb_extension.wasm")) : [];
66
+ function h(e) {
67
+ let t = r(e, "static", "duckdb-extensions"), n = g(t) ? a(t).filter((e) => e.endsWith(".duckdb_extension.wasm")) : [];
81
68
  if (n.length === 0) throw Error(`sql/verify: no extension found in ${t} — pass --extension <file|url>, or let the site's extension preload plugin fetch it first`);
82
69
  if (n.length > 1) throw Error(`sql/verify: several extensions found in ${t} (${n.join(", ")}) — pass --extension to pick one`);
83
70
  return r(t, n[0]);
84
71
  }
85
- function b(e) {
86
- return l(e, { throwIfNoEntry: !1 })?.isDirectory() ?? !1;
72
+ function g(e) {
73
+ return o(e, { throwIfNoEntry: !1 })?.isDirectory() ?? !1;
87
74
  }
88
- async function x(e) {
89
- let t = C(e);
75
+ async function _(e) {
76
+ let t = y(e);
90
77
  if (t.help) {
91
- process.stdout.write(S);
78
+ process.stdout.write(v);
92
79
  return;
93
80
  }
94
- let n = Date.now(), r = await m(t), i = ((Date.now() - n) / 1e3).toFixed(1), a = r.blocks.filter((e) => e.outcome === "error-as-expected").length;
81
+ let n = Date.now(), r = await u(t), i = ((Date.now() - n) / 1e3).toFixed(1), a = r.blocks.filter((e) => e.outcome === "error-as-expected").length;
95
82
  if (t.quiet || (process.stdout.write(`\n${r.blocks.length} block(s) in ${i}s\n`), process.stdout.write(` ${r.asDeclared.length} as declared (${a} erroring on purpose), ${r.unexpected.length} unexpected\n`)), r.unexpected.length > 0) {
96
83
  process.stdout.write("unexpected behaviour:\n");
97
84
  for (let e of r.unexpected) process.stdout.write(`- ${e.file}:${e.line} [${e.outcome}] :: ${e.detail}\n`);
98
85
  process.exitCode = 1;
99
86
  }
100
87
  }
101
- var S = "Usage: duckfn-sql-verify [options]\n\nRuns every runnable SQL block of a duckfn docs site in DuckDB-Wasm, and checks\nthat each one behaves as its own metadata declares (\"expect\": \"error\" for a\nblock that demonstrates a failure).\n\n --site <dir> Docs site root (default: the working directory)\n --content <dir> Content directory, relative to the site root (repeatable;\n default: docs/ plus every i18n/<locale>/… translation)\n --extension <path> The extension to preload: a .duckdb_extension.wasm path,\n or an absolute http(s) URL (default: the single file under\n static/duckdb-extensions/)\n --platform <eh|mvp> DuckDB-Wasm bundle, which must match the extension build\n (default: eh)\n --engine <path> Engine wasm override\n --timeout <ms> Per-block timeout (default: 30000)\n --working-dir <dir> Directory the blocks run in (default: a temporary one)\n --report <file> Write the full result list as JSON\n --quiet Only report unexpected behaviour\n --help Show this help\n";
102
- function C(e) {
88
+ var v = "Usage: duckfn-sql-verify [options]\n\nRuns every runnable SQL block of a duckfn docs site in a headless browser\n(DuckDB-Wasm), and checks that each one behaves as its own metadata declares\n(\"expect\": \"error\" for a block that demonstrates a failure).\n\n --site <dir> Docs site root (default: the working directory)\n --content <dir> Content directory, relative to the site root (repeatable;\n default: docs/ plus every i18n/<locale>/… translation)\n --extension <path> The extension to preload: a .duckdb_extension.wasm path,\n or an absolute http(s) URL (default: the single file under\n static/duckdb-extensions/)\n --platform <eh|mvp> DuckDB-Wasm bundle, which must match the extension build\n (default: eh)\n --engine <path> Engine wasm override\n --browser <path> Browser executable (default: a detected Chrome/Edge,\n or the DFK_BROWSER environment variable)\n --timeout <ms> Per-block timeout (default: 30000)\n --report <file> Write the full result list as JSON\n --quiet Only report unexpected behaviour\n --help Show this help\n";
89
+ function y(e) {
103
90
  let t = {}, n = [];
104
91
  for (let r = 0; r < e.length; r++) {
105
92
  let i = e[r], a = () => {
@@ -123,12 +110,12 @@ function C(e) {
123
110
  case "--engine":
124
111
  t.engine = a();
125
112
  break;
113
+ case "--browser":
114
+ t.browser = a();
115
+ break;
126
116
  case "--timeout":
127
117
  t.timeoutMs = Number(a());
128
118
  break;
129
- case "--working-dir":
130
- t.workingDir = a();
131
- break;
132
119
  case "--report":
133
120
  t.reportFile = a();
134
121
  break;
@@ -145,4 +132,4 @@ function C(e) {
145
132
  return n.length > 0 && (t.contentDirs = n), t;
146
133
  }
147
134
  //#endregion
148
- export { x as cliMain, m as verifySqlDocs };
135
+ export { _ as cliMain, u as verifySqlDocs };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "duckfn-docs-kit",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Shared Docusaurus building blocks (TOC toggle, home-page web components, brand tokens, remark version placeholder) for duckfn-family extension docs sites.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -45,7 +45,7 @@
45
45
  "AGENTS.md"
46
46
  ],
47
47
  "scripts": {
48
- "build": "vite build && tsc -p tsconfig.build.json",
48
+ "build": "vite build && vite build --config vite.harness.config.ts && tsc -p tsconfig.build.json",
49
49
  "typecheck": "tsc -p tsconfig.json --noEmit",
50
50
  "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
51
51
  "prepack": "npm run build"
@@ -62,6 +62,7 @@
62
62
  "@visactor/vtable": "^1.26.8",
63
63
  "codemirror": "^6.0.2",
64
64
  "iconify-icon": "^3.0.3",
65
+ "playwright-core": "^1.63.0",
65
66
  "sql-formatter": "^15.9.0"
66
67
  },
67
68
  "devDependencies": {
package/src/remark.ts CHANGED
@@ -15,7 +15,7 @@ import type {Plugin} from 'unified';
15
15
  export const DEFAULT_VERSION_PLACEHOLDER = '{{DUCKFN_VERSION}}';
16
16
 
17
17
  export interface VersionPlaceholderOptions {
18
- /** The real version string to substitute in, e.g. `0.0.13`. */
18
+ /** The real version string to substitute in, e.g. `0.0.15`. */
19
19
  version: string;
20
20
  /** Override the token if a site uses a different one. */
21
21
  placeholder?: string;
package/src/sql/DfkSql.ts CHANGED
@@ -18,7 +18,7 @@ import {el, HTMLElementBase} from '../dom';
18
18
  * Everything but the result is in the shadow root. The result is a slotted
19
19
  * light-DOM sibling because VTable injects a *document-level* stylesheet that a
20
20
  * shadow boundary could not host — and it is only created when a query runs,
21
- * long after hydration, so the light DOM still starts empty (AGENTS.md rule 11).
21
+ * long after hydration, so the light DOM still starts empty (CONVENTIONS.md rule 11).
22
22
  * The editor has no such problem: it sits in this shadow root, so CodeMirror's
23
23
  * style-mod resolves the root to the same tree its styles are used in.
24
24
  *
@@ -30,7 +30,7 @@ import {el, HTMLElementBase} from '../dom';
30
30
  * which a renderer builds, so the component keeps the node (and its state) and
31
31
  * hands it over through `RenderContext.fullscreenButton`.
32
32
  *
33
- * Content entry is an **attribute seed** (see AGENTS.md rule 5 exception): the
33
+ * Content entry is an **attribute seed** (see CONVENTIONS.md rule 5 exception): the
34
34
  * `config` / `sql` attributes are read once in `connectedCallback` because the
35
35
  * remark-generated JSX cannot hand content through a ref setter. Reading once
36
36
  * to initialise is not an attribute→render loop, so the retained-mode contract
@@ -0,0 +1,402 @@
1
+ /**
2
+ * Runs runnable SQL blocks in a **real browser**, driven from Node with
3
+ * Playwright — the offline successor to the old Node-worker runner
4
+ * (`nodeRunner.ts`, now removed).
5
+ *
6
+ * Why a browser instead of the Node worker
7
+ * ----------------------------------------
8
+ * The Node worker target is not the environment a reader gets: it cannot read
9
+ * remote `http(s)` data (every remote-data example failed with `IO Error: No
10
+ * files found`), and loading the extension forced a loopback server on a
11
+ * Windows-hostile port (the staging path embeds the port, colons are illegal).
12
+ * A browser is the actual product surface: DuckDB-Wasm reads remote files
13
+ * there, and the extension loads same-origin over plain http with none of the
14
+ * staging/port constraints. That is exactly what the docs site ships.
15
+ *
16
+ * Why Playwright, and why `playwright-core`
17
+ * -----------------------------------------
18
+ * Playwright is the mature, maintained way to drive a browser: it owns the
19
+ * protocol, the connect/navigate/evaluate plumbing, auto-waiting, timeouts and
20
+ * crash handling that a hand-rolled driver would have to reinvent. The kit uses
21
+ * the `playwright-core` package (not the full `playwright`) precisely because
22
+ * `playwright-core` never downloads a browser — it launches one you point it
23
+ * at. The runner starts your **system Chrome/Edge** via `executablePath`, so
24
+ * verification stays fully offline. `--browser <path>` / `DFK_BROWSER` override
25
+ * which executable is used; otherwise a small set of well-known paths is probed.
26
+ *
27
+ * How a page maps to a DuckDB instance
28
+ * -------------------------------------
29
+ * `newPage()` navigates to the harness page (`harness.ts`), which builds a
30
+ * fresh `AsyncDuckDB` from the same-origin engine and preloads the extension.
31
+ * Reloading per content file is the isolation boundary (pages never share an
32
+ * instance), while the blocks of one page share its connection — the same "one
33
+ * instance per page, blocks share a connection" model as the site. Files
34
+ * (`COPY … TO`, `dfn_file_write_*`) land in the instance's in-memory file
35
+ * system, readable within the page and gone on reload — the faithful browser
36
+ * behaviour, not a host-directory emulation.
37
+ */
38
+ import {createServer, type Server} from 'node:http';
39
+ import {createRequire} from 'node:module';
40
+ import {existsSync, readFileSync} from 'node:fs';
41
+ import {basename, dirname, join} from 'node:path';
42
+ import {chromium, type Browser, type Page} from 'playwright-core';
43
+
44
+ const require = createRequire(import.meta.url);
45
+
46
+ /** The extension to `LOAD`: a local `.duckdb_extension.wasm` path or an http(s) URL. */
47
+ export interface RunnerOptions {
48
+ extension: string;
49
+ /** DuckDB-Wasm platform bundle; must match how the extension was built. */
50
+ platform?: WasmPlatform;
51
+ /** Engine wasm override; defaults to the platform bundle inside duckdb-wasm. */
52
+ engine?: string;
53
+ /** Browser executable; defaults to a detected Chrome/Edge (`DFK_BROWSER` wins). */
54
+ browser?: string;
55
+ }
56
+
57
+ export type WasmPlatform = 'eh' | 'mvp';
58
+
59
+ export interface RunResult {
60
+ rows: number;
61
+ columns: number;
62
+ }
63
+
64
+ /** Per-platform file names inside `@duckdb/duckdb-wasm/dist`. */
65
+ const PLATFORM_BUNDLES: Record<WasmPlatform, {wasm: string; worker: string}> = {
66
+ eh: {wasm: 'duckdb-eh.wasm', worker: 'duckdb-browser-eh.worker.js'},
67
+ mvp: {wasm: 'duckdb-mvp.wasm', worker: 'duckdb-browser-mvp.worker.js'},
68
+ };
69
+
70
+ /** The harness's `__dfkRun` return shape (see `harness.ts`). */
71
+ interface HarnessResult {
72
+ rows: number;
73
+ columns: number;
74
+ error?: string;
75
+ }
76
+
77
+ /** The harness's `__dfkQuery` return shape (see `harness.ts`). */
78
+ export interface QueryResult {
79
+ columns: string[];
80
+ rows: Record<string, unknown>[];
81
+ error?: string;
82
+ }
83
+
84
+ /**
85
+ * One DuckDB-Wasm instance at a time, driven through a headless browser.
86
+ * Mirrors the surface `verify.ts` used from `WasmSqlRunner`.
87
+ */
88
+ export class BrowserSqlRunner {
89
+ #server: Server | null = null;
90
+ #browser: Browser | null = null;
91
+ #page: Page | null = null;
92
+ #harnessUrl = '';
93
+ /** Set when the current page failed to initialise; every run then reports it. */
94
+ #pageError: string | null = null;
95
+
96
+ readonly #extensionUrl: string;
97
+ readonly #enginePath: string;
98
+ readonly #workerPath: string;
99
+ readonly #browserExecutable: string;
100
+ readonly #allowUnsigned: boolean;
101
+
102
+ private constructor(options: {
103
+ extensionUrl: string;
104
+ enginePath: string;
105
+ workerPath: string;
106
+ browserExecutable: string;
107
+ allowUnsigned: boolean;
108
+ }) {
109
+ this.#extensionUrl = options.extensionUrl;
110
+ this.#enginePath = options.enginePath;
111
+ this.#workerPath = options.workerPath;
112
+ this.#browserExecutable = options.browserExecutable;
113
+ this.#allowUnsigned = options.allowUnsigned;
114
+ }
115
+
116
+ static async create(options: RunnerOptions): Promise<BrowserSqlRunner> {
117
+ const platform = options.platform ?? 'eh';
118
+ const bundle = PLATFORM_BUNDLES[platform];
119
+ const distDir = dirname(require.resolve('@duckdb/duckdb-wasm/dist/duckdb-browser.mjs'));
120
+ const enginePath = options.engine ? resolveLocal(options.engine) : join(distDir, bundle.wasm);
121
+ const workerPath = join(distDir, bundle.worker);
122
+
123
+ const remote = /^https?:\/\//i.test(options.extension);
124
+ const extensionUrl = remote ? options.extension : resolveLocal(options.extension);
125
+
126
+ return new BrowserSqlRunner({
127
+ extensionUrl,
128
+ enginePath,
129
+ workerPath,
130
+ browserExecutable: findBrowser(options.browser),
131
+ // The runner is a trusted local loopback: an unsigned dev extension must
132
+ // load, exactly as the site's own config opts in.
133
+ allowUnsigned: true,
134
+ });
135
+ }
136
+
137
+ /** Starts the static server and the browser; idempotent. */
138
+ async #ensureStarted(): Promise<void> {
139
+ if (this.#page) {
140
+ return;
141
+ }
142
+ this.#server = await startStaticServer(this.#routes());
143
+ const address = this.#server.address();
144
+ const port = typeof address === 'object' && address ? address.port : 0;
145
+ this.#harnessUrl = `http://127.0.0.1:${port}/harness.html`;
146
+
147
+ // `playwright-core` launches the executable we hand it and manages the
148
+ // browser process and a throwaway profile for us.
149
+ this.#browser = await chromium.launch({
150
+ executablePath: this.#browserExecutable,
151
+ headless: true,
152
+ });
153
+ this.#page = await this.#browser.newPage();
154
+ }
155
+
156
+ /** Maps the URL space the harness page needs to everything on disk. */
157
+ #routes(): Routes {
158
+ // A served extension keeps a `/ext/<name>.duckdb_extension.wasm` shape so
159
+ // its base name still names the entry symbol (`<name>_init_c_api`).
160
+ const ext = /^https?:\/\//i.test(this.#extensionUrl)
161
+ ? {remoteUrl: this.#extensionUrl}
162
+ : {localFile: this.#extensionUrl, baseName: basename(this.#extensionUrl)};
163
+ return {
164
+ // Resolved through the package's own `exports` wildcard
165
+ // (`./sql/harness` -> `dist/sql/harness.js`), so it finds the built harness
166
+ // whether the runner runs from the workspace symlink or an installed copy.
167
+ harnessScript: require.resolve('duckfn-docs-kit/sql/harness'),
168
+ engine: {file: this.#enginePath, name: basename(this.#enginePath)},
169
+ worker: {file: this.#workerPath, name: basename(this.#workerPath)},
170
+ extension: ext,
171
+ allowUnsigned: this.#allowUnsigned,
172
+ };
173
+ }
174
+
175
+ /** Drops the current page state and opens a fresh one: new instance, new connection. */
176
+ async newPage(): Promise<void> {
177
+ await this.#ensureStarted();
178
+ const page = this.#page;
179
+ if (!page) {
180
+ throw new Error('sql/browserRunner: browser is not running');
181
+ }
182
+ this.#pageError = null;
183
+ await page.goto(this.#harnessUrl, {waitUntil: 'load'});
184
+ // `__dfkReady` resolves once the engine is up and the preloads have loaded;
185
+ // Playwright awaits the promise and throws if it rejects.
186
+ try {
187
+ await page.evaluate(() => (window as unknown as {__dfkReady: Promise<void>}).__dfkReady);
188
+ } catch (error) {
189
+ this.#pageError = messageOf(error);
190
+ }
191
+ }
192
+
193
+ /** Runs one block; a SQL failure is thrown so the caller can weigh it. */
194
+ async run(sql: string): Promise<RunResult> {
195
+ if (this.#pageError) {
196
+ throw new Error(this.#pageError);
197
+ }
198
+ const page = this.#page;
199
+ if (!page) {
200
+ throw new Error('sql/browserRunner: no page is open — call newPage() first');
201
+ }
202
+ // `sql` crosses as an evaluate argument, never spliced into page code.
203
+ const result = await page.evaluate(
204
+ (statement) => (window as unknown as {__dfkRun: (s: string) => Promise<HarnessResult>}).__dfkRun(statement),
205
+ sql,
206
+ );
207
+ if (result?.error) {
208
+ throw new Error(result.error);
209
+ }
210
+ return {rows: result.rows, columns: result.columns};
211
+ }
212
+
213
+ /**
214
+ * Runs one statement and returns its actual rows (not just a count). Used by
215
+ * the vfs browser probe to read each value; `run()` is the verifier's path.
216
+ * Unlike `run()`, a failure is returned as `{error}` rather than thrown.
217
+ */
218
+ async query(sql: string): Promise<QueryResult> {
219
+ if (this.#pageError) {
220
+ return {columns: [], rows: [], error: this.#pageError};
221
+ }
222
+ const page = this.#page;
223
+ if (!page) {
224
+ throw new Error('sql/browserRunner: no page is open — call newPage() first');
225
+ }
226
+ const result = await page.evaluate(
227
+ (statement) =>
228
+ (window as unknown as {__dfkQuery: (s: string) => Promise<QueryResult>}).__dfkQuery(statement),
229
+ sql,
230
+ );
231
+ return result ?? {columns: [], rows: [], error: 'no result'};
232
+ }
233
+
234
+ async close(): Promise<void> {
235
+ try {
236
+ await this.#page?.close();
237
+ } catch {
238
+ // The page may already be gone when the browser is shutting down.
239
+ }
240
+ this.#page = null;
241
+ try {
242
+ await this.#browser?.close();
243
+ } catch {
244
+ // Likewise the browser: closing twice or after a crash is not an error.
245
+ }
246
+ this.#browser = null;
247
+ if (this.#server) {
248
+ await new Promise<void>((resolve) => this.#server?.close(() => resolve()));
249
+ this.#server = null;
250
+ }
251
+ }
252
+ }
253
+
254
+ /** A path or `file:` URL to an absolute local path; leaves a remote URL alone. */
255
+ function resolveLocal(value: string): string {
256
+ return /^file:\/\//i.test(value) ? new URL(value).pathname.replace(/^\/(\w:)/i, '$1') : value;
257
+ }
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // Static server
261
+ // ---------------------------------------------------------------------------
262
+
263
+ interface Routes {
264
+ harnessScript: string;
265
+ engine: {file: string; name: string};
266
+ worker: {file: string; name: string};
267
+ extension: {remoteUrl: string} | {localFile: string; baseName: string};
268
+ allowUnsigned: boolean;
269
+ }
270
+
271
+ /**
272
+ * A loopback static server for the harness: `harness.html` (generated, wiring
273
+ * the engine/extension URLs into the two contracts `harness.ts` reads),
274
+ * `harness.js` (the bundled harness), `/vendor/*` (engine + worker), and
275
+ * `/ext/*` (the served extension file). Nothing else is reachable; this is a
276
+ * short-lived test fixture, not a web server.
277
+ */
278
+ async function startStaticServer(routes: Routes): Promise<Server> {
279
+ const harness = readFileSync(routes.harnessScript);
280
+ const engine = readFileSync(routes.engine.file);
281
+ const worker = readFileSync(routes.worker.file);
282
+ const extension =
283
+ 'localFile' in routes.extension ? readFileSync(routes.extension.localFile) : null;
284
+ const extBaseName = 'baseName' in routes.extension ? routes.extension.baseName : '';
285
+
286
+ const extensionUrl =
287
+ 'remoteUrl' in routes.extension
288
+ ? routes.extension.remoteUrl
289
+ : `ext/${routes.extension.baseName}`;
290
+
291
+ const html = `<!doctype html>
292
+ <html lang="en">
293
+ <head>
294
+ <meta charset="utf-8" />
295
+ <title>duckfn sql harness</title>
296
+ <script>
297
+ window.DFK_HARNESS_BUNDLE = {
298
+ mainModule: '/vendor/${routes.engine.name}',
299
+ mainWorker: '/vendor/${routes.worker.name}',
300
+ };
301
+ </script>
302
+ <script id="dfk-sql-runtime" type="application/json">
303
+ {"allowUnsignedExtensions":${routes.allowUnsigned},"preload":[{"url":"${extensionUrl}"}]}
304
+ </script>
305
+ </head>
306
+ <body>
307
+ <script type="module" src="/harness.js"></script>
308
+ </body>
309
+ </html>`;
310
+
311
+ const server = createServer((request, response) => {
312
+ const path = new URL(request.url ?? '/', 'http://localhost').pathname;
313
+ if (path === '/harness.html') {
314
+ send(response, 'text/html; charset=utf-8', Buffer.from(html, 'utf8'));
315
+ return;
316
+ }
317
+ if (path === '/harness.js') {
318
+ send(response, 'text/javascript; charset=utf-8', harness);
319
+ return;
320
+ }
321
+ if (path === `/vendor/${routes.engine.name}`) {
322
+ send(response, 'application/wasm', engine);
323
+ return;
324
+ }
325
+ if (path === `/vendor/${routes.worker.name}`) {
326
+ send(response, 'text/javascript; charset=utf-8', worker);
327
+ return;
328
+ }
329
+ if (extension && path === `/ext/${extBaseName}`) {
330
+ send(response, 'application/octet-stream', extension);
331
+ return;
332
+ }
333
+ response.writeHead(404, {'content-type': 'text/plain'});
334
+ response.end('not found');
335
+ });
336
+
337
+ await new Promise<void>((resolve, reject) => {
338
+ server.once('error', reject);
339
+ // Port 0 lets the OS pick a free one; a browser has no reason for a fixed
340
+ // port (unlike the Node worker's staging path, which embedded it).
341
+ server.listen(0, '127.0.0.1', resolve);
342
+ });
343
+ return server;
344
+ }
345
+
346
+ function send(response: import('node:http').ServerResponse, type: string, body: Buffer): void {
347
+ response.writeHead(200, {'content-type': type, 'content-length': body.length});
348
+ response.end(body);
349
+ }
350
+
351
+ // ---------------------------------------------------------------------------
352
+ // Browser detection
353
+ // ---------------------------------------------------------------------------
354
+
355
+ /** A short list of well-known Chrome/Edge locations, overridable by DFK_BROWSER. */
356
+ function findBrowser(explicit?: string): string {
357
+ const candidate = explicit ?? process.env.DFK_BROWSER;
358
+ if (candidate) {
359
+ return candidate;
360
+ }
361
+ const paths: string[] = [];
362
+ if (process.platform === 'win32') {
363
+ const pf = process.env['PROGRAMFILES'] ?? 'C:\\Program Files';
364
+ const pf86 = process.env['PROGRAMFILES(X86)'] ?? 'C:\\Program Files (x86)';
365
+ const local = process.env['LOCALAPPDATA'] ?? '';
366
+ paths.push(
367
+ join(pf, 'Google', 'Chrome', 'Application', 'chrome.exe'),
368
+ join(pf86, 'Google', 'Chrome', 'Application', 'chrome.exe'),
369
+ join(pf86, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
370
+ join(pf, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
371
+ local ? join(local, 'Google', 'Chrome', 'Application', 'chrome.exe') : '',
372
+ );
373
+ } else if (process.platform === 'darwin') {
374
+ paths.push(
375
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
376
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
377
+ );
378
+ } else {
379
+ paths.push(
380
+ '/usr/bin/google-chrome',
381
+ '/usr/bin/google-chrome-stable',
382
+ '/usr/bin/microsoft-edge',
383
+ '/usr/bin/chromium',
384
+ '/usr/bin/chromium-browser',
385
+ );
386
+ }
387
+ for (const path of paths) {
388
+ if (path && existsSync(path)) {
389
+ return path;
390
+ }
391
+ }
392
+ throw new Error(
393
+ 'sql/browserRunner: no Chrome/Edge found — pass --browser <path> or set DFK_BROWSER',
394
+ );
395
+ }
396
+
397
+ function messageOf(error: unknown): string {
398
+ if (error instanceof Error) {
399
+ return error.message;
400
+ }
401
+ return String(error);
402
+ }