From 6eec941f638c9feebef410d23a1d30d61d5070bc Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Fri, 21 Aug 2026 13:09:18 -0400 Subject: [PATCH] Add dg-obsidian-cdp-verify skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apps/obsidian has no test runner, so changes there are verified by driving the running app over the Chrome DevTools Protocol. This packages the driver and the scenario harness used to verify ENG-2114, plus that verification as a worked example. Included gotchas are the ones that produced wrong diagnoses in practice — most notably that every worktree's dev watcher mirrors into the same vault plugin dir, so another branch's build can silently replace the code under test. Co-Authored-By: Claude Opus 5 --- skills/dg-obsidian-cdp-verify/SKILL.md | 110 ++++++++ .../dg-obsidian-cdp-verify/agents/openai.yaml | 4 + .../examples/insert-link-at-cursor.mjs | 237 ++++++++++++++++++ .../dg-obsidian-cdp-verify/scripts/driver.mjs | 159 ++++++++++++ .../scripts/harness.mjs | 84 +++++++ .../scripts/preflight.mjs | 68 +++++ .../scripts/websocket.mjs | 57 +++++ 7 files changed, 719 insertions(+) create mode 100644 skills/dg-obsidian-cdp-verify/SKILL.md create mode 100644 skills/dg-obsidian-cdp-verify/agents/openai.yaml create mode 100644 skills/dg-obsidian-cdp-verify/examples/insert-link-at-cursor.mjs create mode 100644 skills/dg-obsidian-cdp-verify/scripts/driver.mjs create mode 100644 skills/dg-obsidian-cdp-verify/scripts/harness.mjs create mode 100644 skills/dg-obsidian-cdp-verify/scripts/preflight.mjs create mode 100644 skills/dg-obsidian-cdp-verify/scripts/websocket.mjs 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.", + ); +};