Skip to content

Commit df10175

Browse files
trangdoan982claude
andcommitted
Harden e2e test setup: vault selection, page detection, and review fixes
- Fix vault selection by manipulating obsidian.json instead of unsupported --vault flag - Find correct workspace page via DOM query instead of assuming pages()[0] - Load .env via dotenv for OBSIDIAN_TEST_VAULT configuration - Replace blind waitForTimeout calls with proper selector/function waits - Add ensureVaultWithPlugin for safe setup on existing vaults - Add CDP connection retry logic - Scope eslint-disables to individual page.evaluate blocks - Use crypto.randomBytes for proper hex vault IDs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 06062f5 commit df10175

8 files changed

Lines changed: 276 additions & 83 deletions

File tree

apps/obsidian/e2e/helpers/commands.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ export const executeCommand = async (
88
page: Page,
99
commandId: string,
1010
): Promise<void> => {
11+
/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
1112
await page.evaluate((id) => {
1213
// @ts-expect-error - Obsidian's global `app` is available at runtime
1314
app.commands.executeCommandById(`@discourse-graph/obsidian:${id}`);
1415
}, commandId);
15-
await page.waitForTimeout(500);
16+
/* eslint-enable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
1617
};
1718

1819
/**
@@ -24,20 +25,24 @@ export const executeCommandViaPalette = async (
2425
commandLabel: string,
2526
): Promise<void> => {
2627
await page.keyboard.press("Meta+p");
27-
await page.waitForTimeout(500);
28+
await page.waitForSelector(".prompt-input", { timeout: 10_000 });
2829

2930
await page.keyboard.type(commandLabel, { delay: 30 });
30-
await page.waitForTimeout(500);
31+
await page.waitForSelector(".suggestion-item", { timeout: 10_000 });
3132

3233
await page.keyboard.press("Enter");
33-
await page.waitForTimeout(1_000);
34+
await page.waitForSelector(".prompt-container", {
35+
state: "hidden",
36+
timeout: 10_000,
37+
});
3438
};
3539

3640
/**
3741
* Ensure an active editor exists by creating and opening a scratch file.
3842
* Required before running editorCallback commands like "Create discourse node".
3943
*/
4044
export const ensureActiveEditor = async (page: Page): Promise<void> => {
45+
/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */
4146
await page.evaluate(async () => {
4247
// @ts-expect-error - Obsidian's global `app` is available at runtime
4348
const vault = app.vault;
@@ -46,5 +51,6 @@ export const ensureActiveEditor = async (page: Page): Promise<void> => {
4651
// @ts-expect-error - Obsidian's global `app` is available at runtime
4752
await app.workspace.openLinkText(file.path, "", false);
4853
});
49-
await page.waitForTimeout(1_000);
54+
/* eslint-enable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */
55+
await page.waitForSelector(".cm-editor", { timeout: 10_000 });
5056
};

apps/obsidian/e2e/helpers/obsidian-setup.ts

Lines changed: 175 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,174 @@
1+
import crypto from "crypto";
12
import fs from "fs";
3+
import os from "os";
24
import path from "path";
35
import { spawn, execSync, type ChildProcess } from "child_process";
46
import { chromium, type Browser, type Page } from "@playwright/test";
57

68
const OBSIDIAN_PATH = "/Applications/Obsidian.app/Contents/MacOS/Obsidian";
9+
const OBSIDIAN_CONFIG_PATH = path.join(
10+
os.homedir(),
11+
"Library",
12+
"Application Support",
13+
"obsidian",
14+
"obsidian.json",
15+
);
716
const PLUGIN_ID = "@discourse-graph/obsidian";
817
const DEBUG_PORT = 9222;
918

10-
export const createTestVault = (vaultPath: string): void => {
11-
// Clean up any existing vault
12-
cleanTestVault(vaultPath);
19+
/**
20+
* Returns the vault path to use for tests.
21+
* Reads from OBSIDIAN_TEST_VAULT env var, or falls back to the provided default.
22+
*/
23+
export const resolveVaultPath = (defaultPath: string): string =>
24+
// eslint-disable-next-line turboPlugin/no-undeclared-env-vars -- local dev-only, not a turbo cache key
25+
process.env.OBSIDIAN_TEST_VAULT ?? defaultPath;
1326

14-
// Create vault directories
27+
/**
28+
* Whether we're using a custom vault (skip create/clean).
29+
*/
30+
export const isCustomVault = (): boolean =>
31+
// eslint-disable-next-line turboPlugin/no-undeclared-env-vars -- local dev-only, not a turbo cache key
32+
process.env.OBSIDIAN_TEST_VAULT !== undefined;
33+
34+
/**
35+
* Ensure a vault exists at vaultPath with the plugin installed.
36+
*
37+
* Safe to call on existing vaults — only creates directories and
38+
* copies plugin files without wiping existing vault content.
39+
* For fresh temp vaults, call cleanTestVault first.
40+
*/
41+
export const ensureVaultWithPlugin = (vaultPath: string): void => {
1542
const obsidianDir = path.join(vaultPath, ".obsidian");
1643
const pluginDir = path.join(obsidianDir, "plugins", PLUGIN_ID);
1744
fs.mkdirSync(pluginDir, { recursive: true });
1845

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-
);
46+
// Ensure community-plugins.json includes our plugin
47+
const communityPluginsPath = path.join(obsidianDir, "community-plugins.json");
48+
let enabledPlugins: string[] = [];
49+
if (fs.existsSync(communityPluginsPath)) {
50+
enabledPlugins = JSON.parse(
51+
fs.readFileSync(communityPluginsPath, "utf-8"),
52+
) as string[];
53+
}
54+
if (!enabledPlugins.includes(PLUGIN_ID)) {
55+
enabledPlugins.push(PLUGIN_ID);
56+
fs.writeFileSync(communityPluginsPath, JSON.stringify(enabledPlugins));
57+
}
2458

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-
);
59+
// Ensure app.json exists
60+
const appJsonPath = path.join(obsidianDir, "app.json");
61+
if (!fs.existsSync(appJsonPath)) {
62+
fs.writeFileSync(appJsonPath, JSON.stringify({ livePreview: true }));
63+
}
3064

31-
// Copy built plugin files from dist/ into the vault's plugin directory
65+
// Copy built plugin files from dist/
3266
const distDir = path.join(__dirname, "..", "..", "dist");
3367
if (!fs.existsSync(distDir)) {
3468
throw new Error(
3569
`dist/ directory not found at ${distDir}. Run "pnpm build" first.`,
3670
);
3771
}
3872

39-
const filesToCopy = ["main.js", "manifest.json", "styles.css"];
40-
for (const file of filesToCopy) {
73+
for (const file of ["main.js", "manifest.json", "styles.css"]) {
4174
const src = path.join(distDir, file);
4275
if (fs.existsSync(src)) {
4376
fs.copyFileSync(src, path.join(pluginDir, file));
4477
}
4578
}
4679
};
4780

81+
/**
82+
* Create a fresh temp vault (wipes existing content).
83+
* Only use this for auto-created temp vaults, not custom vaults.
84+
*/
85+
export const createTestVault = (vaultPath: string): void => {
86+
cleanTestVault(vaultPath);
87+
ensureVaultWithPlugin(vaultPath);
88+
};
89+
4890
export const cleanTestVault = (vaultPath: string): void => {
4991
if (fs.existsSync(vaultPath)) {
5092
fs.rmSync(vaultPath, { recursive: true, force: true });
5193
}
5294
};
5395

96+
/**
97+
* Set the target vault as the active vault in Obsidian's config.
98+
*
99+
* Obsidian doesn't support a `--vault` CLI flag. Instead, it opens
100+
* whichever vault has `"open": true` in obsidian.json. We manipulate
101+
* that file before launch and restore it after tests.
102+
*/
103+
const setActiveVault = (vaultPath: string): string | undefined => {
104+
if (!fs.existsSync(OBSIDIAN_CONFIG_PATH)) return undefined;
105+
106+
const original = fs.readFileSync(OBSIDIAN_CONFIG_PATH, "utf-8");
107+
const config = JSON.parse(original) as {
108+
vaults: Record<string, { path: string; ts: number; open?: boolean }>;
109+
};
110+
111+
let targetId: string | undefined;
112+
for (const [id, vault] of Object.entries(config.vaults)) {
113+
delete vault.open;
114+
if (vault.path === vaultPath) {
115+
targetId = id;
116+
}
117+
}
118+
119+
if (!targetId) {
120+
targetId = crypto.randomBytes(8).toString("hex");
121+
config.vaults[targetId] = { path: vaultPath, ts: Date.now() };
122+
}
123+
124+
const targetVault = config.vaults[targetId]!;
125+
targetVault.open = true;
126+
targetVault.ts = Date.now();
127+
128+
fs.writeFileSync(OBSIDIAN_CONFIG_PATH, JSON.stringify(config));
129+
return original;
130+
};
131+
132+
/**
133+
* Restore the original obsidian.json after tests.
134+
*/
135+
export const restoreObsidianConfig = (original: string): void => {
136+
fs.writeFileSync(OBSIDIAN_CONFIG_PATH, original);
137+
};
138+
139+
/**
140+
* Find the vault workspace page among all CDP pages.
141+
*
142+
* Obsidian opens multiple windows (dev console, main workspace).
143+
* We need the one that has the `.workspace` element.
144+
*/
145+
const findWorkspacePage = async (
146+
browser: Browser,
147+
timeout = 30_000,
148+
): Promise<Page> => {
149+
const start = Date.now();
150+
while (Date.now() - start < timeout) {
151+
for (const context of browser.contexts()) {
152+
for (const page of context.pages()) {
153+
const hasWorkspace = await page
154+
.locator(".workspace")
155+
.first()
156+
.isVisible()
157+
.catch(() => false);
158+
if (hasWorkspace) return page;
159+
}
160+
}
161+
await new Promise((resolve) => setTimeout(resolve, 500));
162+
}
163+
throw new Error(
164+
`No page with .workspace found within ${timeout}ms. ` +
165+
`Available pages: ${browser
166+
.contexts()
167+
.flatMap((c) => c.pages().map((p) => p.url()))
168+
.join(", ")}`,
169+
);
170+
};
171+
54172
/**
55173
* Launch Obsidian as a subprocess with remote debugging enabled,
56174
* then connect via Chrome DevTools Protocol.
@@ -65,20 +183,30 @@ export const launchObsidian = async (
65183
browser: Browser;
66184
page: Page;
67185
obsidianProcess: ChildProcess;
186+
originalObsidianConfig?: string;
68187
}> => {
69-
// Kill any existing Obsidian instances to free the debug port
188+
// Kill only the process currently holding the debug port
70189
try {
71-
execSync("pkill -f Obsidian", { stdio: "ignore" });
72-
// Wait for processes to fully exit
73-
await new Promise((resolve) => setTimeout(resolve, 2_000));
190+
const pids = execSync(`lsof -ti tcp:${DEBUG_PORT}`, {
191+
stdio: ["ignore", "pipe", "ignore"],
192+
})
193+
.toString()
194+
.trim();
195+
if (pids) {
196+
execSync(`kill -9 ${pids.split("\n").join(" ")}`, { stdio: "ignore" });
197+
await new Promise((resolve) => setTimeout(resolve, 500));
198+
}
74199
} catch {
75-
// No existing Obsidian processes — that's fine
200+
// No process on the port — that's fine
76201
}
77202

203+
// Set the target vault as active in Obsidian's config before launching
204+
const originalObsidianConfig = setActiveVault(vaultPath);
205+
78206
// Launch Obsidian with remote debugging port
79207
const obsidianProcess = spawn(
80208
OBSIDIAN_PATH,
81-
[`--remote-debugging-port=${DEBUG_PORT}`, `--vault=${vaultPath}`],
209+
[`--remote-debugging-port=${DEBUG_PORT}`],
82210
{
83211
stdio: "pipe",
84212
detached: true,
@@ -92,33 +220,38 @@ export const launchObsidian = async (
92220
console.log("[Obsidian stdout]", data.toString());
93221
});
94222

95-
// Unref so the spawned process doesn't keep the test runner alive
96223
obsidianProcess.unref();
97224

98225
// Wait for the debug port to be ready
99226
await waitForDebugPort(DEBUG_PORT, 30_000);
100227

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-
}
228+
// Connect to Obsidian via CDP with retry logic
229+
const browser = await connectWithRetry(`http://localhost:${DEBUG_PORT}`);
111230

112-
let page = context.pages()[0];
113-
if (!page) {
114-
page = await context.waitForEvent("page");
115-
}
231+
// Find the workspace page (not the dev console or other windows)
232+
const page = await findWorkspacePage(browser);
116233

117-
// Wait for Obsidian to finish initializing
118-
await page.waitForLoadState("domcontentloaded");
119-
await page.waitForSelector(".workspace", { timeout: 30_000 });
234+
return { browser, page, obsidianProcess, originalObsidianConfig };
235+
};
120236

121-
return { browser, page, obsidianProcess };
237+
const connectWithRetry = async (
238+
url: string,
239+
maxRetries = 5,
240+
delayMs = 1_000,
241+
): Promise<Browser> => {
242+
let lastError: unknown;
243+
for (let i = 0; i < maxRetries; i++) {
244+
try {
245+
return await chromium.connectOverCDP(url);
246+
} catch (e) {
247+
lastError = e;
248+
console.log(
249+
`CDP connect attempt ${i + 1}/${maxRetries} failed, retrying in ${delayMs}ms...`,
250+
);
251+
await new Promise((resolve) => setTimeout(resolve, delayMs));
252+
}
253+
}
254+
throw lastError;
122255
};
123256

124257
const waitForDebugPort = async (

0 commit comments

Comments
 (0)