Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions skills/dg-obsidian-cdp-verify/SKILL.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions skills/dg-obsidian-cdp-verify/agents/openai.yaml
Original file line number Diff line number Diff line change
@@ -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."
237 changes: 237 additions & 0 deletions skills/dg-obsidian-cdp-verify/examples/insert-link-at-cursor.mjs
Original file line number Diff line number Diff line change
@@ -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",
});
},
},
],
});
Loading