import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { _electron as electron, type ElectronApplication, type Page, } from '@playwright/test'; const repoRoot = join(__dirname, '..'); const VAULT_ID = 'e2e'; const filler = (label: string, lines = 40) => Array.from({ length: lines }, (_, i) => `${label} body line ${i + 1}.`).join( '\n', ); export const NOTES: Record = { 'Target.md': [ '# Target', filler('intro'), '', '## Middle Section', filler('middle'), '', '## Later Section', filler('later'), ].join('\n'), 'Source.md': [ '# Source', '', 'A plain link to [[Target]].', '', 'An anchored link to [[Target#Middle Section]].', '', filler('source'), ].join('\n'), 'Other.md': ['# Other', '', filler('other')].join('\n'), }; /** Not `firstWindow()`: DevTools is a window too, and driving it fails silently. */ async function appWindow(app: ElectronApplication): Promise { const deadline = Date.now() + 30_000; while (Date.now() < deadline) { const candidate = app .windows() .find((w) => !w.url().startsWith('devtools://')); if (candidate) return candidate; await new Promise((resolve) => setTimeout(resolve, 100)); } throw new Error('Timed out waiting for a non-DevTools application window'); } export type LaunchedApp = { app: ElectronApplication; window: Page; vaultDir: string; close: () => Promise; }; /** * Launch against a throwaway vault. The registry reads `localStorage` once at * load, so seed-then-reload beats driving the vault dialog. */ export async function launchApp(): Promise { const vaultDir = await mkdtemp(join(tmpdir(), 'textile-e2e-vault-')); const userDataDir = await mkdtemp(join(tmpdir(), 'textile-e2e-userdata-')); await mkdir(vaultDir, { recursive: true }); for (const [name, body] of Object.entries(NOTES)) { await writeFile(join(vaultDir, name), body, 'utf-8'); } // Inherited, this runs the binary as plain Node: no `electron.app`. const { ELECTRON_RUN_AS_NODE: _ignored, ...parentEnv } = process.env; const app = await electron.launch({ args: ['.'], cwd: repoRoot, env: { ...parentEnv, TEXTILE_USER_DATA: userDataDir }, }); const window = await appWindow(app); await window.waitForLoadState('domcontentloaded'); // CM renders only its viewport, so a sliver of a window hides the document. await app.evaluate(({ BrowserWindow }) => { const win = BrowserWindow.getAllWindows()[0]; if (!win) return; win.webContents.closeDevTools(); win.setBounds({ x: 0, y: 0, width: 1400, height: 900 }); }); await window.evaluate( ({ dir, id }) => { localStorage.setItem( 'textile.openVaults', JSON.stringify([{ state: 'local', id, name: 'E2E Vault', path: dir }]), ); localStorage.setItem('textile.selectedVault', `vault-${id}`); }, { dir: vaultDir, id: VAULT_ID }, ); await window.reload(); await window.waitForLoadState('domcontentloaded'); await window.getByText('Target.md', { exact: true }).first().waitFor(); return { app, window, vaultDir, close: async () => { // The app outlives its last window on macOS, so `close()` alone can hang. const proc = app.process(); await Promise.race([ app.close().catch(() => {}), new Promise((resolve) => setTimeout(resolve, 5_000)), ]); try { if (proc.exitCode === null && proc.signalCode === null) { proc.kill('SIGKILL'); } } catch { // Already gone. } await rm(vaultDir, { recursive: true, force: true }); await rm(userDataDir, { recursive: true, force: true }); }, }; } /** * Reset between tests. Specs share one app because quitting Electron often * hangs mid-exit on macOS. Restoring the notes matters: the editor persists * edits, so a test that types corrupts the vault for later ones. */ export async function resetWorkspace(app: LaunchedApp): Promise { for (const [name, body] of Object.entries(NOTES)) { await writeFile(join(app.vaultDir, name), body, 'utf-8'); } await app.window.evaluate(() => { for (const key of Object.keys(localStorage)) { if (key.startsWith('textile.tiling.')) localStorage.removeItem(key); } }); await app.window.reload(); await app.window.waitForLoadState('domcontentloaded'); await app.window.getByText('Target.md', { exact: true }).first().waitFor(); } export async function openNote(window: Page, fileName: string): Promise { await window.getByText(fileName, { exact: true }).first().click(); await window.locator('.cm-content').first().waitFor(); } export function scroller(window: Page) { return window.locator('.cm-scroller').first(); } export const scrollTop = (window: Page) => scroller(window).evaluate((el) => el.scrollTop);