Skip to content

Commit 1cb7a28

Browse files
committed
curr stage
1 parent 5423f04 commit 1cb7a28

7 files changed

Lines changed: 603 additions & 2822 deletions

File tree

apps/obsidian/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
e2e/test-vault/
2+
e2e/test-results/
3+
e2e/html-report/

apps/obsidian/e2e/NOTES.md

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
# E2E Testing for Obsidian Plugin — Notes
2+
3+
## Approaches Considered
4+
5+
### Option 1: Playwright `electron.launch()`
6+
7+
The standard Playwright approach for Electron apps — point `executablePath` at the binary and let Playwright manage the process lifecycle.
8+
9+
**Pros:**
10+
- First-class Playwright API — `app.evaluate()` runs code in the main process, not just renderer
11+
- Automatic process lifecycle management (launch, close, cleanup)
12+
- Access to Electron-specific APIs (e.g., `app.evaluate(() => process.env)`)
13+
- Well-documented, widely used for Electron testing
14+
15+
**Cons:**
16+
- **Does not work with Obsidian.** Obsidian's executable is a launcher that loads an `.asar` package (`obsidian-1.11.7.asar`) and forks a new Electron process. Playwright connects to the initial process, which exits, causing `kill EPERM` and connection failures.
17+
- No workaround without modifying Obsidian's startup or using a custom Electron shell
18+
19+
**Verdict:** Not viable for Obsidian.
20+
21+
---
22+
23+
### Option 2: CDP via `chromium.connectOverCDP()` (chosen)
24+
25+
Launch Obsidian as a subprocess with `--remote-debugging-port=9222`, then connect via Chrome DevTools Protocol.
26+
27+
**Pros:**
28+
- Works with Obsidian's forked process architecture — the debug port is inherited by the child process
29+
- Full access to renderer via `page.evaluate()` — Obsidian's global `app` object is available
30+
- Keyboard/mouse interaction works normally
31+
- Can take screenshots, traces, and use all Playwright assertions
32+
- Process is managed explicitly — clear control over startup and teardown
33+
34+
**Cons:**
35+
- No main process access (can't call Electron APIs directly, only renderer-side `window`/`app`)
36+
- Must manually manage process lifecycle (spawn, pkill, port polling)
37+
- Fixed debug port (9222) means tests can't run in parallel across multiple Obsidian instances without port management
38+
- Port polling adds ~2-5s startup overhead
39+
- `pkill -f Obsidian` in setup is aggressive — kills ALL Obsidian instances, not just test ones
40+
41+
**Verdict:** Works well for PoC. Sufficient for single-worker CI/local testing.
42+
43+
---
44+
45+
### Option 3: Obsidian's built-in plugin testing (not explored)
46+
47+
Obsidian has no official testing framework. Some community approaches exist (e.g., `obsidian-jest`, hot-reload-based testing), but none are mature or maintained.
48+
49+
**Verdict:** Not a real option today.
50+
51+
---
52+
53+
## What We Learned
54+
55+
### Obsidian internals accessible via `page.evaluate()`
56+
- `app.plugins.plugins["@discourse-graph/obsidian"]` — check plugin loaded
57+
- `app.vault.getMarkdownFiles()` — list files
58+
- `app.vault.read(file)` — read file content
59+
- `app.vault.create(name, content)` — create files
60+
- `app.workspace.openLinkText(path, "", false)` — open a file in the editor
61+
- `app.commands.executeCommandById(id)` — could execute commands directly (alternative to command palette UI)
62+
63+
### Plugin command IDs
64+
Commands are registered with IDs like `@discourse-graph/obsidian:create-discourse-node`. The command palette shows them as "Discourse Graph: Create discourse node".
65+
66+
### Modal DOM structure
67+
The `ModifyNodeModal` renders React inside Obsidian's `.modal-container`:
68+
- Node type: `<select>` element (`.modal-container select`)
69+
- Content: `<input type="text">` (`.modal-container input[type='text']`)
70+
- Confirm: `<button class="mod-cta">`
71+
72+
### Vault configuration
73+
Minimum config for plugin to load:
74+
- `.obsidian/community-plugins.json``["@discourse-graph/obsidian"]`
75+
- `.obsidian/app.json``{"livePreview": true}` (restricted mode must be off, but this is handled by Obsidian detecting the plugins dir)
76+
- Plugin files (`main.js`, `manifest.json`, `styles.css`) in `.obsidian/plugins/@discourse-graph/obsidian/`
77+
78+
---
79+
80+
## Proposal: Full Agentic Testing Flow
81+
82+
### Goal
83+
AI coding agents (Cursor, Claude Code) can run `pnpm test:e2e` after making changes to automatically verify features work end-to-end. The test suite should be comprehensive enough to catch regressions, fast enough to run frequently, and deterministic enough to trust the results.
84+
85+
### Phase 1: Stabilize the PoC (current state + hardening)
86+
87+
**Isolation improvements:**
88+
- Use a unique temp directory per test run (`os.tmpdir()`) instead of a fixed `test-vault/` path to avoid stale state
89+
- Use a random debug port to allow parallel runs
90+
- Replace `pkill -f Obsidian` with tracking the specific child PID — parse it from `lsof -i :<port>` after launch
91+
- Add a global setup/teardown in Playwright config to manage the single Obsidian instance across all tests
92+
93+
**Reliability improvements:**
94+
- Replace `waitForTimeout()` calls with proper waitFor conditions (e.g., `waitForSelector`, `waitForFunction`)
95+
- Add retry logic for CDP connection (currently fails hard on timeout)
96+
- Add a `test.beforeEach` that resets vault state (delete all non-config files) instead of full vault recreation
97+
98+
### Phase 2: Expand test coverage
99+
100+
**Core plugin features to test:**
101+
- Create each discourse node type (Question, Claim, Evidence, Source)
102+
- Verify frontmatter (`nodeTypeId`) is set correctly
103+
- Verify file naming conventions (e.g., `QUE - `, `CLM - `, `EVD - `, `SRC - `)
104+
- Open node type menu via hotkey (`Cmd+\`)
105+
- Discourse context view toggle
106+
- Settings panel opens and renders
107+
108+
**Vault-level tests:**
109+
- Create multiple nodes and verify they appear in file explorer
110+
- Verify node format regex matching (files follow the format pattern)
111+
112+
**Use `app.commands.executeCommandById()` as the primary way to trigger commands** — faster, more reliable, and avoids flaky command palette typing. Reserve command palette tests for testing the palette itself.
113+
114+
### Phase 3: Agentic integration
115+
116+
**For agents to use the tests effectively:**
117+
118+
1. **Fast feedback loop** — Tests should complete in <30s total. Current PoC is ~14s for 2 tests, which is good. Keep Obsidian running between test files using Playwright's `globalSetup`/`globalTeardown`.
119+
120+
2. **Clear error messages** — When a test fails, the agent needs to understand WHY. Add descriptive assertion messages:
121+
```ts
122+
expect(pluginLoaded, "Plugin should be loaded — check dist/ is built and plugin ID matches manifest.json").toBe(true);
123+
```
124+
125+
3. **Screenshot-on-failure for visual debugging** — Already configured. Consider adding `page.screenshot()` at key checkpoints even on success, so agents can visually verify state.
126+
127+
4. **Test file organization** — One test file per feature area:
128+
```
129+
e2e/tests/
130+
├── plugin-load.spec.ts # Plugin loads, settings exist
131+
├── node-creation.spec.ts # Create each node type
132+
├── command-palette.spec.ts # Command palette interactions
133+
├── discourse-context.spec.ts # Context view, relations
134+
└── settings.spec.ts # Settings panel
135+
```
136+
137+
5. **CI integration** — Run in GitHub Actions with a macOS runner. Obsidian would need to be pre-installed on the runner (or downloaded in a setup step). This is the biggest open question — Obsidian doesn't have a headless mode, so CI would need `xvfb` or a virtual display.
138+
139+
6. **Agent-executable test commands:**
140+
```bash
141+
pnpm test:e2e # run all tests
142+
pnpm test:e2e -- --grep "node creation" # run specific tests
143+
pnpm test:e2e:ui # interactive Playwright UI (for humans)
144+
```
145+
146+
### Phase 4: Advanced (future)
147+
148+
- **Visual regression testing** — Compare screenshots against baselines to catch UI regressions
149+
- **Obsidian version matrix** — Test against multiple Obsidian versions (download different `.asar` files)
150+
- **Headless mode wrapper** — Investigate running Obsidian with `--disable-gpu --headless` flags (may not work due to Obsidian's renderer requirements)
151+
- **Test data fixtures** — Pre-built vaults with specific node/relation configurations for testing complex scenarios
152+
- **Performance benchmarks** — Measure plugin load time, command execution time
153+
154+
### Open Questions
155+
156+
1. **CI runner setup** — How to install Obsidian on GitHub Actions macOS runners? Is there a `.dmg` download URL that's stable? Or do we cache the `.app` bundle?
157+
2. **Obsidian updates** — Obsidian auto-updates the `.asar`. Should tests pin a specific version? How to prevent auto-update during test runs?
158+
3. **Multiple vaults** — Obsidian tracks known vaults globally. Test vaults may accumulate in Obsidian's vault list. Need cleanup strategy.
159+
4. **Restricted mode** — The PoC doesn't explicitly disable restricted mode via config. The plugin loads because the `community-plugins.json` file is present, but a fresh Obsidian install might prompt the user to enable community plugins. Need to investigate if there's a config flag to skip this.
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import fs from "fs";
2+
import path from "path";
3+
import { spawn, execSync, type ChildProcess } from "child_process";
4+
import { chromium, type Browser, type Page } from "@playwright/test";
5+
6+
const OBSIDIAN_PATH = "/Applications/Obsidian.app/Contents/MacOS/Obsidian";
7+
const PLUGIN_ID = "@discourse-graph/obsidian";
8+
const DEBUG_PORT = 9222;
9+
10+
export const createTestVault = (vaultPath: string): void => {
11+
// Clean up any existing vault
12+
cleanTestVault(vaultPath);
13+
14+
// Create vault directories
15+
const obsidianDir = path.join(vaultPath, ".obsidian");
16+
const pluginDir = path.join(obsidianDir, "plugins", PLUGIN_ID);
17+
fs.mkdirSync(pluginDir, { recursive: true });
18+
19+
// Write community-plugins.json to enable our plugin
20+
fs.writeFileSync(
21+
path.join(obsidianDir, "community-plugins.json"),
22+
JSON.stringify([PLUGIN_ID]),
23+
);
24+
25+
// Write app.json to disable restricted mode (allows community plugins)
26+
fs.writeFileSync(
27+
path.join(obsidianDir, "app.json"),
28+
JSON.stringify({ livePreview: true }),
29+
);
30+
31+
// Copy built plugin files from dist/ into the vault's plugin directory
32+
const distDir = path.join(__dirname, "..", "..", "dist");
33+
if (!fs.existsSync(distDir)) {
34+
throw new Error(
35+
`dist/ directory not found at ${distDir}. Run "pnpm build" first.`,
36+
);
37+
}
38+
39+
const filesToCopy = ["main.js", "manifest.json", "styles.css"];
40+
for (const file of filesToCopy) {
41+
const src = path.join(distDir, file);
42+
if (fs.existsSync(src)) {
43+
fs.copyFileSync(src, path.join(pluginDir, file));
44+
}
45+
}
46+
};
47+
48+
export const cleanTestVault = (vaultPath: string): void => {
49+
if (fs.existsSync(vaultPath)) {
50+
fs.rmSync(vaultPath, { recursive: true, force: true });
51+
}
52+
};
53+
54+
/**
55+
* Launch Obsidian as a subprocess with remote debugging enabled,
56+
* then connect via Chrome DevTools Protocol.
57+
*
58+
* We can't use Playwright's electron.launch() because Obsidian's
59+
* executable is a launcher that loads an asar package and forks
60+
* a new process, which breaks Playwright's Electron API connection.
61+
*/
62+
export const launchObsidian = async (
63+
vaultPath: string,
64+
): Promise<{
65+
browser: Browser;
66+
page: Page;
67+
obsidianProcess: ChildProcess;
68+
}> => {
69+
// Kill any existing Obsidian instances to free the debug port
70+
try {
71+
execSync("pkill -f Obsidian", { stdio: "ignore" });
72+
// Wait for processes to fully exit
73+
await new Promise((resolve) => setTimeout(resolve, 2_000));
74+
} catch {
75+
// No existing Obsidian processes — that's fine
76+
}
77+
78+
// Launch Obsidian with remote debugging port
79+
const obsidianProcess = spawn(
80+
OBSIDIAN_PATH,
81+
[`--remote-debugging-port=${DEBUG_PORT}`, `--vault=${vaultPath}`],
82+
{
83+
stdio: "pipe",
84+
detached: true,
85+
},
86+
);
87+
88+
obsidianProcess.stderr?.on("data", (data: Buffer) => {
89+
console.log("[Obsidian stderr]", data.toString());
90+
});
91+
obsidianProcess.stdout?.on("data", (data: Buffer) => {
92+
console.log("[Obsidian stdout]", data.toString());
93+
});
94+
95+
// Unref so the spawned process doesn't keep the test runner alive
96+
obsidianProcess.unref();
97+
98+
// Wait for the debug port to be ready
99+
await waitForDebugPort(DEBUG_PORT, 30_000);
100+
101+
// Connect to Obsidian via CDP
102+
const browser = await chromium.connectOverCDP(
103+
`http://localhost:${DEBUG_PORT}`,
104+
);
105+
106+
// Get the first browser context and page
107+
const context = browser.contexts()[0];
108+
if (!context) {
109+
throw new Error("No browser context found after connecting via CDP");
110+
}
111+
112+
let page = context.pages()[0];
113+
if (!page) {
114+
page = await context.waitForEvent("page");
115+
}
116+
117+
// Wait for Obsidian to finish initializing
118+
await page.waitForLoadState("domcontentloaded");
119+
await page.waitForSelector(".workspace", { timeout: 30_000 });
120+
121+
return { browser, page, obsidianProcess };
122+
};
123+
124+
const waitForDebugPort = async (
125+
port: number,
126+
timeoutMs: number,
127+
): Promise<void> => {
128+
const start = Date.now();
129+
while (Date.now() - start < timeoutMs) {
130+
try {
131+
const response = await fetch(`http://localhost:${port}/json/version`);
132+
if (response.ok) return;
133+
} catch {
134+
// Port not ready yet, retry
135+
}
136+
await new Promise((resolve) => setTimeout(resolve, 500));
137+
}
138+
throw new Error(
139+
`Obsidian debug port ${port} did not become ready within ${timeoutMs}ms`,
140+
);
141+
};
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { defineConfig } from "@playwright/test";
2+
3+
export default defineConfig({
4+
testDir: "./tests",
5+
timeout: 60_000,
6+
expect: {
7+
timeout: 10_000,
8+
},
9+
retries: 0,
10+
workers: 1,
11+
reporter: [["list"], ["html", { outputFolder: "html-report" }]],
12+
use: {
13+
screenshot: "only-on-failure",
14+
trace: "retain-on-failure",
15+
},
16+
outputDir: "test-results",
17+
});

0 commit comments

Comments
 (0)