From 7345ab41a869b93b8a152a33ea35ee6c6008ec04 Mon Sep 17 00:00:00 2001 From: sid597 Date: Sat, 3 Oct 2026 02:01:53 +0530 Subject: [PATCH 01/39] Add the proof-runner prototype: run a pull request's proof kit from a Roam page Open a page that holds a proof kit and the runner loads that PR's CI build of the Discourse Graph extension, opens the kit pinned in the right sidebar, and plays its cases when you press Run. Kits that change the graph before load ask first. A finished run writes its verdicts, build and commit to the page. With URL loading the build gets this extension's extensionAPI (settings under a dg: prefix); from roam/js it gets a browser-storage stand-in. --- pnpm-lock.yaml | 19 + prototypes/proof-runner/CHANGELOG.md | 7 + prototypes/proof-runner/README.md | 144 +++ prototypes/proof-runner/package.json | 21 + .../src/baselines/dg-baseline@1.json | 81 ++ prototypes/proof-runner/src/core/action.ts | 68 ++ prototypes/proof-runner/src/core/fixtures.ts | 112 ++ prototypes/proof-runner/src/core/helpers.ts | 691 ++++++++++++ prototypes/proof-runner/src/core/journal.ts | 58 + prototypes/proof-runner/src/core/kit.ts | 683 ++++++++++++ prototypes/proof-runner/src/core/machine.ts | 995 ++++++++++++++++++ prototypes/proof-runner/src/core/publish.ts | 176 ++++ prototypes/proof-runner/src/core/recipes.ts | 177 ++++ prototypes/proof-runner/src/core/template.ts | 65 ++ prototypes/proof-runner/src/index.ts | 42 + .../proof-runner/src/recipes/dialogs.json | 78 ++ .../proof-runner/src/recipes/overlay.json | 26 + .../proof-runner/src/recipes/pages.json | 16 + .../proof-runner/src/recipes/search.json | 61 ++ .../proof-runner/src/recipes/settings.json | 53 + .../proof-runner/src/recipes/sharing.json | 47 + .../proof-runner/src/roam/build-loader.ts | 297 ++++++ prototypes/proof-runner/src/roam/data.ts | 22 + prototypes/proof-runner/src/roam/executor.ts | 248 +++++ prototypes/proof-runner/src/roam/index.ts | 525 +++++++++ prototypes/proof-runner/src/roam/input.ts | 313 ++++++ prototypes/proof-runner/src/roam/page-kit.ts | 481 +++++++++ prototypes/proof-runner/src/roam/panel.ts | 465 ++++++++ prototypes/proof-runner/src/roam/roam.ts | 175 +++ prototypes/proof-runner/src/roam/runner.ts | 543 ++++++++++ prototypes/proof-runner/src/roam/selector.ts | 244 +++++ prototypes/proof-runner/src/styles.css | 3 + prototypes/proof-runner/tailwind.config.cjs | 6 + .../proof-runner/tests/build-loader.test.ts | 71 ++ .../fixtures/kits/eng-2176-obsidian.json | 71 ++ .../tests/fixtures/kits/eng-2176.json | 128 +++ .../tests/fixtures/kits/eng-2194.json | 104 ++ .../tests/fixtures/kits/eng-2195.json | 137 +++ .../tests/fixtures/kits/eng-2267.json | 120 +++ .../tests/fixtures/kits/eng-2296.json | 197 ++++ .../tests/fixtures/kits/eng-2348.json | 151 +++ .../tests/fixtures/kits/eng-2349.json | 167 +++ .../tests/fixtures/kits/eng-2350.json | 263 +++++ .../tests/fixtures/kits/eng-2351.json | 149 +++ .../tests/fixtures/kits/roam-selftest.json | 71 ++ .../tests/fixtures/kits/smoke.json | 35 + .../tests/fixtures/kits/talk-eng-2348.json | 54 + .../proof-runner/tests/page-kit.test.ts | 84 ++ .../proof-runner/tests/selector.test.ts | 41 + prototypes/proof-runner/tests/setup.test.ts | 32 + prototypes/proof-runner/tsconfig.json | 17 + prototypes/proof-runner/vitest.config.ts | 8 + 52 files changed, 8842 insertions(+) create mode 100644 prototypes/proof-runner/CHANGELOG.md create mode 100644 prototypes/proof-runner/README.md create mode 100644 prototypes/proof-runner/package.json create mode 100644 prototypes/proof-runner/src/baselines/dg-baseline@1.json create mode 100644 prototypes/proof-runner/src/core/action.ts create mode 100644 prototypes/proof-runner/src/core/fixtures.ts create mode 100644 prototypes/proof-runner/src/core/helpers.ts create mode 100644 prototypes/proof-runner/src/core/journal.ts create mode 100644 prototypes/proof-runner/src/core/kit.ts create mode 100644 prototypes/proof-runner/src/core/machine.ts create mode 100644 prototypes/proof-runner/src/core/publish.ts create mode 100644 prototypes/proof-runner/src/core/recipes.ts create mode 100644 prototypes/proof-runner/src/core/template.ts create mode 100644 prototypes/proof-runner/src/index.ts create mode 100644 prototypes/proof-runner/src/recipes/dialogs.json create mode 100644 prototypes/proof-runner/src/recipes/overlay.json create mode 100644 prototypes/proof-runner/src/recipes/pages.json create mode 100644 prototypes/proof-runner/src/recipes/search.json create mode 100644 prototypes/proof-runner/src/recipes/settings.json create mode 100644 prototypes/proof-runner/src/recipes/sharing.json create mode 100644 prototypes/proof-runner/src/roam/build-loader.ts create mode 100644 prototypes/proof-runner/src/roam/data.ts create mode 100644 prototypes/proof-runner/src/roam/executor.ts create mode 100644 prototypes/proof-runner/src/roam/index.ts create mode 100644 prototypes/proof-runner/src/roam/input.ts create mode 100644 prototypes/proof-runner/src/roam/page-kit.ts create mode 100644 prototypes/proof-runner/src/roam/panel.ts create mode 100644 prototypes/proof-runner/src/roam/roam.ts create mode 100644 prototypes/proof-runner/src/roam/runner.ts create mode 100644 prototypes/proof-runner/src/roam/selector.ts create mode 100644 prototypes/proof-runner/src/styles.css create mode 100644 prototypes/proof-runner/tailwind.config.cjs create mode 100644 prototypes/proof-runner/tests/build-loader.test.ts create mode 100644 prototypes/proof-runner/tests/fixtures/kits/eng-2176-obsidian.json create mode 100644 prototypes/proof-runner/tests/fixtures/kits/eng-2176.json create mode 100644 prototypes/proof-runner/tests/fixtures/kits/eng-2194.json create mode 100644 prototypes/proof-runner/tests/fixtures/kits/eng-2195.json create mode 100644 prototypes/proof-runner/tests/fixtures/kits/eng-2267.json create mode 100644 prototypes/proof-runner/tests/fixtures/kits/eng-2296.json create mode 100644 prototypes/proof-runner/tests/fixtures/kits/eng-2348.json create mode 100644 prototypes/proof-runner/tests/fixtures/kits/eng-2349.json create mode 100644 prototypes/proof-runner/tests/fixtures/kits/eng-2350.json create mode 100644 prototypes/proof-runner/tests/fixtures/kits/eng-2351.json create mode 100644 prototypes/proof-runner/tests/fixtures/kits/roam-selftest.json create mode 100644 prototypes/proof-runner/tests/fixtures/kits/smoke.json create mode 100644 prototypes/proof-runner/tests/fixtures/kits/talk-eng-2348.json create mode 100644 prototypes/proof-runner/tests/page-kit.test.ts create mode 100644 prototypes/proof-runner/tests/selector.test.ts create mode 100644 prototypes/proof-runner/tests/setup.test.ts create mode 100644 prototypes/proof-runner/tsconfig.json create mode 100644 prototypes/proof-runner/vitest.config.ts 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..3ec2183 --- /dev/null +++ b/prototypes/proof-runner/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +## 0.1.0 - 2026-10-03 + +- 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`. diff --git a/prototypes/proof-runner/README.md b/prototypes/proof-runner/README.md new file mode 100644 index 0000000..2f2368a --- /dev/null +++ b/prototypes/proof-runner/README.md @@ -0,0 +1,144 @@ +# 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, opens the kit pinned in the right sidebar, and plays the +cases when you press **Run**. + +## 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. + +## 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. 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 panel lists what will change and waits for + **Set up and load**; otherwise it loads straight away. +3. 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. +4. **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. 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 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. Builds talk to + the hosted Discourse Graph backend. + +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/ +``` + +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/package.json b/prototypes/proof-runner/package.json new file mode 100644 index 0000000..a1d3353 --- /dev/null +++ b/prototypes/proof-runner/package.json @@ -0,0 +1,21 @@ +{ + "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" + }, + "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/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/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..62e08e9 --- /dev/null +++ b/prototypes/proof-runner/src/core/helpers.ts @@ -0,0 +1,691 @@ +// 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. +export const HELPERS_INIT_SCRIPT = String.raw`(() => { + if (window.proof) return; + + 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"; + }; + + const closeDialogs = async () => { + for (let attempt = 0; attempt < 6 && dialogs().length; attempt += 1) { + const close = document.querySelector( + ".bp3-dialog button[aria-label='Close'], .rm-settings-close-button", + ); + if (close) close.click(); + else document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + await sleep(300); + } + return dialogs().length === 0; + }; + + // The local 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("http://127.0.0.1:54321" + 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); + }, + open: async (title) => { + await api().ui.mainWindow.openPage({ page: { title } }); + return waitFor( + () => + Array.from(document.querySelectorAll(".rm-title-display")).find( + (element) => visible(element) && textOf(element) === title, + ), + { 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/