diff --git a/src/mock-tauri/index.ts b/src/mock-tauri/index.ts index 25b01a5e..e966f7b3 100644 --- a/src/mock-tauri/index.ts +++ b/src/mock-tauri/index.ts @@ -24,11 +24,18 @@ if (typeof window !== 'undefined') { window.__mockHandlers = mockHandlers } +function resolveMockHandler(command: string) { + if (typeof window !== 'undefined' && window.__mockHandlers?.[command]) { + return window.__mockHandlers[command] + } + return mockHandlers[command] +} + export async function mockInvoke(cmd: string, args?: Record): Promise { const vaultResult = await tryVaultApi(cmd, args) if (vaultResult !== undefined) return vaultResult - const handler = mockHandlers[cmd] + const handler = resolveMockHandler(cmd) if (handler) { await new Promise((r) => setTimeout(r, 100)) return handler(args) as T diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index 4383f89c..c2f8205d 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -103,11 +103,17 @@ let mockSettings: Settings = { default_ai_agent: 'claude_code', } -let mockLastVaultPath: string | null = null +const DEFAULT_MOCK_VAULT_PATH = '/Users/mock/demo-vault-v2' +const DEFAULT_MOCK_VAULT = { + label: 'demo-vault-v2', + path: DEFAULT_MOCK_VAULT_PATH, +} + +let mockLastVaultPath: string | null = DEFAULT_MOCK_VAULT_PATH let mockVaultList: { vaults: Array<{ label: string; path: string }>; active_vault: string | null } = { - vaults: [], - active_vault: null, + vaults: [DEFAULT_MOCK_VAULT], + active_vault: DEFAULT_MOCK_VAULT_PATH, } let mockVaultAiGuidanceStatus = { diff --git a/src/mock-tauri/vault-api.test.ts b/src/mock-tauri/vault-api.test.ts new file mode 100644 index 00000000..2e019ca2 --- /dev/null +++ b/src/mock-tauri/vault-api.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const originalFetch = globalThis.fetch + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +describe('tryVaultApi', () => { + afterEach(() => { + vi.resetModules() + vi.restoreAllMocks() + globalThis.fetch = originalFetch + }) + + it('retries vault API discovery after an unavailable response', async () => { + let vaultApiOnline = false + const fetchMock = vi.fn(async (input: string | URL) => { + const url = String(input) + if (url === '/api/vault/ping') { + return jsonResponse({ ok: vaultApiOnline }, vaultApiOnline ? 200 : 503) + } + if (url === '/api/vault/list?path=%2Ffixture') { + return jsonResponse([{ title: 'Alpha Project' }]) + } + throw new Error(`Unexpected fetch: ${url}`) + }) + globalThis.fetch = fetchMock as typeof fetch + + const { tryVaultApi } = await import('./vault-api') + + await expect(tryVaultApi('list_vault', { path: '/fixture' })).resolves.toBeUndefined() + + vaultApiOnline = true + + await expect(tryVaultApi('list_vault', { path: '/fixture' })).resolves.toEqual([{ title: 'Alpha Project' }]) + expect(fetchMock.mock.calls.filter(([url]) => String(url) === '/api/vault/ping')).toHaveLength(2) + }) + + it('unwraps note content responses from the vault API', async () => { + const fetchMock = vi.fn(async (input: string | URL) => { + const url = String(input) + if (url === '/api/vault/ping') { + return jsonResponse({ ok: true }) + } + if (url === '/api/vault/content?path=%2Ffixture%2Falpha.md') { + return jsonResponse({ content: '# Alpha Project' }) + } + throw new Error(`Unexpected fetch: ${url}`) + }) + globalThis.fetch = fetchMock as typeof fetch + + const { tryVaultApi } = await import('./vault-api') + + await expect(tryVaultApi('get_note_content', { path: '/fixture/alpha.md' })).resolves.toBe('# Alpha Project') + expect(fetchMock.mock.calls.filter(([url]) => String(url) === '/api/vault/ping')).toHaveLength(1) + }) +}) diff --git a/src/mock-tauri/vault-api.ts b/src/mock-tauri/vault-api.ts index a23d9d49..ff789fd1 100644 --- a/src/mock-tauri/vault-api.ts +++ b/src/mock-tauri/vault-api.ts @@ -6,16 +6,22 @@ let vaultApiAvailable: boolean | null = null -async function checkVaultApi(): Promise { - if (vaultApiAvailable !== null) return vaultApiAvailable +async function detectVaultApiAvailability(): Promise { try { const res = await fetch('/api/vault/ping', { signal: AbortSignal.timeout(500) }) - vaultApiAvailable = res.ok + return res.ok } catch { - vaultApiAvailable = false + return false } +} + +async function checkVaultApi(): Promise { + if (vaultApiAvailable === true) return true + + const available = await detectVaultApiAvailability() + vaultApiAvailable = available console.info(`[mock-tauri] Vault API available: ${vaultApiAvailable}`) - return vaultApiAvailable + return available } interface VaultApiRequest { @@ -65,25 +71,38 @@ const VAULT_API_COMMANDS: Record) => Vaul }, } -export async function tryVaultApi(cmd: string, args?: Record): Promise { - const available = await checkVaultApi() - if (!available) return undefined - +function buildVaultApiRequest(cmd: string, args?: Record) { + if (!args) return null const requestBuilder = VAULT_API_COMMANDS[cmd] - if (!requestBuilder || !args) return undefined + return requestBuilder?.(args) ?? null +} - const request = requestBuilder(args) +function buildFetchOptions(request: VaultApiRequest): RequestInit { + if (!request.body) { + return { method: request.method || 'GET' } + } + + return { + method: request.method || 'GET', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request.body), + } +} + +async function fetchVaultApiResponse(request: VaultApiRequest) { + const res = await fetch(request.url, buildFetchOptions(request)) + if (!res.ok) return undefined + return res.json() +} + +export async function tryVaultApi(cmd: string, args?: Record): Promise { + const request = buildVaultApiRequest(cmd, args) if (!request) return undefined + if (!await checkVaultApi()) return undefined try { - const fetchOpts: RequestInit = { method: request.method || 'GET' } - if (request.body) { - fetchOpts.headers = { 'Content-Type': 'application/json' } - fetchOpts.body = JSON.stringify(request.body) - } - const res = await fetch(request.url, fetchOpts) - if (!res.ok) return undefined - const data = await res.json() + const data = await fetchVaultApiResponse(request) + if (data === undefined) return undefined return (cmd === 'get_note_content' ? data.content : data) as T } catch (err) { console.warn(`[mock-tauri] Vault API call failed for ${cmd}, falling back to mock:`, err) diff --git a/tests/helpers/fixtureVault.ts b/tests/helpers/fixtureVault.ts index 7cd851d0..a79b57c1 100644 --- a/tests/helpers/fixtureVault.ts +++ b/tests/helpers/fixtureVault.ts @@ -2,20 +2,40 @@ import { expect, type Page } from '@playwright/test' import fs from 'fs' import os from 'os' import path from 'path' +import { installFixtureVaultDesktopBridgeInBrowser } from './fixtureVaultDesktopBridge' const FIXTURE_VAULT = path.resolve('tests/fixtures/test-vault') const FIXTURE_VAULT_READY_TIMEOUT = 30_000 const FIXTURE_VAULT_REMOVE_RETRIES = 10 const FIXTURE_VAULT_REMOVE_RETRY_DELAY_MS = 100 const CLAUDE_CODE_ONBOARDING_DISMISSED_KEY = 'tolaria:claude-code-onboarding-dismissed' +type FixtureCommandArgs = Record | undefined -function copyDirSync(src: string, dest: string): void { +interface FixtureVaultPageArgs { + page: Page + vaultPath: string +} + +interface FixturePageArgs { + page: Page +} + +interface CopyDirArgs { + src: string + dest: string +} + +interface RemoveFixtureVaultArgs { + tempVaultDir: string +} + +function copyDirSync({ src, dest }: CopyDirArgs): void { fs.mkdirSync(dest, { recursive: true }) for (const item of fs.readdirSync(src, { withFileTypes: true })) { const sourcePath = path.join(src, item.name) const destinationPath = path.join(dest, item.name) if (item.isDirectory()) { - copyDirSync(sourcePath, destinationPath) + copyDirSync({ src: sourcePath, dest: destinationPath }) continue } fs.copyFileSync(sourcePath, destinationPath) @@ -24,12 +44,11 @@ function copyDirSync(src: string, dest: string): void { export function createFixtureVaultCopy(): string { const tempVaultDir = fs.mkdtempSync(path.join(os.tmpdir(), 'laputa-test-vault-')) - copyDirSync(FIXTURE_VAULT, tempVaultDir) + copyDirSync({ src: FIXTURE_VAULT, dest: tempVaultDir }) return tempVaultDir } -export function removeFixtureVaultCopy(tempVaultDir: string | null | undefined): void { - if (!tempVaultDir) return +function removeFixtureVaultDirectory({ tempVaultDir }: RemoveFixtureVaultArgs): void { fs.rmSync(tempVaultDir, { recursive: true, force: true, @@ -38,15 +57,21 @@ export function removeFixtureVaultCopy(tempVaultDir: string | null | undefined): }) } -export async function openFixtureVault( - page: Page, - vaultPath: string, -): Promise { +export function removeFixtureVaultCopy(tempVaultDir: string | null | undefined): void { + if (!tempVaultDir) return + removeFixtureVaultDirectory({ tempVaultDir }) +} + +async function installFixtureVaultInitScript({ page, vaultPath }: FixtureVaultPageArgs): Promise { await page.addInitScript(({ dismissedKey, resolvedVaultPath }: { dismissedKey: string; resolvedVaultPath: string }) => { localStorage.clear() localStorage.setItem(dismissedKey, '1') + const jsonHeaders = { 'Content-Type': 'application/json' } + const FRONTMATTER_OPEN = '---\n' + const FRONTMATTER_CLOSE = '\n---\n' const nativeFetch = window.fetch.bind(window) + window.fetch = (input: RequestInfo | URL, init?: RequestInit) => { const requestUrl = typeof input === 'string' ? input @@ -64,85 +89,21 @@ export async function openFixtureVault( return nativeFetch(input, init) } - const applyFixtureVaultOverrides = ( - handlers: Record unknown)> | null | undefined, - ) => { - if (!handlers) return handlers - handlers.load_vault_list = () => ({ - vaults: [{ label: 'Test Vault', path: resolvedVaultPath }], - active_vault: resolvedVaultPath, - hidden_defaults: [], - }) - handlers.check_vault_exists = () => true - handlers.get_last_vault_path = () => resolvedVaultPath - handlers.get_default_vault_path = () => resolvedVaultPath - handlers.save_vault_list = () => null - return handlers + const readJson = async (url: string, init?: RequestInit) => { + const response = await nativeFetch(url, init) + if (!response.ok) { + let message = `HTTP ${response.status}` + try { + const body = await response.json() as { error?: string } + message = body.error ?? message + } catch { + // Preserve the HTTP status fallback when the body is not JSON. + } + throw new Error(message) + } + return response.json() } - let ref = applyFixtureVaultOverrides( - (window.__mockHandlers as Record unknown)> | undefined), - ) ?? null - - Object.defineProperty(window, '__mockHandlers', { - configurable: true, - set(value) { - ref = applyFixtureVaultOverrides( - value as Record unknown)> | undefined, - ) ?? null - }, - get() { - return applyFixtureVaultOverrides(ref) ?? ref - }, - }) - }, { dismissedKey: CLAUDE_CODE_ONBOARDING_DISMISSED_KEY, resolvedVaultPath: vaultPath }) - - await page.goto('/', { waitUntil: 'domcontentloaded' }) - await page.waitForFunction(() => Boolean(window.__mockHandlers)) - await page.evaluate((resolvedVaultPath: string) => { - const handlers = window.__mockHandlers - if (!handlers) { - throw new Error('Mock handlers unavailable for fixture vault override') - } - - handlers.load_vault_list = () => ({ - vaults: [{ label: 'Test Vault', path: resolvedVaultPath }], - active_vault: resolvedVaultPath, - hidden_defaults: [], - }) - handlers.check_vault_exists = () => true - handlers.get_last_vault_path = () => resolvedVaultPath - handlers.get_default_vault_path = () => resolvedVaultPath - handlers.save_vault_list = () => null - }, vaultPath) - await page.reload({ waitUntil: 'domcontentloaded' }) - await page.locator('[data-testid="note-list-container"]').waitFor({ timeout: FIXTURE_VAULT_READY_TIMEOUT }) - await expect(page.getByText('Alpha Project', { exact: true }).first()).toBeVisible({ - timeout: FIXTURE_VAULT_READY_TIMEOUT, - }) -} - -/** - * Browser harness for desktop command-routing tests. - * - * This stubs the Tauri invoke bridge inside Playwright so tests can exercise - * renderer shortcut dispatch and desktop menu-command dispatch without a native - * shell. It is deterministic, but it is not a substitute for real native QA. - */ -export async function openFixtureVaultDesktopHarness( - page: Page, - vaultPath: string, -): Promise { - await openFixtureVault(page, vaultPath) - await page.evaluate((resolvedVaultPath: string) => { - const jsonHeaders = { 'Content-Type': 'application/json' } - const nativeFetch = window.fetch.bind(window) - - const FRONTMATTER_OPEN = '---\n' - const FRONTMATTER_CLOSE = '\n---\n' - - const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const splitFrontmatter = (content: string) => { if (!content.startsWith(FRONTMATTER_OPEN)) { return { frontmatter: null as string | null, body: content } @@ -206,16 +167,12 @@ export async function openFixtureVaultDesktopHarness( return `${FRONTMATTER_OPEN}${nextEntryLines.join('\n')}${FRONTMATTER_CLOSE}${body}` } - const entries = splitFrontmatterEntries(frontmatter).filter((entry) => entry.key !== '') - const keyPattern = new RegExp(`^${escapeRegExp(key)}$`) - let replaced = false - const nextEntries = entries.map((entry) => { - if (!keyPattern.test(entry.key)) return entry - replaced = true - return { key, lines: nextEntryLines } - }) + const nextEntries = splitFrontmatterEntries(frontmatter) + .filter((entry) => entry.key !== '') + .map((entry) => (entry.key === key ? { key, lines: nextEntryLines } : entry)) - if (!replaced) { + const hasEntry = nextEntries.some((entry) => entry.key === key) + if (!hasEntry) { nextEntries.push({ key, lines: nextEntryLines }) } @@ -226,9 +183,8 @@ export async function openFixtureVaultDesktopHarness( const { frontmatter, body } = splitFrontmatter(content) if (frontmatter === null) return content - const keyPattern = new RegExp(`^${escapeRegExp(key)}$`) const nextEntries = splitFrontmatterEntries(frontmatter) - .filter((entry) => entry.key !== '' && !keyPattern.test(entry.key)) + .filter((entry) => entry.key !== '' && entry.key !== key) if (nextEntries.length === 0) { return body @@ -237,32 +193,19 @@ export async function openFixtureVaultDesktopHarness( return `${FRONTMATTER_OPEN}${nextEntries.flatMap((entry) => entry.lines).join('\n')}${FRONTMATTER_CLOSE}${body}` } - const persistFrontmatterChange = async (path: string, transform: (content: string) => string) => { - const current = await readJson(`/api/vault/content?path=${encodeURIComponent(path)}`) as { content: string } + const persistFrontmatterChange = async (notePath: string, transform: (content: string) => string) => { + const current = await readJson( + `/api/vault/content?path=${encodeURIComponent(notePath)}`, + ) as { content: string } const updatedContent = transform(current.content) await readJson('/api/vault/save', { method: 'POST', headers: jsonHeaders, - body: JSON.stringify({ path, content: updatedContent }), + body: JSON.stringify({ path: notePath, content: updatedContent }), }) return updatedContent } - const readJson = async (url: string, init?: RequestInit) => { - const response = await nativeFetch(url, init) - if (!response.ok) { - let message = `HTTP ${response.status}` - try { - const body = await response.json() as { error?: string } - message = body.error ?? message - } catch { - // Keep the HTTP status fallback when the body is not JSON. - } - throw new Error(message) - } - return response.json() - } - const activeVaultList = { vaults: [{ label: 'Test Vault', path: resolvedVaultPath }], active_vault: resolvedVaultPath, @@ -283,16 +226,16 @@ export async function openFixtureVaultDesktopHarness( body: JSON.stringify(payload), }) - const commandHandlers: Record) => Promise | unknown> = { - trigger_menu_command: (commandArgs) => { - const commandId = String(commandArgs?.id ?? '') - const bridge = window.__laputaTest?.dispatchBrowserMenuCommand - if (!bridge) throw new Error('Tolaria test bridge is missing dispatchBrowserMenuCommand') - bridge(commandId) - return null - }, + const readCommandValue = (commandArgs: FixtureCommandArgs, key: string, fallback?: unknown) => + commandArgs?.[key] ?? fallback + + const readCommandString = (commandArgs: FixtureCommandArgs, key: string, fallback = '') => + String(readCommandValue(commandArgs, key, fallback)) + + const buildFixtureStateHandlers = () => ({ load_vault_list: () => activeVaultList, - check_vault_exists: () => true, + check_vault_exists: (commandArgs?: FixtureCommandArgs) => + readCommandString(commandArgs, 'path') === resolvedVaultPath, is_git_repo: () => true, get_last_vault_path: () => resolvedVaultPath, get_default_vault_path: () => resolvedVaultPath, @@ -309,97 +252,164 @@ export async function openFixtureVaultDesktopHarness( anonymous_id: null, release_channel: null, }), - list_vault: (commandArgs) => readVaultList(commandArgs), - reload_vault: (commandArgs) => readVaultList(commandArgs, true), + }) + + const buildFixtureReadHandlers = () => ({ + list_vault: (commandArgs?: FixtureCommandArgs) => readVaultList(commandArgs), + reload_vault: (commandArgs?: FixtureCommandArgs) => readVaultList(commandArgs, true), list_vault_folders: () => [], list_views: () => [], get_modified_files: () => [], detect_renames: () => [], - reload_vault_entry: (commandArgs) => - readJson(`/api/vault/entry?path=${encodeURIComponent(String(commandArgs?.path ?? ''))}`), - get_note_content: async (commandArgs) => { + reload_vault_entry: (commandArgs?: FixtureCommandArgs) => + readJson(`/api/vault/entry?path=${encodeURIComponent(readCommandString(commandArgs, 'path'))}`), + get_note_content: async (commandArgs?: FixtureCommandArgs) => { const data = await readJson( - `/api/vault/content?path=${encodeURIComponent(String(commandArgs?.path ?? ''))}`, + `/api/vault/content?path=${encodeURIComponent(readCommandString(commandArgs, 'path'))}`, ) as { content: string } return data.content }, - get_all_content: (commandArgs) => + get_all_content: (commandArgs?: FixtureCommandArgs) => readJson( - `/api/vault/all-content?path=${encodeURIComponent(String(commandArgs?.path ?? resolvedVaultPath))}`, + `/api/vault/all-content?path=${encodeURIComponent(readCommandString(commandArgs, 'path', resolvedVaultPath))}`, ), - save_note_content: (commandArgs) => - readJson('/api/vault/save', { - method: 'POST', - headers: jsonHeaders, - body: JSON.stringify({ path: commandArgs?.path, content: commandArgs?.content }), - }), - update_frontmatter: (commandArgs) => - persistFrontmatterChange( - String(commandArgs?.path ?? ''), - (content) => replaceFrontmatterEntry(content, String(commandArgs?.key ?? ''), commandArgs?.value), - ), - delete_frontmatter_property: (commandArgs) => - persistFrontmatterChange( - String(commandArgs?.path ?? ''), - (content) => removeFrontmatterEntry(content, String(commandArgs?.key ?? '')), - ), - rename_note: (commandArgs) => - renameNoteRequest({ - vault_path: commandArgs?.vaultPath ?? resolvedVaultPath, - old_path: commandArgs?.oldPath, - new_title: commandArgs?.newTitle, - old_title: commandArgs?.oldTitle ?? null, - }), - rename_note_filename: (commandArgs) => - readJson('/api/vault/rename-filename', { - method: 'POST', - headers: jsonHeaders, - body: JSON.stringify({ - vault_path: commandArgs?.vaultPath ?? resolvedVaultPath, - old_path: commandArgs?.oldPath, - new_filename_stem: commandArgs?.newFilenameStem, - }), - }), - search_vault: (commandArgs) => { - const resolvedPath = String(commandArgs?.path ?? commandArgs?.vaultPath ?? resolvedVaultPath) - const query = encodeURIComponent(String(commandArgs?.query ?? '')) - const mode = encodeURIComponent(String(commandArgs?.mode ?? 'all')) + search_vault: (commandArgs?: FixtureCommandArgs) => { + const resolvedPath = readCommandString( + commandArgs, + 'path', + readCommandValue(commandArgs, 'vaultPath', resolvedVaultPath), + ) + const query = encodeURIComponent(readCommandString(commandArgs, 'query')) + const mode = encodeURIComponent(readCommandString(commandArgs, 'mode', 'all')) return readJson( `/api/vault/search?vault_path=${encodeURIComponent(resolvedPath)}&query=${query}&mode=${mode}`, ) }, - auto_rename_untitled: async (commandArgs) => { - const notePath = String(commandArgs?.notePath ?? '') + }) + + const buildFixtureWriteHandlers = () => ({ + save_note_content: (commandArgs?: FixtureCommandArgs) => + readJson('/api/vault/save', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ + path: readCommandValue(commandArgs, 'path'), + content: readCommandValue(commandArgs, 'content'), + }), + }), + update_frontmatter: (commandArgs?: FixtureCommandArgs) => + persistFrontmatterChange( + readCommandString(commandArgs, 'path'), + (content) => replaceFrontmatterEntry( + content, + readCommandString(commandArgs, 'key'), + readCommandValue(commandArgs, 'value'), + ), + ), + delete_frontmatter_property: (commandArgs?: FixtureCommandArgs) => + persistFrontmatterChange( + readCommandString(commandArgs, 'path'), + (content) => removeFrontmatterEntry(content, readCommandString(commandArgs, 'key')), + ), + rename_note: (commandArgs?: FixtureCommandArgs) => + renameNoteRequest({ + vault_path: readCommandValue(commandArgs, 'vaultPath', resolvedVaultPath), + old_path: readCommandValue(commandArgs, 'oldPath'), + new_title: readCommandValue(commandArgs, 'newTitle'), + old_title: readCommandValue(commandArgs, 'oldTitle', null), + }), + rename_note_filename: (commandArgs?: FixtureCommandArgs) => + readJson('/api/vault/rename-filename', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ + vault_path: readCommandValue(commandArgs, 'vaultPath', resolvedVaultPath), + old_path: readCommandValue(commandArgs, 'oldPath'), + new_filename_stem: readCommandValue(commandArgs, 'newFilenameStem'), + }), + }), + auto_rename_untitled: async (commandArgs?: FixtureCommandArgs) => { + const notePath = readCommandString(commandArgs, 'notePath') const contentData = await readJson( `/api/vault/content?path=${encodeURIComponent(notePath)}`, ) as { content: string } const match = contentData.content.match(/^#\s+(.+)$/m) if (!match) return null return renameNoteRequest({ - vault_path: commandArgs?.vaultPath ?? resolvedVaultPath, + vault_path: readCommandValue(commandArgs, 'vaultPath', resolvedVaultPath), old_path: notePath, new_title: match[1].trim(), }) }, + }) + + const applyFixtureVaultOverrides = ( + handlers: Record unknown)> | null | undefined, + ) => { + if (!handlers) return handlers + Object.assign( + handlers, + buildFixtureStateHandlers(), + buildFixtureReadHandlers(), + buildFixtureWriteHandlers(), + ) + return handlers } - const invoke = async (command: string, args?: Record) => { - const handler = commandHandlers[command] ?? window.__mockHandlers?.[command] - if (!handler) throw new Error(`Unhandled invoke: ${command}`) - return handler(args) - } + let ref = applyFixtureVaultOverrides( + (window.__mockHandlers as Record unknown)> | undefined), + ) ?? null - Object.defineProperty(window, '__TAURI__', { + Object.defineProperty(window, '__mockHandlers', { configurable: true, - value: {}, + set(value) { + ref = applyFixtureVaultOverrides( + value as Record unknown)> | undefined, + ) ?? null + }, + get() { + return applyFixtureVaultOverrides(ref) ?? ref + }, }) - Object.defineProperty(window, '__TAURI_INTERNALS__', { - configurable: true, - value: { invoke }, - }) - }, vaultPath) + }, { dismissedKey: CLAUDE_CODE_ONBOARDING_DISMISSED_KEY, resolvedVaultPath: vaultPath }) +} + +async function waitForFixtureVaultReady({ page }: FixturePageArgs): Promise { + await page.goto('/', { waitUntil: 'domcontentloaded' }) + await page.waitForFunction(() => Boolean(window.__mockHandlers?.list_vault)) + await page.locator('[data-testid="note-list-container"]').waitFor({ timeout: FIXTURE_VAULT_READY_TIMEOUT }) + await expect(page.getByText('Alpha Project', { exact: true }).first()).toBeVisible({ + timeout: FIXTURE_VAULT_READY_TIMEOUT, + }) +} + +export async function openFixtureVault( + page: Page, + vaultPath: string, +): Promise { + await installFixtureVaultInitScript({ page, vaultPath }) + await waitForFixtureVaultReady({ page }) +} + +async function installFixtureVaultDesktopBridge({ page }: FixturePageArgs): Promise { + await page.evaluate(installFixtureVaultDesktopBridgeInBrowser) await page.waitForFunction(() => Boolean(window.__TAURI_INTERNALS__)) } +/** + * Browser harness for desktop command-routing tests. + * + * This stubs the Tauri invoke bridge inside Playwright so tests can exercise + * renderer shortcut dispatch and desktop menu-command dispatch without a native + * shell. It is deterministic, but it is not a substitute for real native QA. + */ +export async function openFixtureVaultDesktopHarness( + page: Page, + vaultPath: string, +): Promise { + await openFixtureVault(page, vaultPath) + await installFixtureVaultDesktopBridge({ page }) +} + export const openFixtureVaultTauri = openFixtureVaultDesktopHarness diff --git a/tests/helpers/fixtureVaultDesktopBridge.ts b/tests/helpers/fixtureVaultDesktopBridge.ts new file mode 100644 index 00000000..e3fc540d --- /dev/null +++ b/tests/helpers/fixtureVaultDesktopBridge.ts @@ -0,0 +1,27 @@ +export function installFixtureVaultDesktopBridgeInBrowser(): void { + const dispatchBrowserMenuCommand = + window.__laputaTest?.dispatchBrowserMenuCommand + ?? (() => { throw new Error('Tolaria test bridge is missing dispatchBrowserMenuCommand') }) + + const specialHandlers: Record) => unknown> = { + trigger_menu_command: (args) => { + dispatchBrowserMenuCommand(String(args?.id ?? '')) + return null + }, + } + + const invoke = (command: string, args?: Record) => { + const handler = specialHandlers[command] ?? window.__mockHandlers?.[command] + if (!handler) throw new Error(`Unhandled invoke: ${command}`) + return handler(args) + } + + Object.defineProperty(window, '__TAURI__', { + configurable: true, + value: {}, + }) + Object.defineProperty(window, '__TAURI_INTERNALS__', { + configurable: true, + value: { invoke }, + }) +}