diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a184f59..c32d19e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,25 @@ importers: specifier: 'catalog:' version: 4.1.10(@types/node@26.2.0)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.2.0)(typescript@7.0.2))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.25.12)(jiti@1.21.7)(sass@1.103.1)(yaml@2.9.0)) + prototypes/proof-runner: + dependencies: + roamjs-components: + specifier: 'catalog:' + version: 0.89.0(5222ab9d8d0f15037684080314cfeeb5) + use-sync-external-store: + specifier: 'catalog:' + version: 1.6.0(react@17.0.2) + devDependencies: + '@discoursegraphs/extension-base': + specifier: workspace:* + version: link:../../packages/extension-base + jsdom: + specifier: 'catalog:' + version: 30.0.1 + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@26.2.0)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.2.0)(typescript@7.0.2))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.25.12)(jiti@1.21.7)(sass@1.103.1)(yaml@2.9.0)) + prototypes/properties-panel: dependencies: roamjs-components: diff --git a/prototypes/proof-runner/CHANGELOG.md b/prototypes/proof-runner/CHANGELOG.md new file mode 100644 index 0000000..a3a8564 --- /dev/null +++ b/prototypes/proof-runner/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +## 0.1.0 - 2026-10-05 + +- Load a pull request's CI build from a proof kit page, after asking before any graph-wide setup. +- Run the kit's cases from a panel pinned in the right sidebar, with by-hand cases, verdicts, and a run log on the page. +- Hand the build this extension's `extensionAPI` with URL loading; use a browser-storage stand-in from `roam/js`. +- Show the page's own case blocks in the panel, so editing a step edits the page; Retry runs a failed step as its block reads now. +- Show what a kit needs before Run as a checklist, each unmet need with the one button that meets it. +- Point a pull request's CI build at the proof database for kits that need one, and start that database from the panel with Connect this machine. +- Register agent tools with Roam's AI API, and open the person's agent on a kit with Ask your agent. +- Ship the proof helper a tester's machine runs for Connect: `pnpm helper setup` once, Docker or native Supabase, links on Linux, macOS and Windows. diff --git a/prototypes/proof-runner/README.md b/prototypes/proof-runner/README.md new file mode 100644 index 0000000..55b2b26 --- /dev/null +++ b/prototypes/proof-runner/README.md @@ -0,0 +1,232 @@ +# Proof runner + +Test a Discourse Graph pull request from a Roam page. A page holds a proof kit: the PR it proves, +and its cases written as steps a person could follow. Open the page and the runner loads that PR's +CI build of the Discourse Graph extension, shows the kit's run panel on the page, and plays the +cases when you press **Run**. When the panel is out of view during a run, a floating bar carries the +status and the controls. + +## Status + +Internal prototype for evaluation by Discourse Graphs, tried on `test-3-graph`. The kit format, +machine, recipes and baselines are shared with the Playwright proof runner that records PR videos, +so the same kit runs in both. Check the build you are running with `proofRunner.version` in the +browser console. + +## Getting set up + +Two things, once each. After that, opening a kit page's link is all it takes. + +**1. Turn the runner on in Roam**, once per graph in each browser: Settings > Roam Depot > +Developer extensions (turn on developer mode) > Load from URL, and paste the runner's address from +[Install from a URL](#install-from-a-url). The first block on every kit page says the same, and +hides once the runner is on. + +**2. Set up your machine**, once. Kits that use a database (most of them) need it; kits that +don't, run without it. You need: + +- Node 22.6 or later (`node --version`): the helper is TypeScript that Node runs directly. +- Docker, running (Docker Desktop on macOS and Windows). On Linux and macOS, `--runtime native` + runs Supabase without Docker instead. +- A discourse-graph checkout: the proof database's schema comes from its migrations. +- This repository. No `pnpm install` is needed; the helper uses only Node. + +Then, from `prototypes/proof-runner`: + +```bash +pnpm helper setup --dg +# or, without pnpm: +node --experimental-strip-types helper/cli.ts setup --dg +``` + +Setup remembers the checkout and how Supabase runs (Docker when it answers, native otherwise; +`--runtime docker|native` chooses), keeps the proof database's files in a folder of its own +(Linux `~/.local/share/dg-proof`, macOS `~/Library/Application Support/dg-proof`, Windows +`%LOCALAPPDATA%\dg-proof`), and registers `dg-proof://` links so the page's buttons can reach your +machine: through xdg on Linux, a small AppleScript app in `~/Applications` on macOS, and registry +keys under your own user (no admin) on Windows. Run setup again to change what it remembered; +`pnpm helper uninstall` removes the links. The proof database is a Supabase project of its own +(`dg-proof`, API on `127.0.0.1:55321`), so it never touches the database you develop against, and +never production. + +**Then, on a kit page**, the panel lists what the kit needs. Press **Connect this machine**: your +browser asks once to open the link and once to let roamresearch.com reach your machine; allow both. +The first start installs the Supabase CLI into that folder and downloads Supabase, which takes a few +minutes; later starts take about 20 seconds. Press **Set up and load** if the kit asks, then +**Run**. **Disconnect** stops what Connect started. + +**When something doesn't work:** + +- `pnpm helper status` says whether the helper and the proof database are running. +- `handler.log`, in the folder above, has what the helper did after a click. +- `pnpm helper start` runs the helper in your terminal, so you can watch it; `pnpm helper stop` + stops it. +- "Discourse Graph is already running" on the panel: DG is installed from Roam Depot in that graph. + Turn it off there, or use a test graph without it. +- So far the links and the helper have run on Linux only. The macOS and Windows registration and + Docker mode are written and tested as plans, but haven't run on those machines yet. + +## What opening a kit page does + +1. The runner reads the page's `{{proof}}` block. `pr:: 1506` looks the PR's branch up on GitHub; + `build:: ` names one directly. +2. The panel shows a checklist of what the kit needs before Run, one line per need that applies: + the runner, this machine (for a kit that needs a database), the graph setup, the build, the + database session, and an agent (for by-hand cases). A met line is a check mark; one that isn't + shows the one button that meets it. When every line is met they fold into one, and Run is one + press. +3. It runs the kit's before-load fixtures, the flags and node types Discourse Graph reads once at + startup. When any of them would change the graph, the checklist lists what will change and waits + for **Set up and load**; otherwise it loads straight away. +4. It loads the branch's CI build from `discoursegraphs.com/releases/roam//` and checks the + commit inside it against the PR head. The panel says when CI hasn't caught up yet. With URL + loading, the build gets this extension's `extensionAPI`, with its settings stored under a `dg:` + prefix. From `roam/js` it gets a stand-in that keeps settings in the browser. +5. **Run** plays the in-session fixtures, the baseline smoke checks, then the cases. It clicks, + types, presses keys and runs palette commands in the page, and runs each case's check. A case + written only as an `intent` is done by hand: the panel says what to do, and you press Done, then + Pass or Fail. During a run the panel shows the page's own case blocks, so editing a step there + edits the page, and Retry runs a failed step as its block reads now. A finished run adds a line + to the kit's `runs` list: the verdicts, the build and commit, the time, and who ran it. + +If Discourse Graph is already running in the graph (installed from Roam Depot), the runner doesn't +start a second copy and says so. Turn that copy off for the graph to test builds there. + +A kit that names a build runs only on that build, loaded for it. After navigating to a kit for a +different PR, the panel offers **Reload with this build** and keeps Run off until you do. + +Releases are served with a one-day cache, and Roam loads a URL extension through the browser cache. +After a new version is published, a hard reload (Ctrl+Shift+R) picks it up; `proofRunner.version` +says which one is running. + +## A kit page + +```text +{{proof}} Warn when sharing with Stored Relations off + kit:: eng-2348 + pr:: 1506 + baseline:: dg-baseline@1 + case:: Discover shared nodes warns while Stored Relations is off + id:: import-warns + open import the way a user would + use:: discover.open + expect js:: `proof.waitFor(() => proof.callout('Stored relations are disabled'))` + case:: Search keeps working + intent:: run Node Search for "PK baseline" and look at the results + expect:: the baseline nodes show and no sync error appears +``` + +Each step is a sentence with its action folded under it: `use:: ` (plus `with::`), any +action kind as `:: `, or a code block. An attribute the parser doesn't know is an error, +so a typo can't leave a case without its check. + +## What it changes + +- Before-load fixtures write graph-wide flags and node types, after you press Set up and load. +- In-session fixtures create the pages a kit needs, and every finished run adds a line to the + kit's `runs` list. +- The CI build itself writes Discourse Graph's settings on load, as any install does. + +CI builds are compiled against the production database, and tests never run there. For a kit that +needs a database, or that has or turns on sync or node sharing, the runner points the PR's CI build +at the proof database before it runs: it swaps the Supabase URL and key for the proof database's and +Discourse Graph's website API for the embeddings stub, and refuses the build if anything of the +hosted stack is left. The proof database runs on the tester's machine: the checklist's **Connect +this machine** opens a `dg-proof://connect` link, which that machine's proof helper answers by +starting the database and handing the runner its address and the kits' keys. Kits that only +exercise UI (`dg-baseline@2`) run on CI builds as they are, for anyone. + +## Agent tools + +With Roam's AI API (`extensionAPI.ai.addTool`), the runner registers tools an agent connected to the +graph can call: `proof_status`, `proof_snapshot`, `proof_control`, `proof_add_steps` and +`proof_fix_step`. An agent can read a kit and its run, see the page's dialogs and controls, run and +pause, write steps for a by-hand case onto the page, and fix a failed step. Js an agent writes waits +for an Allow in the panel, and verdicts stay with the person. The checklist's **Ask your agent** +opens a `dg-proof://agent` link that starts one. From `roam/js` there is no `extensionAPI`, so no +tools. + +Kit pages run their own js when you press Run, so keep kits in graphs shared with people you trust, +as with `roam/js`. + +## Install from a URL + +In Roam, use **Load Developer Extensions from URL** with: + +```text +https://discoursegraphs.com/releases/prototypes/proof-runner/ +``` + +Until the pull request that adds this prototype merges, use its preview instead: +`https://discoursegraphs.com/releases/prototypes/previews/proof-runner/proof-runner/`. + +Roam supplies the extension API, loads `extension.css`, and unloads the extension in this mode. + +## Load from roam/js + +Paste this loader into a `roam/js` code block. To test a pull-request preview, change only `baseUrl` to the preview release directory posted on the pull request. + +```javascript +(async () => { + const baseUrl = + "https://discoursegraphs.com/releases/prototypes/proof-runner"; + const globalKey = "__roamPrototype:proof-runner"; + const loadKey = `${globalKey}:load`; + + const previousLoad = window[loadKey] ?? Promise.resolve(); + const currentLoad = previousLoad.catch(() => {}).then(async () => { + const version = Date.now(); + const previous = window[globalKey]; + const previousExtension = previous?.extension ?? previous; + + // Import and validate the replacement before unloading a working copy. + const module = await import(`${baseUrl}/extension.js?v=${version}`); + const extension = module.default; + if (!extension?.onload || !extension?.onunload) { + throw new Error("The loaded module is not a Roam extension."); + } + + if (previousExtension?.onunload) { + await previousExtension.onunload(); + } + previous?.stylesheet?.remove(); + delete window[globalKey]; + + const stylesheet = document.createElement("link"); + stylesheet.rel = "stylesheet"; + stylesheet.href = `${baseUrl}/extension.css?v=${version}`; + stylesheet.dataset.roamPrototype = "proof-runner"; + + try { + document.head.appendChild(stylesheet); + await extension.onload({ + extensionAPI: undefined, + extension: { version: "roam/js" }, + }); + window[globalKey] = { extension, stylesheet }; + } catch (error) { + try { + await extension.onunload(); + } catch (cleanupError) { + console.error( + "Could not clean up the failed Proof runner load:", + cleanupError, + ); + } + stylesheet.remove(); + throw error; + } + }); + + window[loadKey] = currentLoad; + try { + await currentLoad; + } finally { + if (window[loadKey] === currentLoad) delete window[loadKey]; + } +})().catch((error) => { + console.error("Could not load Proof runner:", error); +}); +``` + +A `roam/js` block can use global `window.roamAlphaAPI` capabilities, but Roam does not provide the extension-scoped `extensionAPI` through this loading path. Features that require extension settings or other `extensionAPI` methods are available only with URL loading unless the prototype provides a fallback. diff --git a/prototypes/proof-runner/helper/cli.ts b/prototypes/proof-runner/helper/cli.ts new file mode 100644 index 0000000..5891078 --- /dev/null +++ b/prototypes/proof-runner/helper/cli.ts @@ -0,0 +1,160 @@ +import { spawn, spawnSync } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { LOCAL_BUILDS_URL } from "../src/core/database.ts"; +import { agentPrompt, parseProofLink } from "../src/roam/links.ts"; +import * as database from "./database.ts"; +import { helperPaths, resolvePlace, writeConfig } from "./config.ts"; +import { installHandler, thisMachine, uninstallHandler } from "./handler.ts"; +import { helperAnswers, serveHelper } from "./server.ts"; + +// The proof helper: what a developer's machine runs so a kit page's Connect +// this machine works. Set it up once; the page does the rest. +// +// setup [--dg ] [--runtime docker|native] [--workdir ] +// remember where the checkout is and how Supabase runs here, and +// register dg-proof:// links (Linux and macOS) +// start [--port 8766] [--no-ensure] +// what Connect runs: the embeddings stub and the proof database, +// then the keys server the page asks; runs until stopped +// stop stop a running helper and what it started +// status +// open +// what a link runs: connect starts the helper; agent opens a +// terminal with claude on the kit +// uninstall +// remove the link registration (the database's files stay) +// +// Run with Node 22 or later: node --experimental-strip-types cli.ts , +// or `pnpm helper ` in roam-prototypes/prototypes/proof-runner. + +const log = (line: string): void => console.log(`[proof-helper] ${line}`); + +const flagsOf = (args: string[]): Record => { + const flags: Record = {}; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (!arg.startsWith("--")) continue; + const next = args[index + 1]; + if (next === undefined || next.startsWith("--")) flags[arg.slice(2)] = true; + else { + flags[arg.slice(2)] = next; + index += 1; + } + } + return flags; +}; + +const text = (value: string | boolean | undefined): string | undefined => (typeof value === "string" ? value : undefined); + +const here = process.argv[1]; + +// What a link runs: this file, with the flags Node was started with (tsx's +// loader, or type stripping), so it runs the same way from a click. +const launchCommand = (): string[] => [process.execPath, ...process.execArgv, here]; + +const placeFrom = (flags: Record, needCheckout = true) => + resolvePlace({ dg: text(flags.dg), runtime: text(flags.runtime), workdir: text(flags.workdir) }, path.dirname(here), { needCheckout }); + +const setup = async (flags: Record): Promise => { + const place = await placeFrom(flags); + const config = await writeConfig(place); + log(`Saved ${config}: schema from ${place.checkout}, Supabase in ${place.runtime === "docker" ? "Docker" : "native processes"}, files in ${place.workdir}.`); + await installHandler(thisMachine(launchCommand(), helperPaths().app)); + log("dg-proof:// links are registered. Open a kit page and press Connect this machine; the browser asks once before opening the link."); +}; + +const commandExists = (name: string): boolean => + process.platform === "win32" + ? spawnSync("where", [name], { stdio: "ignore" }).status === 0 + : spawnSync("sh", ["-c", `command -v ${name}`], { stdio: "ignore" }).status === 0; + +// A PowerShell single-quoted string: nothing in it is expanded. +const powershellLiteral = (value: string): string => `'${value.replace(/'/g, "''")}'`; + +// A terminal with claude on the kit, in the checkout. The prompt goes in +// through the environment (or a file on macOS), never through a shell. +const openAgent = async (graph: string, uid: string, checkout: string): Promise => { + const prompt = agentPrompt(graph, uid); + const title = `proof agent · ${graph}`; + const shell = process.env.SHELL ?? "/bin/sh"; + const run = 'claude "$PROOF_AGENT_PROMPT"; exec "$SHELL"'; + if (process.platform === "win32") { + const file = path.join(os.tmpdir(), `dg-proof-agent-${uid}.txt`); + await fs.writeFile(file, prompt); + const script = `Set-Location -LiteralPath ${powershellLiteral(checkout)}; claude (Get-Content -Raw -LiteralPath ${powershellLiteral(file)})`; + spawn("cmd", ["/c", "start", title, "powershell", "-NoExit", "-Command", script], { detached: true, stdio: "ignore" }).unref(); + return; + } + if (process.platform === "darwin") { + const file = path.join(os.tmpdir(), `dg-proof-agent-${uid}.txt`); + await fs.writeFile(file, prompt); + const line = `cd ${JSON.stringify(checkout)} && claude "$(cat ${JSON.stringify(file)})"`; + spawn("osascript", ["-e", `tell application "Terminal" to do script ${JSON.stringify(line)}`, "-e", 'tell application "Terminal" to activate'], { + detached: true, + stdio: "ignore", + }).unref(); + return; + } + const terminal = commandExists("kitty") + ? ["kitty", "--directory", checkout, "--title", title, shell, "-ic", run] + : commandExists("x-terminal-emulator") + ? ["x-terminal-emulator", "-e", shell, "-ic", run] + : null; + if (!terminal) { + log("No terminal found to open the agent in; the page put the prompt on the clipboard."); + return; + } + spawn(terminal[0], terminal.slice(1), { cwd: checkout, detached: true, stdio: "ignore", env: { ...process.env, PROOF_AGENT_PROMPT: prompt } }).unref(); +}; + +const open = async (link: string | undefined): Promise => { + if (!link) throw new Error("open needs a dg-proof:// link."); + const parsed = parseProofLink(link); + log(`open ${link}`); + const place = await placeFrom({}); + if (parsed.kind === "connect") { + if (await helperAnswers()) { + log("The helper is already running."); + return; + } + await serveHelper({ place, ensure: true, log }); + return; + } + await openAgent(parsed.graph, parsed.uid, place.checkout); +}; + +const main = async (): Promise => { + const [command = "help", ...args] = process.argv.slice(2); + const flags = flagsOf(args); + if (command === "setup") await setup(flags); + else if (command === "start") { + const ensure = flags["no-ensure"] !== true; + await serveHelper({ + place: await placeFrom(flags, ensure), + port: text(flags.port) ? Number(flags.port) : undefined, + ensure, + log, + }); + } else if (command === "stop") { + await fetch(`${LOCAL_BUILDS_URL}/stop`, { method: "POST" }).catch(() => undefined); + log("Asked the helper to stop."); + } else if (command === "status") { + const place = await placeFrom(flags, false); + const { answering, state } = await database.status(place); + log(`Helper: ${(await helperAnswers()) ? "running" : "not running"}. Proof database: ${answering ? "answering" : "not answering"}.`); + if (state) log(`Schema from ${state.source}, seeded ${state.seededAt}.`); + } else if (command === "open") await open(args[0]); + else if (command === "uninstall") { + await uninstallHandler(thisMachine(launchCommand(), helperPaths().app)); + log("dg-proof:// links are no longer registered; the database's files stay."); + } else { + console.log("proof helper: setup [--dg ] [--runtime docker|native] [--workdir ] | start [--port ] [--no-ensure] | stop | status | open | uninstall"); + } +}; + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/prototypes/proof-runner/helper/config.ts b/prototypes/proof-runner/helper/config.ts new file mode 100644 index 0000000..1d6058e --- /dev/null +++ b/prototypes/proof-runner/helper/config.ts @@ -0,0 +1,83 @@ +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { Place, Runtime } from "./database.ts"; + +// Where the helper keeps its files on this machine, and what setup decided: +// the discourse-graph checkout, Docker or native, and the working folder. + +const appDir = (): string => + process.platform === "darwin" + ? path.join(os.homedir(), "Library/Application Support/dg-proof") + : process.platform === "win32" + ? path.join(process.env.LOCALAPPDATA ?? path.join(os.homedir(), "AppData", "Local"), "dg-proof") + : path.join(process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local/share"), "dg-proof"); + +export const helperPaths = () => { + const app = appDir(); + const config = + process.env.DG_PROOF_CONFIG ?? + (process.platform === "linux" + ? path.join(process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), "dg-proof", "config.json") + : path.join(app, "config.json")); + return { app, config, workdir: path.join(app, "db"), log: path.join(app, "handler.log") }; +}; + +const CONFIG_FILE = "packages/database/supabase/config.toml"; + +export const isCheckout = (dir: string): boolean => existsSync(path.join(dir, CONFIG_FILE)); + +// A discourse-graph checkout next to one of the folders the helper sits in, +// the usual layout of side-by-side clones. +export const findCheckout = (from: string): string | null => { + for (let dir = path.resolve(from); ; dir = path.dirname(dir)) { + const sibling = path.join(dir, "discourse-graph"); + if (isCheckout(sibling)) return sibling; + if (path.dirname(dir) === dir) return null; + } +}; + +export const dockerAnswers = (): boolean => spawnSync("docker", ["info"], { stdio: "ignore" }).status === 0; + +export const readConfig = async (): Promise => { + try { + return JSON.parse(await fs.readFile(helperPaths().config, "utf8")) as Place; + } catch { + return null; + } +}; + +export const writeConfig = async (place: Place): Promise => { + const file = helperPaths().config; + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, `${JSON.stringify(place, null, 2)}\n`); + return file; +}; + +// The place from flags, then setup's config, then what this machine has. A +// helper that only answers (no database to start) needs no checkout. +export const resolvePlace = async ( + flags: { dg?: string; runtime?: string; workdir?: string }, + from: string, + { needCheckout = true }: { needCheckout?: boolean } = {}, +): Promise => { + const saved = await readConfig(); + const checkout = flags.dg ?? saved?.checkout ?? findCheckout(from) ?? (needCheckout ? null : ""); + if (checkout === null || (needCheckout && !isCheckout(checkout))) { + throw new Error( + `${checkout ? `${checkout} isn't a discourse-graph checkout` : "No discourse-graph checkout found next to this one"}: pass --dg .`, + ); + } + // Supabase's native stack runs on Linux and macOS; Windows runs it in Docker. + const windows = process.platform === "win32"; + const runtime = (flags.runtime ?? saved?.runtime ?? (windows || dockerAnswers() ? "docker" : "native")) as Runtime; + if (runtime !== "docker" && runtime !== "native") throw new Error("--runtime is docker or native."); + if (windows && runtime === "native") throw new Error("On Windows the proof database runs in Docker (Docker Desktop); leave out --runtime."); + return { + checkout: checkout && path.resolve(checkout), + runtime, + workdir: path.resolve(flags.workdir ?? saved?.workdir ?? helperPaths().workdir), + }; +}; diff --git a/prototypes/proof-runner/helper/database.ts b/prototypes/proof-runner/helper/database.ts new file mode 100644 index 0000000..31a02dc --- /dev/null +++ b/prototypes/proof-runner/helper/database.ts @@ -0,0 +1,395 @@ +import { spawn } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { existsSync, readdirSync } from "node:fs"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { PROOF_DB_URL } from "../src/core/database.ts"; + +// The proof database: a local Supabase only proof kits use, apart from the +// stacks a developer works against and never the hosted one. It runs on its +// own ports (the API on 127.0.0.1:55321, Postgres on 55322) as project +// "dg-proof", in Docker the way discourse-graph's README runs Supabase, or as +// native processes (the CLI's experimental stack). Its schema is a +// discourse-graph checkout's migrations. Where it keeps its files, which +// checkout and which runtime make a Place; nothing here knows a machine. + +export type Runtime = "docker" | "native"; + +export type Place = { + // Where the project, the pinned CLI, the keys and the state live. + workdir: string; + // The discourse-graph checkout whose migrations make the schema. + checkout: string; + runtime: Runtime; +}; + +export const PROJECT_ID = "dg-proof"; +// The CLI the proof database is tested on; the native stack is alpha. +export const SUPABASE_CLI_VERSION = "2.119.0"; +const SOURCE = "packages/database/supabase"; +// Kits use the API, auth, storage and Postgres. The rest stays off. +const EXCLUDED = ["analytics", "mail", "functions"]; + +type Result = { code: number; stdout: string; stderr: string }; + +const run = ( + command: string, + args: string[], + options: { cwd?: string; env?: NodeJS.ProcessEnv; echo?: boolean; input?: string; shell?: boolean } = {}, +): Promise => + new Promise((resolve) => { + const child = spawn(command, args, { + cwd: options.cwd, + env: options.env ?? process.env, + stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"], + shell: options.shell, + }); + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + if (options.echo) process.stdout.write(chunk); + }); + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + if (options.echo) process.stderr.write(chunk); + }); + if (options.input !== undefined) child.stdin?.end(options.input); + child.on("error", (error) => resolve({ code: 127, stdout, stderr: String(error) })); + child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr })); + }); + +const log = (message: string): void => console.log(`[proof-db] ${message}`); + +const files = (place: Place) => ({ + supabase: path.join(place.workdir, "supabase"), + env: path.join(place.workdir, ".env"), + state: path.join(place.workdir, "state.json"), + // The CLI package's own launcher, run with Node: the same on every OS, + // where node_modules/.bin holds shell or .cmd shims. + cli: path.join(place.workdir, "node_modules", "supabase", "dist", "supabase.js"), +}); + +// The CLI maps every SUPABASE_* variable onto its config, so keys exported +// for another stack (or the hosted one) must not reach it. +const cliEnv = (place: Place): NodeJS.ProcessEnv => { + const env: NodeJS.ProcessEnv = {}; + for (const [name, value] of Object.entries(process.env)) { + if (!name.startsWith("SUPABASE_")) env[name] = value; + } + if (place.runtime === "native") env.SUPABASE_EXPERIMENTAL_STACK = "1"; + return env; +}; + +const cli = async (place: Place, args: string[], echo = false): Promise => { + const { cli: bin } = files(place); + if (!existsSync(bin)) { + log(`Installing the Supabase CLI ${SUPABASE_CLI_VERSION} into ${place.workdir} (once).`); + await fs.mkdir(place.workdir, { recursive: true }); + const manifest = path.join(place.workdir, "package.json"); + if (!existsSync(manifest)) { + await fs.writeFile( + manifest, + `${JSON.stringify({ name: "proof-db", private: true, devDependencies: { supabase: SUPABASE_CLI_VERSION } }, null, 2)}\n`, + ); + } + // npm is a .cmd on Windows, which Node only runs through a shell. + const install = await run("npm", ["install", "--no-audit", "--no-fund"], { cwd: place.workdir, shell: process.platform === "win32" }); + if (install.code !== 0) throw new Error(`npm install in ${place.workdir} failed:\n${install.stderr.slice(-1500)}`); + } + return run(process.execPath, [bin, ...args, "--workdir", place.workdir, "--agent", "no"], { env: cliEnv(place), echo }); +}; + +const failed = (what: string, result: Result): Error => + new Error(`${what} failed (exit ${result.code}):\n${(result.stderr || result.stdout).trim().split("\n").slice(-12).join("\n")}`); + +// The checkout's config.toml, moved onto the proof database's ports (543xx +// becomes 553xx) and its own project id, so it never meets a developer's own +// stack. For the native runtime it turns on the experimental stack and drops +// the Deno 1 pin the stack refuses (edge functions stay off anyway). +export const proofConfig = (toml: string, runtime: Runtime = "native"): string => { + let experimental = false; + const lines = toml.split("\n").flatMap((line): string[] => { + if (/^\s*project_id\s*=/.test(line)) return [`project_id = "${PROJECT_ID}"`]; + if (/^\s*deno_version\s*=/.test(line)) return runtime === "native" ? [] : [line]; + if (/^\s*inspector_port\s*=/.test(line)) return ["inspector_port = 55383"]; + const port = /^(\s*(?:port|shadow_port)\s*=\s*)543(\d\d)\s*$/.exec(line); + if (port) return [`${port[1]}553${port[2]}`]; + if (/^\s*stack\s*=/.test(line)) return []; + if (/^\s*\[experimental\]\s*$/.test(line)) { + experimental = true; + return runtime === "native" ? [line, "stack = true"] : [line]; + } + return [line]; + }); + if (!experimental && runtime === "native") lines.push("", "[experimental]", "stack = true"); + const config = lines.join("\n"); + if (!/\[api\][^[]*\nport = 55321\b/.test(config)) { + throw new Error("The checkout's config.toml doesn't put the API on 54321, so the proof database can't move it to 55321."); + } + return config; +}; + +// Each migration's digest by version, to tell when an applied one changed. +export const migrationDigests = async (dir: string): Promise> => { + const digests: Record = {}; + for (const name of readdirSync(dir).filter((file) => file.endsWith(".sql")).sort()) { + digests[name.split("_")[0]] = createHash("sha256").update(await fs.readFile(path.join(dir, name))).digest("hex"); + } + return digests; +}; + +export type State = { source: string; migrations: Record; seededAt: string }; + +const readState = async (place: Place): Promise => { + try { + return JSON.parse(await fs.readFile(files(place).state, "utf8")) as State; + } catch { + return null; + } +}; + +export const parseEnvLines = (text: string): Record => { + const env: Record = {}; + for (const line of text.split("\n")) { + const match = /^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=\s*(.*?)\s*$/.exec(line); + if (match) env[match[1]] = match[2].replace(/^(['"])(.*)\1$/, "$2"); + } + return env; +}; + +// The keys and URLs of the running stack, named the way discourse-graph's +// packages/database/.env.local names them, so kits' {{env.X}} read the same. +const stackEnv = async (place: Place): Promise> => { + const status = await cli(place, ["status", "--env"]); + if (status.code !== 0) throw failed("supabase status", status); + const raw = parseEnvLines(status.stdout); + const env: Record = {}; + for (const [name, value] of Object.entries(raw)) { + env[name === "API_URL" ? "SUPABASE_URL" : `SUPABASE_${name}`] = value; + } + if (env.SUPABASE_URL !== PROOF_DB_URL) { + throw new Error(`The proof stack answers on ${env.SUPABASE_URL ?? "nothing"}, not ${PROOF_DB_URL}.`); + } + for (const name of ["SUPABASE_PUBLISHABLE_KEY", "SUPABASE_SERVICE_ROLE_KEY", "SUPABASE_DB_URL"]) { + if (!env[name]) throw new Error(`supabase status gave no ${name}.`); + } + return env; +}; + +// The proof database's keys, from the .env up wrote. +export const databaseEnv = async (place: Place): Promise> => { + const text = await fs.readFile(files(place).env, "utf8").catch(() => null); + if (text === null) throw new Error(`The proof database in ${place.workdir} isn't set up yet.`); + return parseEnvLines(text); +}; + +// psql from the native stack's own Postgres build; the machine needn't have one. +const psqlPath = (): string => { + const root = path.join(os.homedir(), ".supabase/cache/stack/slim-services/postgres"); + const versions = existsSync(root) ? readdirSync(root).sort().reverse() : []; + for (const version of versions) { + const candidate = path.join(root, version, `${process.platform}-${process.arch === "x64" ? "amd64" : process.arch}`, "bin/psql"); + if (existsSync(candidate)) return candidate; + } + return "psql"; +}; + +// A statement through psql: the native stack's own psql, or the one inside +// the Docker stack's database container. +export const sql = async ( + place: Place, + statement: string, + { rows = false }: { rows?: boolean } = {}, +): Promise<{ code: number; output: string }> => { + const flags = ["-X", "-v", "ON_ERROR_STOP=1", ...(rows ? ["-At"] : ["-q"])]; + const result = + place.runtime === "docker" + ? await run("docker", ["exec", "-i", `supabase_db_${PROJECT_ID}`, "psql", "-U", "postgres", "-d", "postgres", ...flags], { + input: statement, + }) + : await run(psqlPath(), [...flags, "-d", (await databaseEnv(place)).SUPABASE_DB_URL, "-c", statement], { env: cliEnv(place) }); + return { code: result.code, output: (result.stdout + result.stderr).slice(-4000) }; +}; + +const literal = (value: string): string => `'${value.replace(/'/g, "''")}'`; + +// What kits expect to find: the two source spaces publish fixtures write as +// (the dev graph and the Obsidian test vault), test-3-graph where kits run, +// each with its anonymous account, the two authors the published nodes name, +// and the eng-2329-demo group all three are in. Ids are fixed because kits +// name them. A graph a kit runs in that isn't here gets its space at sign-in. +type SeedSpace = { id: number; url: string; name: string; platform: "Roam" | "Obsidian" }; + +export const SEED = { + spaces: [ + { id: 66566, url: "https://roamresearch.com/#/app/discourse-dev-graph", name: "discourse-dev-graph", platform: "Roam" }, + { id: 106039, url: "obsidian:defc3ac0ee76e0de", name: "Sid-Obsidian-test", platform: "Obsidian" }, + { id: 120237, url: "https://roamresearch.com/#/app/test-3-graph", name: "test-3-graph", platform: "Roam" }, + ] as SeedSpace[], + // Each author belongs to the space it publishes from, as a real publish + // leaves it: an importing graph reads relation authors through that access. + authors: [ + { id: 3, platform: "Roam", localId: "proof-author-roam", name: "Proof author (Roam)", space: 66566 }, + { id: 106041, platform: "Obsidian", localId: "proof-author-obsidian", name: "Proof author (Obsidian)", space: 106039 }, + ], + group: { name: "eng-2329-demo", admin: 120237 }, + // Generated ids start past every fixed one. + firstFreeId: 200000, +}; + +export const anonEmail = (space: SeedSpace): string => + `${space.platform.toLowerCase()}-${space.id}-anon@database.discoursegraphs.com`; + +export const groupEmail = (name: string): string => `${name}@groups.discoursegraphs.com`; + +export const seedSql = (): string => { + const lines = [ + "BEGIN;", + `SELECT setval('public.entity_id_seq', GREATEST((SELECT last_value FROM public.entity_id_seq), ${SEED.firstFreeId}));`, + ]; + for (const author of SEED.authors) { + lines.push( + `INSERT INTO "PlatformAccount" (id, platform, account_local_id, name, agent_type) VALUES (${author.id}, '${author.platform}', ${literal(author.localId)}, ${literal(author.name)}, 'person') ON CONFLICT (id) DO NOTHING;`, + ); + } + const group = `(SELECT id FROM auth.users WHERE email = ${literal(groupEmail(SEED.group.name))})`; + for (const space of SEED.spaces) { + const user = `(SELECT id FROM auth.users WHERE email = ${literal(anonEmail(space))})`; + lines.push( + `INSERT INTO "Space" (id, url, name, platform) VALUES (${space.id}, ${literal(space.url)}, ${literal(space.name)}, '${space.platform}') ON CONFLICT (id) DO UPDATE SET url = EXCLUDED.url, name = EXCLUDED.name;`, + `INSERT INTO "PlatformAccount" (platform, account_local_id, name, agent_type, dg_account) VALUES ('${space.platform}', ${literal(anonEmail(space))}, 'Anonymous of space ${space.id}', 'anonymous', ${user}) ON CONFLICT (account_local_id, platform) DO UPDATE SET dg_account = EXCLUDED.dg_account;`, + `INSERT INTO "SpaceAccess" (account_uid, space_id, permissions) VALUES (${user}, ${space.id}, 'editor') ON CONFLICT (account_uid, space_id) DO NOTHING;`, + `INSERT INTO group_membership (group_id, member_id, admin) VALUES (${group}, ${user}, ${space.id === SEED.group.admin}) ON CONFLICT (member_id, group_id) DO NOTHING;`, + `INSERT INTO "SpaceAccess" (account_uid, space_id, permissions) VALUES (${group}, ${space.id}, 'partial') ON CONFLICT (account_uid, space_id) DO NOTHING;`, + ); + } + for (const author of SEED.authors) { + lines.push(`INSERT INTO "LocalAccess" (account_id, space_id) VALUES (${author.id}, ${author.space}) ON CONFLICT DO NOTHING;`); + } + lines.push("COMMIT;"); + return lines.join("\n"); +}; + +type AuthUser = { id: string; email?: string }; + +// Auth users go through the admin API (it writes the identities and token +// columns sign-in needs); the rows that point at them go through SQL. The +// anonymous accounts get a throwaway password: proof.supabase.signIn resets +// it to the one the graph or vault stores. +const seed = async (place: Place, env: Record): Promise => { + const key = env.SUPABASE_SERVICE_ROLE_KEY; + const headers = { apikey: key, authorization: `Bearer ${key}`, "content-type": "application/json" }; + const listed = await fetch(`${PROOF_DB_URL}/auth/v1/admin/users?page=1&per_page=1000`, { headers }); + if (!listed.ok) throw new Error(`Listing auth users failed: ${listed.status} ${(await listed.text()).slice(0, 200)}`); + const existing = new Set(((await listed.json()) as { users?: AuthUser[] }).users?.map((user) => user.email) ?? []); + const wanted = [ + ...SEED.spaces.map((space) => ({ email: anonEmail(space), password: randomUUID(), email_confirm: true })), + { email: groupEmail(SEED.group.name), password: randomUUID(), role: "anon", user_metadata: { group: true }, email_confirm: false }, + ]; + for (const user of wanted.filter((item) => !existing.has(item.email))) { + const created = await fetch(`${PROOF_DB_URL}/auth/v1/admin/users`, { method: "POST", headers, body: JSON.stringify(user) }); + if (!created.ok) throw new Error(`Creating ${user.email} failed: ${created.status} ${(await created.text()).slice(0, 200)}`); + } + const result = await sql(place, seedSql()); + if (result.code !== 0) throw new Error(`Seeding failed: ${result.output.trim().slice(-600)}`); +}; + +const appliedVersions = async (place: Place): Promise => { + const result = await sql(place, "SELECT version FROM supabase_migrations.schema_migrations ORDER BY version", { rows: true }); + if (result.code !== 0) throw new Error(`Reading the applied migrations failed: ${result.output.trim().slice(-400)}`); + return result.output.split("\n").map((line) => line.trim()).filter(Boolean); +}; + +// What brings the database to the checkout's migrations: nothing, applying +// the new ones, or a reset when one it applied changed or isn't there. +export const schemaPlan = ({ + applied, + wanted, + before, +}: { + applied: string[]; + wanted: Record; + before: Record | null; +}): "current" | "apply" | "reset" => { + if (applied.some((version) => !(version in wanted))) return "reset"; + if (before && applied.some((version) => version in before && before[version] !== wanted[version])) return "reset"; + return Object.keys(wanted).some((version) => !applied.includes(version)) ? "apply" : "current"; +}; + +const writeProject = async (place: Place): Promise => { + const source = path.join(place.checkout, SOURCE); + const config = await fs.readFile(path.join(source, "config.toml"), "utf8").catch(() => null); + if (config === null) throw new Error(`No ${SOURCE}/config.toml in ${place.checkout}. Is that a discourse-graph checkout?`); + const { supabase } = files(place); + await fs.rm(supabase, { recursive: true, force: true }); + await fs.mkdir(supabase, { recursive: true }); + await fs.writeFile(path.join(supabase, "config.toml"), proofConfig(config, place.runtime)); + for (const dir of ["migrations", "schemas", "functions"]) { + if (existsSync(path.join(source, dir))) await fs.cp(path.join(source, dir), path.join(supabase, dir), { recursive: true }); + } + return path.join(supabase, "migrations"); +}; + +const writeEnv = async (place: Place, env: Record): Promise => { + const text = `${Object.entries(env) + .map(([name, value]) => `${name}=${value}`) + .join("\n")}\n`; + await fs.writeFile(files(place).env, text, { mode: 0o600 }); +}; + +// Starts the proof database, brings its schema to the checkout's migrations +// (a reset when an applied one changed), seeds what kits expect and writes +// its keys. +export const up = async (place: Place, { forceReset = false }: { forceReset?: boolean } = {}): Promise => { + const before = await readState(place); + const migrations = await writeProject(place); + const digests = await migrationDigests(migrations); + log(`Starting the proof database (${place.runtime}, ${PROOF_DB_URL}) with ${place.checkout}'s schema.`); + const runtime = place.runtime === "native" ? ["--runtime", "native"] : []; + const start = await cli(place, ["start", ...runtime, "--exclude", EXCLUDED.join(","), "--output-format", "text"]); + if (start.code !== 0) throw failed("supabase start", start); + const env = await stackEnv(place); + await writeEnv(place, env); + const plan = forceReset + ? "reset" + : schemaPlan({ applied: await appliedVersions(place), wanted: digests, before: before?.migrations ?? null }); + if (plan === "apply") { + log("Applying the checkout's new migrations."); + const applied = await cli(place, ["migration", "up", "--local", "--include-all"]); + if (applied.code !== 0) throw failed("supabase migration up", applied); + } else if (plan === "reset") { + log("Resetting the database to the checkout's migrations (its data goes)."); + const reset = await cli(place, ["db", "reset", "--local", "--no-seed"]); + if (reset.code !== 0) throw failed("supabase db reset", reset); + } + await seed(place, env); + await fs.writeFile( + files(place).state, + `${JSON.stringify({ source: place.checkout, migrations: digests, seededAt: new Date().toISOString() }, null, 2)}\n`, + ); + log(`Ready: ${PROOF_DB_URL}, keys in ${files(place).env}, schema from ${place.checkout}.`); +}; + +export const stop = async (place: Place): Promise => { + const stopped = await cli(place, ["stop"], true); + if (stopped.code !== 0) throw failed("supabase stop", stopped); +}; + +// Whether the proof database answers, and what it was built from. +export const status = async (place: Place): Promise<{ answering: boolean; state: State | null }> => { + let answering = false; + try { + const response = await fetch(`${PROOF_DB_URL}/auth/v1/health`, { signal: AbortSignal.timeout(8000) }); + answering = response.ok; + } catch { + answering = false; + } + return { answering, state: await readState(place) }; +}; + +// The CLI's own account of the stack, printed. +export const printStatus = async (place: Place): Promise => { + await cli(place, ["status", "--output-format", "text"], true); +}; diff --git a/prototypes/proof-runner/helper/handler.ts b/prototypes/proof-runner/helper/handler.ts new file mode 100644 index 0000000..30dd31f --- /dev/null +++ b/prototypes/proof-runner/helper/handler.ts @@ -0,0 +1,144 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +// dg-proof:// links, registered once per machine so the panel's buttons can +// start the helper (and an agent). The link reaches a launcher script that +// runs the helper's `open` in the background, so the browser's click returns +// at once. Linux registers through xdg (a desktop entry); macOS through a +// small AppleScript app that receives the link as an event, since macOS +// doesn't hand links to plain scripts; Windows through registry keys under +// the user's own classes, which need no admin. The plans are data, so each +// is tested on any machine. + +export type HandlerPlan = { + files: Array<{ path: string; text: string; mode?: number }>; + // Run in order after the files are written. + commands: string[][]; + // What the uninstall removes, and runs. + remove: string[]; + undo?: string[][]; +}; + +const quote = (value: string): string => `'${value.replace(/'/g, "'\\''")}'`; + +// The script every link runs: the helper's open, detached and logged. +const launcherScript = (command: string[], log: string): string => + [ + "#!/bin/sh", + "# Written by the proof helper's setup: answers dg-proof:// links.", + `nohup ${command.map(quote).join(" ")} open "$1" >> ${quote(log)} 2>&1 < /dev/null &`, + "", + ].join("\n"); + +export const linuxHandler = ({ home, command, app }: { home: string; command: string[]; app: string }): HandlerPlan => { + const launcher = path.join(app, "open.sh"); + const applications = path.join(home, ".local/share/applications"); + const desktop = path.join(applications, "dg-proof.desktop"); + return { + files: [ + { path: launcher, text: launcherScript(command, path.join(app, "handler.log")), mode: 0o755 }, + { + path: desktop, + text: ["[Desktop Entry]", "Type=Application", "Name=Proof kits", `Exec=${launcher} %u`, "MimeType=x-scheme-handler/dg-proof;", "NoDisplay=true", "Terminal=false", ""].join("\n"), + }, + ], + commands: [ + ["xdg-mime", "default", "dg-proof.desktop", "x-scheme-handler/dg-proof"], + ["update-desktop-database", applications], + ], + remove: [desktop, launcher], + }; +}; + +const LSREGISTER = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister"; + +export const macHandler = ({ home, command, app }: { home: string; command: string[]; app: string }): HandlerPlan => { + const launcher = path.join(app, "open.sh"); + const source = path.join(app, "DG Proof.applescript"); + const bundle = path.join(home, "Applications", "DG Proof.app"); + const plist = path.join(bundle, "Contents", "Info.plist"); + const applescript = [ + "on open location theURL", + `\tdo shell script quoted form of ${JSON.stringify(launcher)} & " " & quoted form of theURL`, + "end open location", + "", + ].join("\n"); + return { + files: [ + { path: launcher, text: launcherScript(command, path.join(app, "handler.log")), mode: 0o755 }, + { path: source, text: applescript }, + ], + commands: [ + ["osacompile", "-o", bundle, source], + ["plutil", "-replace", "CFBundleURLTypes", "-json", JSON.stringify([{ CFBundleURLName: "DG Proof", CFBundleURLSchemes: ["dg-proof"] }]), plist], + ["plutil", "-replace", "LSUIElement", "-bool", "true", plist], + [LSREGISTER, "-f", bundle], + ], + remove: [launcher, source, bundle], + }; +}; + +const WINDOWS_KEY = "HKCU\\Software\\Classes\\dg-proof"; + +// The registry runs the launcher with the link; it starts the helper in its +// own minimized window, so the browser's click returns, and closing that +// window stops the helper. +export const windowsHandler = ({ command, app }: { command: string[]; app: string }): HandlerPlan => { + const launcher = path.win32.join(app, "open.cmd"); + const quoteCmd = (value: string): string => `"${value.replace(/"/g, '""')}"`; + return { + files: [ + { + path: launcher, + text: ["@echo off", "rem Written by the proof helper's setup: answers dg-proof:// links.", `start "proof helper" /min ${command.map(quoteCmd).join(" ")} open %1`, ""].join("\r\n"), + }, + ], + commands: [ + ["reg", "add", WINDOWS_KEY, "/ve", "/d", "URL:DG Proof", "/f"], + ["reg", "add", WINDOWS_KEY, "/v", "URL Protocol", "/d", "", "/f"], + ["reg", "add", `${WINDOWS_KEY}\\shell\\open\\command`, "/ve", "/d", `"${launcher}" "%1"`, "/f"], + ], + remove: [launcher], + undo: [["reg", "delete", WINDOWS_KEY, "/f"]], + }; +}; + +export const handlerFor = (platform: NodeJS.Platform, options: { home: string; command: string[]; app: string }): HandlerPlan => { + if (platform === "linux") return linuxHandler(options); + if (platform === "darwin") return macHandler(options); + if (platform === "win32") return windowsHandler(options); + throw new Error(`dg-proof:// links can't be registered on ${platform} (Linux, macOS and Windows can).`); +}; + +// update-desktop-database isn't on every Linux desktop; xdg-mime's default +// is what links follow. +const OPTIONAL = new Set(["update-desktop-database"]); + +export const installHandler = async (plan: HandlerPlan): Promise => { + for (const file of plan.files) { + await fs.mkdir(path.dirname(file.path), { recursive: true }); + await fs.writeFile(file.path, file.text, { mode: file.mode }); + } + for (const [command, ...args] of plan.commands) { + try { + execFileSync(command, args, { stdio: "inherit" }); + } catch (error) { + if (!OPTIONAL.has(command)) throw error; + } + } +}; + +export const uninstallHandler = async (plan: HandlerPlan): Promise => { + for (const target of plan.remove) await fs.rm(target, { recursive: true, force: true }); + for (const [command, ...args] of plan.undo ?? []) { + try { + execFileSync(command, args, { stdio: "inherit" }); + } catch { + // Already gone. + } + } +}; + +export const thisMachine = (command: string[], app: string): HandlerPlan => handlerFor(process.platform, { home: os.homedir(), command, app }); diff --git a/prototypes/proof-runner/helper/server.ts b/prototypes/proof-runner/helper/server.ts new file mode 100644 index 0000000..a29ca33 --- /dev/null +++ b/prototypes/proof-runner/helper/server.ts @@ -0,0 +1,135 @@ +import http from "node:http"; +import { EMBED_STUB_URL, LOCAL_BUILDS_URL, PROOF_DB_URL } from "../src/core/database.ts"; +import * as database from "./database.ts"; +import type { Place } from "./database.ts"; +import { startEmbedStub } from "./stub.ts"; + +// This machine's helper for the in-Roam runner, for kits that touch a +// database. With ensure (what the panel's Connect starts) it first brings up +// the embeddings stub and the proof database, saying "starting" meanwhile, +// then hands the runner the proof database's address and the kits' env. +// Only roamresearch.com pages may reach it (Chrome asks once before a page +// there can reach localhost). The keys are Supabase's stock keys for a stack +// that holds only test data, and stay in the tab that asked. POST /stop +// stops it and what it started. + +export const ROAM_ORIGIN = "https://roamresearch.com"; + +const describe = (error: unknown): string => (error instanceof Error ? error.message : String(error)); + +const answers = async (url: string, init?: RequestInit): Promise => { + try { + await fetch(url, { ...init, signal: AbortSignal.timeout(3000) }); + return true; + } catch { + return false; + } +}; + +export const helperAnswers = (port = Number(new URL(LOCAL_BUILDS_URL).port)): Promise => + answers(`http://127.0.0.1:${port}/status`); + +export const serveHelper = async ({ + place, + port = Number(new URL(LOCAL_BUILDS_URL).port), + ensure = false, + log = (line: string) => console.log(`[proof-helper] ${line}`), +}: { + place: Place; + port?: number; + ensure?: boolean; + log?: (line: string) => void; +}): Promise => { + const stoppers: Array<() => Promise | void> = []; + let starting = ensure; + let failure: string | null = null; + const shutdown = async (): Promise => { + log("Stopping."); + for (const stopIt of stoppers.reverse()) { + try { + await stopIt(); + } catch (error) { + log(`Couldn't stop everything: ${describe(error)}`); + } + } + process.exit(0); + }; + const server = http.createServer((request, response) => { + const origin = request.headers.origin; + response.setHeader("Cache-Control", "no-store"); + response.setHeader("Vary", "Origin"); + if (origin && origin !== ROAM_ORIGIN) { + response.writeHead(403).end(); + return; + } + if (origin) response.setHeader("Access-Control-Allow-Origin", origin); + if (request.method === "OPTIONS") { + response.setHeader("Access-Control-Allow-Methods", "GET, POST"); + if (request.headers["access-control-request-private-network"]) { + response.setHeader("Access-Control-Allow-Private-Network", "true"); + } + response.writeHead(204).end(); + return; + } + const url = new URL(request.url ?? "/", `http://127.0.0.1:${port}`); + if (request.method === "POST" && url.pathname === "/stop") { + response.writeHead(204).end(); + void shutdown(); + return; + } + if (request.method !== "GET" || url.pathname !== "/status") { + response.writeHead(request.method === "GET" ? 404 : 405).end(); + return; + } + void (async () => { + const { answering } = starting ? { answering: false } : await database.status(place); + const env = answering ? await database.databaseEnv(place).catch(() => null) : null; + const body = { + database: env ? { url: PROOF_DB_URL, publishableKey: env.SUPABASE_PUBLISHABLE_KEY } : null, + // What kits fill {{env.X}} from, in the asking tab only. + env: env + ? { + SUPABASE_URL: PROOF_DB_URL, + SUPABASE_PUBLISHABLE_KEY: env.SUPABASE_PUBLISHABLE_KEY, + SUPABASE_SERVICE_ROLE_KEY: env.SUPABASE_SERVICE_ROLE_KEY, + } + : null, + starting, + error: failure, + }; + response.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify(body)); + })().catch((error: unknown) => { + if (!response.headersSent) response.writeHead(500); + response.end(String(error)); + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", () => resolve()); + }); + log(`Answering ${ROAM_ORIGIN} on http://127.0.0.1:${port}/ (database: ${place.runtime}, schema from ${place.checkout}).`); + process.on("SIGTERM", () => void shutdown()); + process.on("SIGINT", () => void shutdown()); + if (ensure) { + try { + if (!(await answers(EMBED_STUB_URL, { method: "OPTIONS" }))) { + const stub = await startEmbedStub(); + log(`Embeddings stub on ${EMBED_STUB_URL}.`); + stoppers.push(() => { + stub.close(); + }); + } + if (!(await database.status(place)).answering) { + await database.up(place); + stoppers.push(() => database.stop(place)); + } + log("The proof database and the embeddings stub are up."); + } catch (error) { + failure = `Couldn't start the proof database: ${describe(error)}`; + log(failure); + } finally { + starting = false; + } + } + await new Promise(() => undefined); +}; diff --git a/prototypes/proof-runner/helper/stub.ts b/prototypes/proof-runner/helper/stub.ts new file mode 100644 index 0000000..d7df48c --- /dev/null +++ b/prototypes/proof-runner/helper/stub.ts @@ -0,0 +1,46 @@ +import http from "node:http"; +import { EMBED_STUB_URL } from "../src/core/database.ts"; + +// DG's embeddings and error reports, answered on this machine: every POST +// gets one embedding of the right length per input, which is all a kit +// needs from them. A build pointed at the proof database calls it from +// roamresearch.com, so it answers the private-network preflight too. + +const DIMENSIONS = 1536; + +export const embeddingsFor = (body: string): { data: Array<{ embedding: number[] }> } => { + let input: unknown = []; + try { + input = (JSON.parse(body || "{}") as { input?: unknown }).input ?? []; + } catch { + input = []; + } + const count = Array.isArray(input) ? input.length : typeof input === "string" ? 1 : 0; + const embedding = new Array(DIMENSIONS).fill(0.001); + return { data: Array.from({ length: count }, () => ({ embedding })) }; +}; + +export const startEmbedStub = (port = Number(new URL(EMBED_STUB_URL).port)): Promise => + new Promise((resolve, reject) => { + const server = http.createServer((request, response) => { + response.setHeader("Access-Control-Allow-Origin", "*"); + response.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS"); + response.setHeader("Access-Control-Allow-Headers", "*"); + if (request.method === "OPTIONS") { + if (request.headers["access-control-request-private-network"]) { + response.setHeader("Access-Control-Allow-Private-Network", "true"); + } + response.writeHead(204).end(); + return; + } + let body = ""; + request.on("data", (chunk: Buffer) => { + body += chunk.toString(); + }); + request.on("end", () => { + response.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify(embeddingsFor(body))); + }); + }); + server.once("error", reject); + server.listen(port, "127.0.0.1", () => resolve(server)); + }); diff --git a/prototypes/proof-runner/package.json b/prototypes/proof-runner/package.json new file mode 100644 index 0000000..46de34c --- /dev/null +++ b/prototypes/proof-runner/package.json @@ -0,0 +1,22 @@ +{ + "name": "proof-runner", + "version": "0.1.0", + "private": true, + "description": "Runs a pull request's proof kit from a Roam page: loads the PR's Discourse Graph build and plays the kit's cases in a panel in the right sidebar.", + "type": "module", + "scripts": { + "dev": "roam-prototype dev", + "build": "roam-prototype build", + "test": "vitest run --passWithNoTests", + "helper": "node --experimental-strip-types --no-warnings=ExperimentalWarning helper/cli.ts" + }, + "dependencies": { + "roamjs-components": "catalog:", + "use-sync-external-store": "catalog:" + }, + "devDependencies": { + "@discoursegraphs/extension-base": "workspace:*", + "jsdom": "catalog:", + "vitest": "catalog:" + } +} diff --git a/prototypes/proof-runner/src/baselines/dg-baseline@1.json b/prototypes/proof-runner/src/baselines/dg-baseline@1.json new file mode 100644 index 0000000..17947b5 --- /dev/null +++ b/prototypes/proof-runner/src/baselines/dg-baseline@1.json @@ -0,0 +1,81 @@ +{ + "name": "dg-baseline", + "version": 1, + "why": "What most DG Roam proofs assume: the sync, node-sharing and node-search flags on, the four default node types, one page per default type, and a quiet UI. Taken from what past storyboards seeded most and what the open PRs need.", + "prepare": [ + { + "id": "flag-sync", + "why": "the sync flag (Suggestive mode overlay enabled) is on, so DG: Discover shared nodes is registered", + "phase": "before-load", + "check": { "js": "proof.flags.get('Suggestive mode overlay enabled')" }, + "apply": { "js": "proof.flags.set('Suggestive mode overlay enabled', true)" } + }, + { + "id": "flag-node-sharing", + "why": "node sharing is on, so DG: Share current node, Publish and Refresh are registered", + "phase": "before-load", + "check": { "js": "proof.flags.get('Enable node sharing')" }, + "apply": { "js": "proof.flags.set('Enable node sharing', true)" } + }, + { + "id": "flag-node-search", + "why": "advanced node search is on, so DG: Open Node Search is registered", + "phase": "before-load", + "check": { "js": "proof.flags.get('Advanced node search enabled')" }, + "apply": { "js": "proof.flags.set('Advanced node search enabled', true)" } + }, + { + "id": "baseline-pages", + "why": "one page per default node type exists", + "phase": "in-session", + "check": { + "js": "['[[CLM]] - PK baseline claim', '[[QUE]] - PK baseline question', '@PK baseline source', '[[EVD]] - PK baseline evidence - [[@PK baseline source]]'].every((title) => proof.page.exists(title))" + }, + "apply": { + "js": "(async () => { for (const title of ['[[CLM]] - PK baseline claim', '[[QUE]] - PK baseline question', '@PK baseline source', '[[EVD]] - PK baseline evidence - [[@PK baseline source]]']) { await proof.page.ensure(title, ['Baseline page for proof kits. Leave it in place.']); } })()" + } + }, + { + "id": "quiet-ui", + "why": "no dialog, toast or right-sidebar window is left over from the load", + "phase": "in-session", + "apply": { + "js": "(async () => { await proof.closeDialogs(); proof.toasts.clear(); await proof.sidebar.clearRight(); })()" + } + } + ], + "smoke": [ + { + "id": "extension-loaded", + "title": "The DG extension loaded", + "expect": { + "js": "proof.assert(typeof (window.roamjs && window.roamjs.extension && window.roamjs.extension.queryBuilder && window.roamjs.extension.queryBuilder.runQuery) === 'function', 'The DG extension did not load.')" + } + }, + { + "id": "default-node-types", + "title": "The four default node types exist", + "expect": { + "js": "(() => { const labels = proof.nodeTypes().map((node) => node.text); const missing = ['Claim', 'Question', 'Evidence', 'Source'].filter((label) => !labels.includes(label)); proof.assert(missing.length === 0, 'Missing node types: ' + missing.join(', ') + '. Found: ' + labels.join(', ')); })()" + } + }, + { + "id": "flags-on", + "title": "The baseline flags read back as on", + "expect": { + "js": "(() => { const off = ['Suggestive mode overlay enabled', 'Enable node sharing', 'Advanced node search enabled'].filter((name) => !proof.flags.get(name)); proof.assert(off.length === 0, 'These flags are off: ' + off.join(', ')); })()" + } + }, + { + "id": "settings-open", + "title": "Discourse settings open from the command palette", + "steps": [ + { "use": "settings.open-home", "why": "open the settings" }, + { "use": "dialog.close-all", "why": "close them again" } + ], + "expect": { + "js": "proof.assert(proof.dialogs().length === 0, 'A dialog is still open after closing settings.')" + } + } + ] +} diff --git a/prototypes/proof-runner/src/baselines/dg-baseline@2.json b/prototypes/proof-runner/src/baselines/dg-baseline@2.json new file mode 100644 index 0000000..c4657a8 --- /dev/null +++ b/prototypes/proof-runner/src/baselines/dg-baseline@2.json @@ -0,0 +1,99 @@ +{ + "name": "dg-baseline", + "version": 2, + "why": "What most DG Roam proofs assume, without a database: sync and node sharing off (so a PR's CI build can run it, for anyone), advanced node search on, the four default node types, one page per default type, and a quiet UI. A kit that needs the database turns sync or sharing on in its own prepare and says needs: supabase; it then runs against the proof database.", + "prepare": [ + { + "id": "no-sync", + "why": "the sync flag (Suggestive mode overlay enabled) is off, so DG doesn't connect to a database when it loads", + "phase": "before-load", + "check": { + "js": "!proof.flags.get('Suggestive mode overlay enabled')" + }, + "apply": { + "js": "proof.flags.set('Suggestive mode overlay enabled', false)" + } + }, + { + "id": "no-node-sharing", + "why": "node sharing is off, so DG doesn't connect to a database when it loads", + "phase": "before-load", + "check": { + "js": "!proof.flags.get('Enable node sharing')" + }, + "apply": { + "js": "proof.flags.set('Enable node sharing', false)" + } + }, + { + "id": "flag-node-search", + "why": "advanced node search is on, so DG: Open Node Search is registered", + "phase": "before-load", + "check": { + "js": "proof.flags.get('Advanced node search enabled')" + }, + "apply": { + "js": "proof.flags.set('Advanced node search enabled', true)" + } + }, + { + "id": "baseline-pages", + "why": "one page per default node type exists", + "phase": "in-session", + "check": { + "js": "['[[CLM]] - PK baseline claim', '[[QUE]] - PK baseline question', '@PK baseline source', '[[EVD]] - PK baseline evidence - [[@PK baseline source]]'].every((title) => proof.page.exists(title))" + }, + "apply": { + "js": "(async () => { for (const title of ['[[CLM]] - PK baseline claim', '[[QUE]] - PK baseline question', '@PK baseline source', '[[EVD]] - PK baseline evidence - [[@PK baseline source]]']) { await proof.page.ensure(title, ['Baseline page for proof kits. Leave it in place.']); } })()" + } + }, + { + "id": "quiet-ui", + "why": "no dialog, toast or right-sidebar window is left over from the load", + "phase": "in-session", + "apply": { + "js": "(async () => { await proof.closeDialogs(); proof.toasts.clear(); await proof.sidebar.clearRight(); })()" + } + } + ], + "smoke": [ + { + "id": "extension-loaded", + "title": "The DG extension loaded", + "expect": { + "js": "proof.assert(typeof (window.roamjs && window.roamjs.extension && window.roamjs.extension.queryBuilder && window.roamjs.extension.queryBuilder.runQuery) === 'function', 'The DG extension did not load.')" + } + }, + { + "id": "default-node-types", + "title": "The four default node types exist", + "expect": { + "js": "(() => { const labels = proof.nodeTypes().map((node) => node.text); const missing = ['Claim', 'Question', 'Evidence', 'Source'].filter((label) => !labels.includes(label)); proof.assert(missing.length === 0, 'Missing node types: ' + missing.join(', ') + '. Found: ' + labels.join(', ')); })()" + } + }, + { + "id": "flags", + "title": "The baseline flags read back: node search on, sync and sharing off", + "expect": { + "js": "(() => { const wrong = [['Advanced node search enabled', true], ['Suggestive mode overlay enabled', false], ['Enable node sharing', false]].filter(([name, value]) => proof.flags.get(name) !== value).map(([name]) => name); proof.assert(wrong.length === 0, 'These flags are the wrong way round: ' + wrong.join(', ')); })()" + } + }, + { + "id": "settings-open", + "title": "Discourse settings open from the command palette", + "steps": [ + { + "use": "settings.open-home", + "why": "open the settings" + }, + { + "use": "dialog.close-all", + "why": "close them again" + } + ], + "expect": { + "js": "proof.assert(proof.dialogs().length === 0, 'A dialog is still open after closing settings.')" + } + } + ] +} diff --git a/prototypes/proof-runner/src/core/action.ts b/prototypes/proof-runner/src/core/action.ts new file mode 100644 index 0000000..b4d0095 --- /dev/null +++ b/prototypes/proof-runner/src/core/action.ts @@ -0,0 +1,68 @@ +// The actions a step can do, the same shapes recorder/recordDemo.ts runs +// over CDP. Kept here so the browser runner can use them without the +// Playwright recorder; tsc flags it where the two drift apart. + +export type ClickAction = { + click: + | string + | { + selector: string; + button?: "left" | "right" | "middle"; + count?: number; + position?: { x: number; y: number }; + }; +}; +export type TypeAction = { + type: { into: string; text: string; delay_ms?: number }; +}; +export type FillAction = { fill: { into: string; text: string } }; +export type PressAction = { + press: string | { selector?: string; key: string }; +}; +export type ScrollAction = { scroll: number | { y?: number; to?: string } }; +/** + * Glide the mouse onto an element without clicking. Pair with a follow-up + * `press` for menus that swallow synthetic clicks (e.g. popovers rendered + * inside the tldraw canvas eat pointer-up, so onClick never fires): the + * burned cursor lands on the item, the hotkey does the actual selection. + */ +export type HoverAction = { hover: string }; +export type PauseAction = { pause: number }; +export type WaitForAction = { wait_for: string }; +export type OpenAction = { open: string }; +export type JsAction = { js: string }; +export type ScreenshotAction = { screenshot: string }; +export type CommandPaletteAction = { + command_palette: string | { label: string; type_delay_ms?: number }; +}; +/** + * Runs its actions only when the js expression is truthy in the page, so a + * toggle-style setting can be turned on without flipping it back off on the + * next run: { when: { js: "!overlayIsOn()", do: [{ command_palette: ... }] } }. + */ +export type WhenAction = { when: { js: string; do: Action[] } }; +export type DragAction = { + drag: { + hover?: string; + hoverAt?: "center" | "end"; + from: string; + to: string; + toPosition?: { x: number; y: number }; + }; +}; + +export type Action = + | ClickAction + | TypeAction + | FillAction + | PressAction + | HoverAction + | ScrollAction + | PauseAction + | WaitForAction + | OpenAction + | JsAction + | ScreenshotAction + | CommandPaletteAction + | DragAction + | WhenAction; diff --git a/prototypes/proof-runner/src/core/database.ts b/prototypes/proof-runner/src/core/database.ts new file mode 100644 index 0000000..4b1d070 --- /dev/null +++ b/prototypes/proof-runner/src/core/database.ts @@ -0,0 +1,99 @@ +// The proof database: a local Supabase on its own ports, used by proof kits +// and nothing else. Builds made for kits are compiled against it, and every +// path in proof that touches a database (fixtures, helpers, the in-Roam +// runner) goes here. The hosted database (production) is never one of them. +export const PROOF_DB_HOST = "127.0.0.1:55321"; +export const PROOF_DB_URL = `http://${PROOF_DB_HOST}`; + +// Where `roam/cli.ts local` serves PRs' local builds and the proof +// database's keys to the in-Roam runner. +export const LOCAL_BUILDS_URL = "http://127.0.0.1:8766"; + +// The embeddings stub, which local builds also name on 127.0.0.1. +const EMBED_STUB_PORT = "3210"; +export const EMBED_STUB_URL = `http://127.0.0.1:${EMBED_STUB_PORT}`; + +// The database a build was compiled against, read from the bundle: the proof +// database, a hosted project, or another local stack (a dev one). DG's other +// local URLs (the website on localhost:3000 and friends) use "localhost", +// while Supabase's own status output, which builds bake, uses 127.0.0.1. +export const databaseUrlOf = (source: string): string | null => { + if (source.includes(PROOF_DB_HOST)) return PROOF_DB_URL; + const hosted = /https:\/\/[a-z0-9]+\.supabase\.co/.exec(source)?.[0]; + if (hosted) return hosted; + for (const match of source.matchAll(/http:\/\/127\.0\.0\.1:(\d+)/g)) { + if (match[1] !== EMBED_STUB_PORT) return match[0]; + } + return null; +}; + +export const isProofDatabase = (url: string | null): boolean => url === PROOF_DB_URL; + +const HOSTED_DB = /https:\/\/[a-z0-9]+\.supabase\.co/g; +const PUBLISHABLE_KEY = /sb_publishable_[A-Za-z0-9_-]+/g; +// A legacy anon key: a JWT whose payload names the anon role. +const JWT = /eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g; +// DG's website root, which its API and error reports hang off; docs links +// and schema ids under it stay as they are. +const WEBSITE_API = /https:\/\/discoursegraphs\.com\/(?=api\b|["'`])/g; + +const isAnonKey = (token: string): boolean => { + try { + const part = token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"); + const payload = JSON.parse(atob(part.padEnd(part.length + ((4 - (part.length % 4)) % 4), "="))) as { role?: string }; + return payload.role === "anon"; + } catch { + return false; + } +}; + +// What of the hosted stack a build still names: its database, a key that +// isn't the proof database's, DG's website API. +export const productionLeftIn = (source: string, publishableKey: string): string[] => { + const left = new Set(); + for (const match of source.matchAll(HOSTED_DB)) left.add(match[0]); + for (const match of source.matchAll(PUBLISHABLE_KEY)) if (match[0] !== publishableKey) left.add("a hosted publishable key"); + for (const match of source.matchAll(JWT)) if (match[0] !== publishableKey && isAnonKey(match[0])) left.add("a hosted anon key"); + if (WEBSITE_API.test(source)) left.add("discoursegraphs.com's API"); + WEBSITE_API.lastIndex = 0; + return [...left]; +}; + +// A PR's CI build, compiled against the hosted database, pointed at the +// proof database before it runs: the Supabase URL and key go to the proof +// database, and DG's website API (embeddings, error reports) to the +// embeddings stub, which is what a local build bakes in. Throws when any of +// the hosted stack is left, so a build that could still reach production +// never runs. +export const pointAtProofDatabase = (source: string, publishableKey: string): string => { + const pointed = source + .replace(HOSTED_DB, PROOF_DB_URL) + .replace(PUBLISHABLE_KEY, publishableKey) + .replace(JWT, (token) => (isAnonKey(token) ? publishableKey : token)) + .replace(WEBSITE_API, `${EMBED_STUB_URL}/`); + const left = productionLeftIn(pointed, publishableKey); + if (left.length) { + throw new Error(`This build still names ${left.join(", ")} after pointing it at the proof database, so it won't run.`); + } + if (!pointed.includes(PROOF_DB_HOST)) throw new Error("This build names no database to point at the proof database."); + return pointed; +}; + +// The flags that make DG connect to its database when it loads. +export const CONNECTING_FLAGS = ["Suggestive mode overlay enabled", "Enable node sharing"]; + +const FLAG_SET = /proof\.flags\.set\(\s*(['"])(.+?)\1\s*,\s*(true|false)\s*\)/g; + +// The connecting flags on once a kit's setup has run: what's on in the graph +// now, then each setup js that turns one on or off, in order. +export const connectingFlagsAfter = (on: readonly string[], setupJs: readonly string[]): string[] => { + const flags = new Set(on.filter((name) => CONNECTING_FLAGS.includes(name))); + for (const js of setupJs) { + for (const match of js.matchAll(FLAG_SET)) { + if (!CONNECTING_FLAGS.includes(match[2])) continue; + if (match[3] === "true") flags.add(match[2]); + else flags.delete(match[2]); + } + } + return [...flags]; +}; diff --git a/prototypes/proof-runner/src/core/fixtures.ts b/prototypes/proof-runner/src/core/fixtures.ts new file mode 100644 index 0000000..acc59a2 --- /dev/null +++ b/prototypes/proof-runner/src/core/fixtures.ts @@ -0,0 +1,112 @@ +import type { Action } from "./action"; +import type { Fixture, Step, TestCase } from "./kit"; + +// Fixtures and baseline smoke cases as machine steps, for a live rehearsal and +// the in-Roam runner alike. + +const wrapCheckApply = ( + fixture: Fixture, + check: string | null, + apply: string, +): string => { + const label = JSON.stringify( + `Fixture ${fixture.id} did not take: ${fixture.why}`, + ); + if (!check) { + return `(async () => { await (${apply}); return "applied"; })()`; + } + return `(async () => { + const present = async () => Boolean(await (${check})); + if (await present()) return "kept"; + await (${apply}); + if (!(await present())) throw new Error(${label}); + return "applied"; +})()`; +}; + +const assertStep = (check: string, label: string): string => + `(async () => { if (!(await (${check}))) throw new Error(${JSON.stringify(label)}); })()`; + +// In-session fixtures become ordinary steps: a js fixture is one step that +// checks, applies and checks again; a UI fixture runs its steps every time, +// then asserts its check. Strings stay unfilled until they run. +export const inSessionSteps = (fixtures: Fixture[]): Step[] => { + const steps: Step[] = []; + for (const fixture of fixtures.filter( + (item) => item.phase === "in-session", + )) { + const check = + fixture.check && "js" in fixture.check ? fixture.check.js : null; + if ("js" in fixture.apply) { + steps.push({ + id: `fixture-${fixture.id}`, + do: { js: wrapCheckApply(fixture, check, fixture.apply.js) }, + why: fixture.why, + source: "kit", + }); + continue; + } + if ("steps" in fixture.apply) { + fixture.apply.steps.forEach((step, index) => { + steps.push({ + ...step, + id: `fixture-${fixture.id}-${index + 1}`, + source: "kit", + }); + }); + if (check) { + steps.push({ + id: `fixture-${fixture.id}-check`, + do: { + js: assertStep( + check, + `Fixture ${fixture.id} did not take: ${fixture.why}`, + ), + }, + why: `confirm ${fixture.why}`, + source: "kit", + }); + } + } + } + return steps; +}; + +export const assertCase = (js: string, label: string): Action => ({ + js: `(async () => { + try { + await (${js}); + } catch (error) { + throw new Error(${JSON.stringify(label)} + ": " + (error && error.message ? error.message : String(error))); + } +})()`, +}); + +// Smoke cases are always-true checks from the baseline. They run as setup +// steps, so a broken environment fails fast and never reaches a PR case. +// Smoke checks that need your eyes (text only) can't run unattended. +export const smokeSteps = (smoke: TestCase[]): Step[] => { + const steps: Step[] = []; + for (const testCase of smoke) { + if (testCase.steps.length === 0 && !testCase.expect?.js) continue; + testCase.steps.forEach((step, index) => { + steps.push({ + ...step, + id: `smoke-${testCase.id}-${index + 1}`, + source: "kit", + }); + }); + if (testCase.expect?.js) { + steps.push({ + id: `smoke-${testCase.id}-check`, + do: assertCase( + testCase.expect.js, + `Smoke check failed: ${testCase.title}`, + ), + why: `smoke: ${testCase.title}`, + source: "kit", + }); + } + } + return steps; +}; diff --git a/prototypes/proof-runner/src/core/helpers.ts b/prototypes/proof-runner/src/core/helpers.ts new file mode 100644 index 0000000..60b07d0 --- /dev/null +++ b/prototypes/proof-runner/src/core/helpers.ts @@ -0,0 +1,797 @@ +// The helper library every proof session loads before the page's own scripts, +// as window.proof. Kit js uses it instead of pasting its own waiters and +// clickers: proof.waitFor(...), proof.callout("Stored relations are disabled"), +// proof.setSwitch("Enable stored relations", false, { confirm: ["Deactivate"] }). +// Plain JS in a string: tsx would inject helpers into serialized functions. +import { PROOF_DB_URL } from "./database"; + +export const HELPERS_INIT_SCRIPT = String.raw`(() => { + if (window.proof) return; + + // The proof database (core/database.ts), the only one proof talks to. + const PROOF_DB_URL = ${JSON.stringify(PROOF_DB_URL)}; + + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + + const describe = (probe, what) => + what || (typeof probe === "string" ? probe : "the condition"); + + // Resolves with the first truthy value of a selector or a function. + const waitFor = async (probe, options) => { + const timeout = (options && options.timeout) || 10000; + const interval = (options && options.interval) || 100; + const deadline = Date.now() + timeout; + for (;;) { + const value = + typeof probe === "string" ? document.querySelector(probe) : await probe(); + if (value) return value; + if (Date.now() > deadline) { + throw new Error( + "Timed out after " + timeout + " ms waiting for " + describe(probe, options && options.what) + ".", + ); + } + await sleep(interval); + } + }; + + const assert = (condition, message) => { + if (!condition) throw new Error(message || "Assertion failed."); + return true; + }; + + const visible = (element) => + Boolean(element) && + element.getClientRects().length > 0 && + getComputedStyle(element).visibility !== "hidden"; + + const textOf = (element) => (element.innerText || element.textContent || "").trim(); + + const byText = (selector, text, root) => + Array.from((root || document).querySelectorAll(selector)).find( + (element) => visible(element) && textOf(element).includes(text), + ) || null; + + const dialogs = () => + Array.from(document.querySelectorAll(".bp3-dialog")).filter(visible); + + const dialogText = () => dialogs().map(textOf).join("\n"); + + const callout = (title) => byText(".bp3-callout", title); + + const switchByLabel = (label) => + byText("label.bp3-control, label.bp3-checkbox, label.bp3-switch", label); + + // Blueprint hides the real input under .bp3-control-indicator, so click + // the indicator. confirm lists dialog buttons to try, in order, when the + // switch asks before it changes (Stored relations does both ways). + const setSwitch = async (label, on, options) => { + const control = await waitFor(() => switchByLabel(label), { + what: 'the "' + label + '" switch', + }); + const input = control.querySelector("input"); + if (!input) throw new Error('The "' + label + '" switch has no input.'); + if (input.checked === on) return "kept"; + (control.querySelector(".bp3-control-indicator") || control).click(); + const confirm = (options && options.confirm) || []; + if (confirm.length) { + const button = await waitFor( + () => + confirm + .map((name) => byText(".bp3-dialog button", name)) + .find(Boolean) || null, + { what: "one of: " + confirm.join(", ") }, + ); + button.click(); + } + await waitFor( + () => { + const current = switchByLabel(label); + const box = current && current.querySelector("input"); + return Boolean(box) && box.checked === on; + }, + { what: '"' + label + '" to turn ' + (on ? "on" : "off") }, + ); + return "changed"; + }; + + // Blueprint v3 closes a dialog on an Escape keydown that reaches its overlay + // with which 27. A constructed event has which 0 and, sent to the document, + // never reaches the overlay, so DG's settings (no close button) stayed open. + const pressEscapeOn = (target) => { + const event = new KeyboardEvent("keydown", { key: "Escape", code: "Escape", bubbles: true, cancelable: true, composed: true }); + Object.defineProperty(event, "keyCode", { get: () => 27 }); + Object.defineProperty(event, "which", { get: () => 27 }); + target.dispatchEvent(event); + }; + + // Closes the top dialog first: its close button when it has one, else Escape. + const closeDialogs = async () => { + for (let attempt = 0; attempt < 6 && dialogs().length; attempt += 1) { + const open = dialogs(); + const top = open[open.length - 1]; + const close = + top.querySelector("button[aria-label='Close']") || + document.querySelector(".rm-settings-close-button"); + if (close) close.click(); + else pressEscapeOn(top); + await sleep(300); + } + return dialogs().length === 0; + }; + + // Menus a step left open, such as DG's node search menu: a Blueprint + // popover in a portal that stays up after its block stops editing. Like a + // dialog, it closes on an Escape keydown that reaches its overlay. + const menus = () => Array.from(document.querySelectorAll(".bp3-portal .bp3-popover")).filter(visible); + + const closeMenus = async () => { + for (let attempt = 0; attempt < 6 && menus().length; attempt += 1) { + const open = menus(); + pressEscapeOn(open[open.length - 1]); + await sleep(200); + } + return menus().length === 0; + }; + + // The proof database's REST API. Pass the key from the kit, + // {{env.SUPABASE_SERVICE_ROLE_KEY}}, so it is filled in only while the step runs. + const rest = async (path, options) => { + const opts = options || {}; + if (!opts.key) throw new Error("proof.rest needs { key }."); + const headers = { + apikey: opts.key, + authorization: "Bearer " + opts.key, + "content-type": "application/json", + }; + if (opts.prefer) headers.prefer = opts.prefer; + const response = await fetch(PROOF_DB_URL + path, { + method: opts.method || "GET", + headers, + body: opts.body === undefined ? undefined : JSON.stringify(opts.body), + }); + const text = await response.text(); + if (!response.ok) { + throw new Error((opts.method || "GET") + " " + path + " -> " + response.status + ": " + text.slice(0, 200)); + } + return text ? JSON.parse(text) : null; + }; + + // recordDemo forwards only page console lines with this prefix. + const log = (label, value) => + console.log("[recordDemo] " + label + " " + JSON.stringify(value)); + + // Roam data. Titles are passed as query inputs, never spliced into Datalog. + const api = () => window.roamAlphaAPI; + const CONFIG_PAGE = "roam/js/discourse-graph"; + const FLAGS_BLOCK = "Feature Flags"; + + // Pulled props come back with ":"-prefixed keys. + const normalizeProps = (raw) => { + const out = {}; + for (const key in raw || {}) out[key.replace(/^:+/, "")] = raw[key]; + return out; + }; + + const uid = (title) => + api().data.q( + "[:find ?u . :in $ ?t :where [?p :node/title ?t] [?p :block/uid ?u]]", + title, + ) || null; + + const createBlocks = async (parentUid, children) => { + const list = children || []; + for (let index = 0; index < list.length; index += 1) { + const child = typeof list[index] === "string" ? { string: list[index] } : list[index]; + const blockUid = child.uid || api().util.generateUID(); + await api().data.block.create({ + location: { "parent-uid": parentUid, order: index }, + block: { string: child.string, uid: blockUid }, + }); + if (child.children) await createBlocks(blockUid, child.children); + } + }; + + const page = { + uid, + exists: (title) => Boolean(uid(title)), + // Creates the page with these child blocks unless it already exists. + ensure: async (title, children) => { + const existing = uid(title); + if (existing) return existing; + const pageUid = api().util.generateUID(); + await api().data.page.create({ page: { title, uid: pageUid } }); + await createBlocks(pageUid, children); + await waitFor(() => uid(title), { what: "the page " + title }); + return pageUid; + }, + remove: async (titles) => { + let removed = 0; + for (const title of [].concat(titles)) { + const pageUid = uid(title); + if (!pageUid) continue; + await api().data.page.delete({ page: { uid: pageUid } }); + removed += 1; + } + return removed; + }, + recreate: async (title, children) => { + await page.remove(title); + return page.ensure(title, children); + }, + // Waits for the main window to show the page, by its uid. Roam draws the + // [[links]] in a title as separate pieces and can hide their brackets, + // so a title like "[[EVD]] - … - [[@Source]]" needn't read as itself on + // screen, and waiting for that text timed out on every such page. + open: async (title) => { + const pageUid = uid(title); + if (!pageUid) throw new Error("No page titled " + JSON.stringify(title) + " in this graph."); + await api().ui.mainWindow.openPage({ page: { uid: pageUid } }); + return waitFor( + async () => { + if ((await api().ui.mainWindow.getOpenPageOrBlockUid()) !== pageUid) return null; + const shown = document.querySelector( + '.roam-article .rm-title-display-container[data-page-uid="' + pageUid + '"] .rm-title-display', + ); + if (shown) return visible(shown) ? shown : null; + // A Roam that doesn't mark the title with its uid: the main window's title, now that it's this page. + return Array.from(document.querySelectorAll(".roam-article .rm-title-display")).find((element) => visible(element)) || null; + }, + { what: "the page " + title }, + ); + }, + }; + + const configChildren = () => { + const result = api().data.pull( + "[{:block/children [:block/uid :block/string :block/props]}]", + [":node/title", CONFIG_PAGE], + ); + return (result && result[":block/children"]) || []; + }; + + const flagsBlock = () => + configChildren().find((child) => child[":block/string"] === FLAGS_BLOCK) || null; + + // Feature flags are props on the Feature Flags block of roam/js/discourse-graph. + // The extension reads them once at load, so set them before it loads. + const flags = { + all: () => normalizeProps((flagsBlock() || {})[":block/props"]), + get: (name) => flags.all()[name] === true, + set: async (name, value) => { + let pageUid = uid(CONFIG_PAGE); + if (!pageUid) { + pageUid = api().util.generateUID(); + await api().data.page.create({ page: { title: CONFIG_PAGE, uid: pageUid } }); + } + const block = flagsBlock(); + let blockUid; + let existing = {}; + if (block) { + blockUid = block[":block/uid"]; + existing = normalizeProps(block[":block/props"]); + } else { + blockUid = api().util.generateUID(); + await api().data.block.create({ + location: { "parent-uid": pageUid, order: "last" }, + block: { string: FLAGS_BLOCK, uid: blockUid }, + }); + } + if (existing[name] === value) return "kept"; + const props = Object.assign({}, existing); + props[name] = value; + await api().data.block.update({ block: { uid: blockUid, props } }); + await waitFor(() => flags.all()[name] === value, { + what: "the flag " + name + " to read back as " + value, + timeout: 5000, + }); + return "changed"; + }, + }; + + const toasts = { + clear: () => { + document + .querySelectorAll(".bp3-toast .bp3-toast-dismiss button, .bp3-toast button[aria-label='Close']") + .forEach((button) => button.click()); + return true; + }, + }; + + const sidebar = { + clearRight: async () => { + for (const item of api().ui.rightSidebar.getWindows()) { + try { + await api().ui.rightSidebar.removeWindow({ + window: { + type: item.type, + "block-uid": item["block-uid"] || item["page-uid"] || item["mentions-uid"], + }, + }); + } catch (error) { + // A window that's already gone is fine. + } + } + return api().ui.rightSidebar.getWindows().length === 0; + }, + }; + + // Strips ":" prefixes at every depth, for props read back from Roam. + const plain = (value) => { + if (Array.isArray(value)) return value.map(plain); + if (!value || typeof value !== "object") return value; + const out = {}; + for (const key in value) out[key.replace(/^:+/, "")] = plain(value[key]); + return out; + }; + + const rect = (element) => { + const box = element.getBoundingClientRect(); + return { left: box.left, top: box.top, right: box.right, bottom: box.bottom, width: box.width, height: box.height }; + }; + + // True when two elements' boxes share any area. + const overlaps = (a, b) => { + const one = rect(a); + const two = rect(b); + return one.left < two.right && two.left < one.right && one.top < two.bottom && two.top < one.bottom; + }; + + // Stored relations are blocks on this page whose "discourse-graph" prop holds + // { sourceUid, destinationUid, hasSchema, tentative? }, as createReifiedBlock + // writes them. Imported ones carry tentative: "true" until accepted. + const RELATIONS_PAGE = "roam/js/discourse-graph/relations"; + + const relationBlocks = () => { + const pageUid = uid(RELATIONS_PAGE); + if (!pageUid) return []; + const result = api().data.pull( + "[{:block/children [:block/uid :block/props]}]", + [":block/uid", pageUid], + ); + return ((result && result[":block/children"]) || []) + .map((child) => { + const props = plain(child[":block/props"] || {}); + return Object.assign({ uid: child[":block/uid"] }, props["discourse-graph"] || {}); + }) + .filter((item) => item.sourceUid && item.destinationUid); + }; + + const configTree = () => { + const result = api().data.pull( + "[:block/string :block/uid :block/props {:block/children ...}]", + [":node/title", CONFIG_PAGE], + ); + return (result && result[":block/children"]) || []; + }; + + const byString = (children, text) => + (children || []).find( + (child) => String(child[":block/string"] || "").trim().toLowerCase() === text.toLowerCase(), + ) || null; + + const relations = { + page: RELATIONS_PAGE, + all: relationBlocks, + touching: (uids) => { + const set = new Set([].concat(uids)); + return relationBlocks().filter((item) => set.has(item.sourceUid) || set.has(item.destinationUid)); + }, + // The id of the relation type with this label, from the legacy grammar + // tree or, with the new settings store, the Global block's Relations prop. + schemaId: (label) => { + const tree = configTree(); + const grammar = byString(tree, "grammar"); + const relationsNode = grammar && byString(grammar[":block/children"], "relations"); + const legacy = relationsNode && byString(relationsNode[":block/children"], label); + if (legacy) return legacy[":block/uid"]; + for (const child of tree) { + const props = plain(child[":block/props"] || {}); + const all = props.Relations || {}; + for (const id in all) { + if (all[id] && all[id].label === label) return id; + } + } + return null; + }, + removeTouching: async (uids) => { + const found = relations.touching(uids); + for (const item of found) { + await api().data.block.delete({ block: { uid: item.uid } }); + } + return found.length; + }, + // Writes the block an import writes for a relation still pending review. + createTentative: async ({ sourceUid, destinationUid, schemaUid }) => { + let pageUid = uid(RELATIONS_PAGE); + if (!pageUid) { + pageUid = api().util.generateUID(); + await api().data.page.create({ page: { title: RELATIONS_PAGE, uid: pageUid } }); + } + const blockUid = api().util.generateUID(); + await api().data.block.create({ + location: { "parent-uid": pageUid, order: "last" }, + block: { string: blockUid, uid: blockUid }, + }); + await api().data.block.update({ + block: { + uid: blockUid, + props: { "discourse-graph": { sourceUid, destinationUid, hasSchema: schemaUid, tentative: "true" } }, + }, + }); + await waitFor(() => relationBlocks().some((item) => item.uid === blockUid), { + what: "the pending relation block to read back", + }); + return blockUid; + }, + }; + + // A node type is a discourse-graph/nodes/