diff --git a/skills/dg-obsidian-cdp-verify/SKILL.md b/skills/dg-obsidian-cdp-verify/SKILL.md new file mode 100644 index 000000000..dfc2cefb2 --- /dev/null +++ b/skills/dg-obsidian-cdp-verify/SKILL.md @@ -0,0 +1,110 @@ +--- +name: dg-obsidian-cdp-verify +description: Verify Obsidian plugin changes by driving the running app over the Chrome DevTools Protocol and asserting on real behaviour. Use when a change needs proving in the real app rather than by unit test — apps/obsidian has no test runner — or when a reviewer asks "does this actually work?". +--- + +# DG Obsidian CDP Verify + +Use this skill to prove an `apps/obsidian` change works in a real Obsidian vault. + +`apps/obsidian` has no test runner (roam, website, database and content-model do). +The way to prove a change works is to drive the real app: Obsidian is Electron, so +it speaks the Chrome DevTools Protocol. About 150 lines covers `evaluate`, input +injection and condition polling — Playwright is not required. + +## Prerequisites + +1. **Relaunch Obsidian with the debug port.** Run the steps separately — the + auto-mode classifier blocks quit-and-relaunch as one compound command. + ```bash + osascript -e 'tell application "Obsidian" to quit' + ``` + ```bash + open -na /Applications/Obsidian.app --args --remote-debugging-port=9222 + ``` + Ask the user before doing this: it closes their running app. +2. **Your build in the vault.** `apps/obsidian/.env` mirrors the dev build into + the vault plugin dir. Confirm it is _your_ build — see the first gotcha. + +## Quick Start + +```sh +# 1. preflight: port up, right vault, YOUR bundle, plugin enabled +node skills/dg-obsidian-cdp-verify/scripts/preflight.mjs "a string unique to your change" + +# 2. run a verification +node skills/dg-obsidian-cdp-verify/examples/insert-link-at-cursor.mjs +``` + +Set `VAULT` to target a vault other than `testVault`. + +Write the verification as scenarios and hand them to `runVerification`, which owns +everything order-dependent (plugin reload, stray-modal cleanup, teardown, exit +code): + +```js +import { runVerification } from "./scripts/harness.mjs"; + +await runVerification({ + modalSelector: ".dg-node-search-modal", + setup: async ({ client }) => ({ + /* snapshot anything you will mutate */ + }), + teardown: async ({ client, state }) => { + /* put it back */ + }, + scenarios: [ + { + name: "01-does-the-thing", + body: async ({ client, check, state }) => { + await client.evaluate(`return app.commands.executeCommandById("…");`); + await client.waitFor(`!!document.querySelector(".my-modal")`, { + label: "modal", + }); + check( + "the thing happened", + await client.evaluate(`…`), + "detail on failure", + ); + }, + }, + ], +}); +``` + +`client` gives you `evaluate`, `waitFor`, `key`, `typeText`, `reloadPlugin` and +`pressEscape`. See `examples/insert-link-at-cursor.mjs` — the real +verification that shipped ENG-2114 (15 assertions, 3 scenarios). Copy it as the +starting point for a new one. + +## Gotchas + +Each of these cost real debugging time. The second and third produced confident, +wrong diagnoses that survived until they were deliberately tested. + +| Symptom | Cause | +| ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| "My feature is missing from the build" | **Every worktree's dev watcher mirrors into the same vault plugin dir.** Another branch's watcher silently overwrote your bundle. This is what `preflight.mjs` checks. Find competing watchers with `pgrep -fl "scripts/dev.ts"`; rebuild with `(cd apps/obsidian && pnpm build)`. | +| App appears to double-handle one keypress | Your key helper spread `{ type, ...opts }`, so a caller's `type: "keyDown"` overrode the loop and sent two keydowns. `type` must come **after** the spread. | +| `editor.hasFocus()` is false but focus looks right | CodeMirror ANDs with `document.hasFocus()`, so it reports false whenever Obsidian is not the frontmost macOS app — running a build in the terminal flips it. Assert `document.activeElement.closest(".cm-editor")` instead. | +| File content assertion fails, editor looks correct | Obsidian saves on a debounce. Poll the file until it changes; never read straight after the action. | +| Assertions read a mix of two modals | A crashed earlier run left one mounted. `runVerification` clears strays first; a synthetic `body.click()` will not dismiss a modal, an Escape key event will. | +| `getLeaf(true)` throws "No tab group found" | You detached every markdown leaf first. Use `getLeaf("tab")`, which reuses an empty active leaf. | +| Editing only files under `src/styles/` never rebuilds | The concatenation runs in esbuild's `onEnd`, outside its module graph. Touch a `.ts` file. | +| Top-level `await` throws inside `evaluate` | Bodies are wrapped in a plain function. Return a promise chain instead. | +| Wrong window driven | Several page targets exist — one per open vault, plus popouts and settings. Select by `app.vault.getName()`, never by title. | + +Two more, from experience rather than symptoms: + +- **Reset state at the start of a scenario**, or assertions inherit leftover tabs + and splits from the last run and a correct implementation reads as a failure. +- **The developer is using the app while you drive it.** A human opening a tab + mid-run produces failures that look like code bugs. Re-run before believing a + causal story built on one observation. + +## Safety Notes + +- Ask before relaunching Obsidian: it closes the app the developer is using. +- Verification runs mutate the vault — scratch notes, sometimes app settings. + Snapshot anything you change in `setup` and restore it in `teardown`. +- Point runs at a dev vault (`testVault`), never a real one. diff --git a/skills/dg-obsidian-cdp-verify/agents/openai.yaml b/skills/dg-obsidian-cdp-verify/agents/openai.yaml new file mode 100644 index 000000000..09fb27aa1 --- /dev/null +++ b/skills/dg-obsidian-cdp-verify/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "DG Obsidian CDP Verify" + short_description: "Verify apps/obsidian changes in the running app" + default_prompt: "Use $dg-obsidian-cdp-verify to verify my apps/obsidian change against the running app." diff --git a/skills/dg-obsidian-cdp-verify/examples/insert-link-at-cursor.mjs b/skills/dg-obsidian-cdp-verify/examples/insert-link-at-cursor.mjs new file mode 100644 index 000000000..d7163784c --- /dev/null +++ b/skills/dg-obsidian-cdp-verify/examples/insert-link-at-cursor.mjs @@ -0,0 +1,237 @@ +// Worked example — the verification that shipped ENG-2114 ("insert active search +// result as a link at cursor"). 15 assertions across 3 scenarios. Read this +// before writing your own. +// +// node examples/insert-link-at-cursor.mjs +import { runVerification } from "../scripts/harness.mjs"; + +const SCRATCH = "__verify-scratch.md"; +const MODAL = ".dg-node-search-modal"; +const json = (v) => JSON.stringify(v); + +const setLinkConfig = (client, { useMarkdownLinks, newLinkFormat }) => + client.evaluate(` + app.vault.setConfig("useMarkdownLinks", ${json(useMarkdownLinks)}); + app.vault.setConfig("newLinkFormat", ${json(newLinkFormat)}); + return true; + `); + +// `getLeaf("tab")` reuses an empty active leaf, so this does not pile up tabs. +// Detaching every markdown leaf first would leave no tab group to open into. +const openScratchAt = async (client, body, line, ch) => { + await client.evaluate(` + return (async () => { + const existing = app.vault.getAbstractFileByPath(${json(SCRATCH)}); + if (existing) await app.vault.modify(existing, ${json(body)}); + else await app.vault.create(${json(SCRATCH)}, ${json(body)}); + const file = app.vault.getAbstractFileByPath(${json(SCRATCH)}); + const leaf = app.workspace.getLeaf("tab"); + await leaf.openFile(file, { state: { mode: "source" } }); + app.workspace.setActiveLeaf(leaf, { focus: true }); + return true; + })(); + `); + await client.evaluate(` + const view = app.workspace.activeLeaf.view; + view.editor.focus(); + view.editor.setCursor({ line: ${line}, ch: ${ch} }); + return true; + `); +}; + +const openSearch = async (client) => { + await client.evaluate( + `return app.commands.executeCommandById("@discourse-graph/obsidian:open-node-search");`, + ); + await client.waitFor(`!!document.querySelector(${json(MODAL)})`, { + label: "search modal open", + }); +}; + +const footerLabels = (client) => + client.evaluate(` + return Array.from( + document.querySelectorAll(${json(`${MODAL} .dg-search-footer-action`)}), + ).map((el) => el.textContent.trim()); + `); + +const typeQuery = async (client, text) => { + await client.evaluate(` + document.querySelector(${json(`${MODAL} input`)}).focus(); + return true; + `); + await client.typeText(text); + // Beat the 250ms search debounce. + await client.waitFor( + `document.querySelectorAll(${json(`${MODAL} [role="option"]`)}).length > 0`, + { label: "results rendered", timeout: 4000 }, + ); +}; + +const pressModEnter = (client) => + client.key({ + key: "Enter", + code: "Enter", + windowsVirtualKeyCode: 13, + nativeVirtualKeyCode: 13, + modifiers: 4, // Meta + }); + +const readScratch = (client) => + client.evaluate( + `return app.vault.read(app.vault.getAbstractFileByPath(${json(SCRATCH)}));`, + ); + +// The editor change reaches disk on Obsidian's debounced save, so content +// assertions poll rather than reading straight after the modal closes. +const waitForScratchChange = (client, original, label) => + client.waitFor( + `app.vault.read(app.vault.getAbstractFileByPath(${json(SCRATCH)})).then((t) => t !== ${json(original)})`, + { label }, + ); + +/** + * `editor.hasFocus()` also requires `document.hasFocus()`, so it reads false + * whenever the Obsidian window is not the frontmost macOS app — which says + * nothing about the code. Whether `document.activeElement` sits inside the + * editor is the signal that survives an unfocused window. + */ +const editorState = (client) => + client.evaluate(` + const editor = app.workspace.activeLeaf.view.editor; + const el = document.activeElement; + return { + focusInEditor: !!(el && el.closest(".cm-editor")), + documentHasFocus: document.hasFocus(), + cursor: editor.getCursor(), + path: app.workspace.activeLeaf.view.file.path, + }; + `); + +const targetNodeTitle = (client) => + client.evaluate(` + const files = app.vault.getMarkdownFiles().filter((f) => f.path !== ${json(SCRATCH)}); + return files[0].basename; + `); + +const insertScenario = + ({ label, useMarkdownLinks, newLinkFormat }) => + async ({ client, check, state }) => { + await setLinkConfig(client, { useMarkdownLinks, newLinkFormat }); + const original = "before| after\n"; + await openScratchAt(client, original, 0, 6); + await openSearch(client); + + const labels = await footerLabels(client); + check( + `${label}: insert action present in footer`, + labels.some((l) => l.includes("insert link at cursor")), + labels.join(" / "), + ); + + await typeQuery(client, state.nodeTitle.slice(0, 4)); + await pressModEnter(client); + + await client.waitFor(`!document.querySelector(${json(MODAL)})`, { + label: `${label}: modal closed`, + }); + check(`${label}: modal closed after insert`, true); + + await waitForScratchChange(client, original, `${label}: note written`); + const text = await readScratch(client); + const inserted = text + .replace("before", "") + .replace(" after\n", "") + .replace("|", ""); + check( + `${label}: link landed at pre-open cursor (col 6)`, + text.startsWith("before") && + text.endsWith(" after\n") && + text !== original, + json(text), + ); + check( + `${label}: format is ${useMarkdownLinks ? "markdown" : "wikilink"}`, + useMarkdownLinks + ? /^\[[^\]]*\]\([^)]*\)$/.test(inserted.trim()) + : /^\[\[.*\]\]$/.test(inserted.trim()), + inserted.trim(), + ); + + const focus = await editorState(client); + check( + `${label}: focus returned to the editor`, + focus.focusInEditor === true, + json(focus), + ); + check( + `${label}: cursor sits after the inserted link`, + focus.cursor.line === 0 && focus.cursor.ch > 6, + `ch=${focus.cursor.ch}`, + ); + check( + `${label}: inserted into the pre-open note`, + focus.path === SCRATCH, + focus.path, + ); + }; + +await runVerification({ + modalSelector: MODAL, + // These scenarios flip the vault's own link settings, so the originals are + // captured up front and put back in teardown — never leave a teammate's vault + // reconfigured by a verification run. + setup: async ({ client }) => ({ + nodeTitle: await targetNodeTitle(client), + linkConfig: await client.evaluate(`return { + useMarkdownLinks: app.vault.getConfig("useMarkdownLinks"), + newLinkFormat: app.vault.getConfig("newLinkFormat"), + };`), + }), + teardown: async ({ client, state }) => { + await setLinkConfig(client, state.linkConfig); + await client.evaluate(` + const scratch = app.vault.getAbstractFileByPath(${json(SCRATCH)}); + return (scratch ? app.vault.trash(scratch, true) : Promise.resolve()).then(() => true); + `); + console.log("restored vault link config, trashed scratch note"); + }, + scenarios: [ + { + name: "01-insert-wikilink-shortest", + body: insertScenario({ + label: "wikilink/shortest", + useMarkdownLinks: false, + newLinkFormat: "shortest", + }), + }, + { + name: "02-insert-markdown-absolute", + body: insertScenario({ + label: "markdown/absolute", + useMarkdownLinks: true, + newLinkFormat: "absolute", + }), + }, + { + name: "03-absent-without-cursor", + body: async ({ client, check }) => { + await client.evaluate(` + app.workspace.detachLeavesOfType("markdown"); + return true; + `); + await openSearch(client); + const labels = await footerLabels(client); + check( + "insert action absent with no note open", + !labels.some((l) => l.includes("insert link at cursor")), + labels.join(" / "), + ); + await client.pressEscape(); + await client.waitFor(`!document.querySelector(${json(MODAL)})`, { + label: "modal closed", + }); + }, + }, + ], +}); diff --git a/skills/dg-obsidian-cdp-verify/scripts/driver.mjs b/skills/dg-obsidian-cdp-verify/scripts/driver.mjs new file mode 100644 index 000000000..b6b38c3bd --- /dev/null +++ b/skills/dg-obsidian-cdp-verify/scripts/driver.mjs @@ -0,0 +1,159 @@ +// Minimal Chrome DevTools Protocol driver for a running Obsidian. +// +// Obsidian is Electron, so it exposes CDP when launched with +// --remote-debugging-port. ~150 lines covers evaluate, input injection and +// polling; Playwright is not required. +import { resolveWebSocket } from "./websocket.mjs"; + +const PORT = Number(process.env.CDP_PORT ?? 9222); +export const PLUGIN_ID = process.env.PLUGIN_ID ?? "@discourse-graph/obsidian"; +const POLL_MS = 25; + +const listTargets = async () => { + const res = await fetch(`http://127.0.0.1:${PORT}/json/list`); + if (!res.ok) throw new Error(`CDP list failed: ${res.status}`); + return res.json(); +}; + +/** + * Picks the page target by vault name, not by title: several page targets exist + * (one per open vault, plus popouts and settings windows) and titles collide. + */ +export const connect = async ({ + vault = process.env.VAULT ?? "testVault", +} = {}) => { + const targets = (await listTargets()).filter( + (t) => t.type === "page" && !t.url.startsWith("devtools://"), + ); + if (!targets.length) { + throw new Error( + `No CDP page targets on port ${PORT}. Is Obsidian running with ` + + "--remote-debugging-port? See SKILL.md, step 1.", + ); + } + + for (const target of targets) { + const client = await open(target.webSocketDebuggerUrl); + const isVault = await client.evaluate( + `!!document.querySelector(".workspace") && app.vault.getName() === ${JSON.stringify(vault)}`, + ); + if (isVault === true) return client; + client.close(); + } + throw new Error( + `No page target for vault "${vault}". Open that vault, or set VAULT=.`, + ); +}; + +const open = async (url) => { + const WebSocket = await resolveWebSocket(); + return new Promise((resolve, reject) => { + const ws = new WebSocket(url); + let id = 0; + const pending = new Map(); + const listeners = new Map(); + + ws.on("message", (raw) => { + const msg = JSON.parse(raw.toString()); + if (msg.method) { + listeners.get(msg.method)?.(msg.params); + return; + } + const entry = pending.get(msg.id); + if (!entry) return; + pending.delete(msg.id); + if (msg.error) entry.reject(new Error(JSON.stringify(msg.error))); + else entry.resolve(msg.result); + }); + ws.on("error", reject); + + ws.on("open", () => { + const send = (method, params = {}) => + new Promise((res, rej) => { + const nextId = ++id; + pending.set(nextId, { resolve: res, reject: rej }); + ws.send(JSON.stringify({ id: nextId, method, params })); + }); + + /** + * Bodies are wrapped in a plain function, so top-level `await` throws. + * Return a promise chain instead; `awaitPromise` resolves it. + */ + const evaluate = async (expression) => { + const wrapped = expression.includes("return") + ? expression + : `return (${expression});`; + const result = await send("Runtime.evaluate", { + expression: `(() => { ${wrapped} })()`, + awaitPromise: true, + returnByValue: true, + }); + if (result.exceptionDetails) { + const detail = + result.exceptionDetails.exception?.description ?? + result.exceptionDetails.text; + throw new Error(`evaluate failed: ${detail}\n${expression}`); + } + return result.result.value; + }; + + /** Always poll for a condition. Fixed sleeps make runs slower AND flakier. */ + const waitFor = async (expression, { timeout = 5000, label } = {}) => { + const deadline = Date.now() + timeout; + for (;;) { + if ((await evaluate(expression)) === true) return; + if (Date.now() > deadline) + throw new Error(`timeout waiting for ${label ?? expression}`); + await new Promise((r) => setTimeout(r, POLL_MS)); + } + }; + + /** + * `type` is applied after the caller's options on purpose: a caller + * passing `type: "keyDown"` would otherwise override the loop and send two + * keydowns, which looks exactly like the app double-handling one keypress. + */ + const key = async (opts = {}) => { + for (const type of ["keyDown", "keyUp"]) { + await send("Input.dispatchKeyEvent", { ...opts, type }); + } + }; + + const typeText = async (text, { delayMs = 0 } = {}) => { + for (const char of text) { + await key({ text: char, key: char }); + if (delayMs) await new Promise((r) => setTimeout(r, delayMs)); + } + }; + + /** Reload so a rebuilt bundle is actually the code under test. */ + const reloadPlugin = () => + evaluate(` + return app.plugins + .disablePlugin(${JSON.stringify(PLUGIN_ID)}) + .then(() => app.plugins.enablePlugin(${JSON.stringify(PLUGIN_ID)})) + .then(() => true); + `); + + const pressEscape = () => + key({ + key: "Escape", + code: "Escape", + windowsVirtualKeyCode: 27, + nativeVirtualKeyCode: 27, + }); + + resolve({ + send, + on: (method, handler) => listeners.set(method, handler), + evaluate, + waitFor, + key, + typeText, + reloadPlugin, + pressEscape, + close: () => ws.close(), + }); + }); + }); +}; diff --git a/skills/dg-obsidian-cdp-verify/scripts/harness.mjs b/skills/dg-obsidian-cdp-verify/scripts/harness.mjs new file mode 100644 index 000000000..4f9b984ef --- /dev/null +++ b/skills/dg-obsidian-cdp-verify/scripts/harness.mjs @@ -0,0 +1,84 @@ +// Scenario runner: assertions, baseline reset, exit code. +// +// A verification file declares scenarios and calls `runVerification`. Everything +// order-dependent — plugin reload, stray-modal cleanup, teardown — happens here +// so each scenario only describes its own behaviour. +import { connect } from "./driver.mjs"; + +export const createChecker = () => { + const results = []; + const check = (label, pass, detail = "") => { + results.push({ label, pass, detail }); + console.log( + `${pass ? "PASS" : "FAIL"} ${label}${detail ? ` — ${detail}` : ""}`, + ); + }; + return { check, results }; +}; + +/** + * A crashed earlier run can leave a modal mounted, and a second copy in the DOM + * makes every `querySelectorAll` return both — assertions then read a mix of two + * modals. Clear them before asserting anything. + */ +export const clearStrayModals = async ( + client, + { selector = ".modal" } = {}, +) => { + const gone = `!document.querySelector(${JSON.stringify(selector)})`; + for (let attempt = 0; attempt < 5; attempt++) { + if ((await client.evaluate(gone)) === true) return; + await client.pressEscape(); + await new Promise((r) => setTimeout(r, 150)); + } + await client.waitFor(gone, { label: "no stray modal at start" }); +}; + +/** + * @param scenarios [{ name, body }] — body receives ({ client, check, state }). + * @param setup optional: runs once before scenarios, may return state. + * @param teardown optional: receives the value `setup` returned. + * @param modalSelector what counts as a stray modal to clear at start. + */ +export const runVerification = async ({ + scenarios, + setup, + teardown, + modalSelector, + vault, +}) => { + const client = await connect(vault ? { vault } : {}); + console.log("connected to vault target"); + + // Reload so a rebuilt bundle is the code actually under test. + await client.reloadPlugin(); + await client.waitFor( + `!!app.plugins.plugins[${JSON.stringify(process.env.PLUGIN_ID ?? "@discourse-graph/obsidian")}]`, + { + label: "plugin re-enabled", + }, + ); + await clearStrayModals( + client, + modalSelector ? { selector: modalSelector } : {}, + ); + + const { check, results } = createChecker(); + const state = setup ? await setup({ client, check }) : undefined; + + try { + for (const scenario of scenarios) { + console.log(`\n— ${scenario.name}`); + await scenario.body({ client, check, state }); + } + } finally { + if (teardown) await teardown({ client, state }); + } + + const failed = results.filter((r) => !r.pass); + console.log( + `\n${results.length - failed.length}/${results.length} assertions passed`, + ); + client.close(); + process.exit(failed.length ? 1 : 0); +}; diff --git a/skills/dg-obsidian-cdp-verify/scripts/preflight.mjs b/skills/dg-obsidian-cdp-verify/scripts/preflight.mjs new file mode 100644 index 000000000..d9cf18880 --- /dev/null +++ b/skills/dg-obsidian-cdp-verify/scripts/preflight.mjs @@ -0,0 +1,68 @@ +#!/usr/bin/env node +// Checks the three things that silently invalidate a verification run. +// +// node preflight.mjs "some string unique to your change" +import { readFileSync, existsSync } from "node:fs"; +import { connect, PLUGIN_ID } from "./driver.mjs"; + +const marker = process.argv[2]; +const vault = process.env.VAULT ?? "testVault"; +const pluginDir = + process.env.PLUGIN_DIR ?? + `${process.env.HOME}/Documents/${vault}/.obsidian/plugins/discourse-graphs`; + +const report = []; +const note = (ok, text) => { + report.push(ok); + console.log(`${ok ? "ok " : "FAIL"} ${text}`); +}; + +// 1. Is the debug port up at all? +let client; +try { + client = await connect({ vault }); + note(true, `CDP reachable, attached to vault "${vault}"`); +} catch (error) { + note(false, error.message); + console.log( + "\nRelaunch Obsidian with the port:\n" + + " osascript -e 'tell application \"Obsidian\" to quit'\n" + + " open -na /Applications/Obsidian.app --args --remote-debugging-port=9222", + ); + process.exit(1); +} + +// 2. Is the built bundle in the vault actually YOUR build? Every worktree's dev +// watcher mirrors to the same vault, so another branch's watcher can silently +// replace it — the failure looks like "my feature is missing". +const bundle = `${pluginDir}/main.js`; +if (!existsSync(bundle)) { + note(false, `no bundle at ${bundle}`); +} else if (!marker) { + note( + true, + `bundle present (pass a marker string to verify it is your build)`, + ); +} else { + const hit = readFileSync(bundle, "utf8").includes(marker); + note( + hit, + `bundle ${hit ? "contains" : "does NOT contain"} ${JSON.stringify(marker)}`, + ); + if (!hit) { + console.log( + "\nAnother worktree's watcher probably overwrote it. Rebuild from yours:\n" + + " (cd apps/obsidian && pnpm build)\n" + + 'Check for competing watchers with: pgrep -fl "scripts/dev.ts"', + ); + } +} + +// 3. Is the plugin actually enabled? +const enabled = await client.evaluate( + `!!app.plugins.plugins[${JSON.stringify(PLUGIN_ID)}]`, +); +note(enabled === true, `plugin ${PLUGIN_ID} enabled`); + +client.close(); +process.exit(report.every(Boolean) ? 0 : 1); diff --git a/skills/dg-obsidian-cdp-verify/scripts/websocket.mjs b/skills/dg-obsidian-cdp-verify/scripts/websocket.mjs new file mode 100644 index 000000000..d211f31af --- /dev/null +++ b/skills/dg-obsidian-cdp-verify/scripts/websocket.mjs @@ -0,0 +1,57 @@ +// Resolving a WebSocket implementation without adding a dependency. +// +// Node 22+ ships a global WebSocket. On older Node (this repo runs 20.x) we fall +// back to `ws`, which is present in the pnpm store as a transitive dependency. +// It is resolved rather than imported by path so the scripts work from any +// worktree and on any teammate's machine. +import { createRequire } from "node:module"; +import { existsSync, readdirSync } from "node:fs"; +import path from "node:path"; + +const fromPnpmStore = (startDir) => { + let dir = path.resolve(startDir); + for (;;) { + const store = path.join(dir, "node_modules", ".pnpm"); + if (existsSync(store)) { + const match = readdirSync(store) + .filter((entry) => entry.startsWith("ws@")) + .sort() + .at(-1); + if (match) { + const candidate = path.join( + store, + match, + "node_modules", + "ws", + "index.js", + ); + if (existsSync(candidate)) return candidate; + } + } + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } +}; + +export const resolveWebSocket = async ({ from = process.cwd() } = {}) => { + if (typeof globalThis.WebSocket === "function") return globalThis.WebSocket; + + const require = createRequire(path.join(from, "noop.js")); + try { + return require("ws"); + } catch { + // Not hoisted into a reachable node_modules — look in the pnpm store. + } + + const storePath = fromPnpmStore(from); + if (storePath) { + const loaded = await import(`file://${storePath}`); + return loaded.default ?? loaded.WebSocket; + } + + throw new Error( + "No WebSocket implementation found. Run this from inside the monorepo " + + "after `pnpm install`, or use Node 22+ which has a global WebSocket.", + ); +};