1+ import crypto from "crypto" ;
12import fs from "fs" ;
3+ import os from "os" ;
24import path from "path" ;
35import { spawn , execSync , type ChildProcess } from "child_process" ;
46import { chromium , type Browser , type Page } from "@playwright/test" ;
57
68const 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+ ) ;
716const PLUGIN_ID = "@discourse-graph/obsidian" ;
817const 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+
4890export 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
124257const waitForDebugPort = async (
0 commit comments