diff --git a/src/hooks/useVaultLoader.test.ts b/src/hooks/useVaultLoader.test.ts index 2ccaa3fd..59632214 100644 --- a/src/hooks/useVaultLoader.test.ts +++ b/src/hooks/useVaultLoader.test.ts @@ -11,6 +11,8 @@ const mockEntries: VaultEntry[] = [ archived: false, modifiedAt: 1700000000, createdAt: 1700000000, fileSize: 100, snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, template: null, sort: null, outgoingLinks: [], + sidebarLabel: null, view: null, visible: null, organized: false, favorite: false, favoriteIndex: null, + listPropertiesDisplay: [], properties: {}, hasH1: false, }, ] @@ -152,6 +154,55 @@ describe('useVaultLoader', () => { expect(result.current.entries[0].title).toBe('Hello') }) + it('normalizes missing entry and view string metadata from vault load', async () => { + backendInvokeFn.mockImplementation(((cmd: string) => { + if (isVaultLoadCommand(cmd)) { + return Promise.resolve([ + { + path: '/vault/note/missing-title.md', + filename: undefined, + title: undefined, + aliases: undefined, + outgoingLinks: undefined, + relationships: undefined, + properties: undefined, + }, + ]) + } + if (cmd === 'list_views') return Promise.resolve([{ filename: undefined, definition: {} }]) + if (cmd === 'get_modified_files') return Promise.resolve([]) + if (cmd === 'list_vault_folders') return Promise.resolve([]) + return Promise.resolve(null) + }) as typeof defaultMockInvoke) + + const { result } = renderHook(() => useVaultLoader('/vault')) + + await waitForEntries(result) + await waitFor(() => { + expect(result.current.views).toHaveLength(1) + }) + + expect(result.current.entries[0]).toMatchObject({ + path: '/vault/note/missing-title.md', + filename: 'missing-title.md', + title: 'missing-title', + aliases: [], + outgoingLinks: [], + relationships: {}, + properties: {}, + }) + expect(result.current.views[0]).toMatchObject({ + filename: 'view-1.yml', + definition: { + name: 'View 1', + icon: null, + color: null, + sort: null, + filters: { all: [] }, + }, + }) + }) + it('reports initial vault loading until the note scan resolves', async () => { const entriesLoad = createDeferred() backendInvokeFn.mockImplementation(((cmd: string) => { diff --git a/src/hooks/useVaultLoader.ts b/src/hooks/useVaultLoader.ts index 4e894e28..843ed099 100644 --- a/src/hooks/useVaultLoader.ts +++ b/src/hooks/useVaultLoader.ts @@ -1,6 +1,4 @@ import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react' -import { invoke } from '@tauri-apps/api/core' -import { isTauri, mockInvoke } from '../mock-tauri' import type { VaultEntry, FolderNode, GitCommit, ModifiedFile, NoteStatus, GitPushResult, ViewFile } from '../types' import { GITIGNORED_VISIBILITY_CHANGED_EVENT, @@ -8,46 +6,16 @@ import { type GitignoredVisibilityChangedEvent, } from '../lib/gitignoredVisibilityEvents' import { clearPrefetchCache } from './useTabManagement' - -function tauriCall(command: string, tauriArgs: Record, mockArgs?: Record): Promise { - return isTauri() ? invoke(command, tauriArgs) : mockInvoke(command, mockArgs ?? tauriArgs) -} - -function hasVaultPath(vaultPath: string): boolean { - return vaultPath.trim().length > 0 -} - -function loadVaultEntries(vaultPath: string): Promise { - const command = isTauri() ? 'reload_vault' : 'list_vault' - return tauriCall(command, { path: vaultPath }) -} - -async function loadVaultData(vaultPath: string) { - if (!isTauri()) console.info('[mock] Using mock Tauri data for browser testing') - const entries = await loadVaultEntries(vaultPath) - console.log(`Vault scan complete: ${entries.length} entries found`) - return { entries } -} - -function loadVaultFolders(vaultPath: string): Promise { - return tauriCall('list_vault_folders', { path: vaultPath }) -} - -function loadVaultViews(vaultPath: string): Promise { - return tauriCall('list_views', { vaultPath }) -} - -async function loadVaultChrome(vaultPath: string) { - const [folders, views] = await Promise.all([ - loadVaultFolders(vaultPath).catch(() => [] as FolderNode[]), - loadVaultViews(vaultPath).catch(() => [] as ViewFile[]), - ]) - - return { - folders: folders ?? [], - views: views ?? [], - } -} +import { + commitWithPush, + hasVaultPath, + loadVaultChrome, + loadVaultData, + loadVaultFolders, + loadVaultViews, + reloadVaultEntries, + tauriCall, +} from './vaultLoaderCommands' function resetVaultState(options: { clearNewPaths: () => void @@ -78,11 +46,11 @@ async function loadInitialVaultState(options: { setViews: (views: ViewFile[]) => void }) { const { path, isCurrentVaultPath, setEntries, setFolders, setIsLoading, setViews } = options - const chromeLoad = loadVaultChrome(path) + const chromeLoad = loadVaultChrome({ vaultPath: path }) setIsLoading(true) try { - const { entries } = await loadVaultData(path) + const { entries } = await loadVaultData({ vaultPath: path }) if (isCurrentVaultPath(path)) setEntries(entries) } catch (err) { console.warn('Vault scan failed:', err) @@ -106,15 +74,6 @@ function useCurrentVaultPathGuard(vaultPath: string) { return useCallback((path: string) => currentPathRef.current === path, []) } -async function commitWithPush(vaultPath: string, message: string): Promise { - if (!isTauri()) { - await mockInvoke('git_commit', { message }) - return mockInvoke('git_push', {}) - } - await invoke('git_commit', { vaultPath, message }) - return invoke('git_push', { vaultPath }) -} - function useNewNoteTracker() { const [newPaths, setNewPaths] = useState>(new Set()) @@ -242,7 +201,7 @@ function useInitialVaultLoad({ }) resetReloading() - if (!hasVaultPath(path)) return + if (!hasVaultPath({ vaultPath: path })) return let cancelled = false void loadInitialVaultState({ @@ -277,13 +236,17 @@ function useModifiedFilesLoader(vaultPath: string, isCurrentVaultPath: (path: st const path = vaultPath setModifiedFilesError(null) - if (!hasVaultPath(path)) { + if (!hasVaultPath({ vaultPath: path })) { setModifiedFiles([]) return } try { - const files = await tauriCall('get_modified_files', { vaultPath: path }, {}) + const files = await tauriCall({ + command: 'get_modified_files', + tauriArgs: { vaultPath: path }, + mockArgs: {}, + }) if (isCurrentVaultPath(path)) setModifiedFiles(files) } catch (err) { if (!isCurrentVaultPath(path)) return @@ -347,18 +310,32 @@ function useEntryMutations( function useGitLoaders(vaultPath: string) { const loadGitHistory = useCallback(async (path: string): Promise => { - try { return await tauriCall('get_file_history', { vaultPath, path }, { path }) } + try { + return await tauriCall({ + command: 'get_file_history', + tauriArgs: { vaultPath, path }, + mockArgs: { path }, + }) + } catch (err) { console.warn('Failed to load git history:', err); return [] } }, [vaultPath]) const loadDiffAtCommit = useCallback((path: string, commitHash: string): Promise => - tauriCall('get_file_diff_at_commit', { vaultPath, path, commitHash }, { path, commitHash }), [vaultPath]) + tauriCall({ + command: 'get_file_diff_at_commit', + tauriArgs: { vaultPath, path, commitHash }, + mockArgs: { path, commitHash }, + }), [vaultPath]) const loadDiff = useCallback((path: string): Promise => - tauriCall('get_file_diff', { vaultPath, path }, { path }), [vaultPath]) + tauriCall({ + command: 'get_file_diff', + tauriArgs: { vaultPath, path }, + mockArgs: { path }, + }), [vaultPath]) const commitAndPush = useCallback((message: string): Promise => - commitWithPush(vaultPath, message), [vaultPath]) + commitWithPush({ vaultPath, message }), [vaultPath]) return { loadGitHistory, loadDiffAtCommit, loadDiff, commitAndPush } } @@ -386,9 +363,9 @@ function useVaultReloads({ const reloadFolders = useCallback(async () => { const path = vaultPath - if (!hasVaultPath(path)) return [] as FolderNode[] + if (!hasVaultPath({ vaultPath: path })) return [] as FolderNode[] try { - const folders = await loadVaultFolders(path) + const folders = await loadVaultFolders({ vaultPath: path }) if (!isCurrentVaultPath(path)) return [] as FolderNode[] const nextFolders = folders ?? [] setFolders(nextFolders) @@ -400,11 +377,11 @@ function useVaultReloads({ const reloadVault = useCallback(async () => { const path = vaultPath - if (!hasVaultPath(path)) return [] as VaultEntry[] + if (!hasVaultPath({ vaultPath: path })) return [] as VaultEntry[] clearPrefetchCache() beginReload() try { - const entries = await tauriCall('reload_vault', { path }) + const entries = await reloadVaultEntries({ vaultPath: path }) if (!isCurrentVaultPath(path)) return [] as VaultEntry[] setEntries(entries) void loadModifiedFiles() @@ -419,9 +396,9 @@ function useVaultReloads({ const reloadViews = useCallback(async () => { const path = vaultPath - if (!hasVaultPath(path)) return [] + if (!hasVaultPath({ vaultPath: path })) return [] try { - const nextViews = await loadVaultViews(path) + const nextViews = await loadVaultViews({ vaultPath: path }) if (!isCurrentVaultPath(path)) return [] const resolvedViews = nextViews ?? [] setViews(resolvedViews) @@ -462,7 +439,7 @@ function useGitignoredVisibilityReloads( export function useVaultLoader(vaultPath: string) { const [entries, setEntries] = useState([]) const [folders, setFolders] = useState([]) - const [isLoading, setIsLoading] = useState(() => hasVaultPath(vaultPath)) + const [isLoading, setIsLoading] = useState(() => hasVaultPath({ vaultPath })) const [views, setViews] = useState([]) const tracker = useNewNoteTracker() const pendingSave = usePendingSaveTracker() diff --git a/src/hooks/vaultLoaderCommands.ts b/src/hooks/vaultLoaderCommands.ts new file mode 100644 index 00000000..c0553645 --- /dev/null +++ b/src/hooks/vaultLoaderCommands.ts @@ -0,0 +1,86 @@ +import { invoke } from '@tauri-apps/api/core' +import { isTauri, mockInvoke } from '../mock-tauri' +import type { FolderNode, GitPushResult, VaultEntry, ViewFile } from '../types' +import { normalizeVaultEntries, normalizeViewFiles } from '../utils/vaultMetadataNormalization' + +interface TauriCallOptions { + command: string + tauriArgs: Record + mockArgs?: Record +} + +interface VaultPathOptions { + vaultPath: string +} + +interface CommitWithPushOptions extends VaultPathOptions { + message: string +} + +interface LoadedVaultData { + entries: VaultEntry[] +} + +interface LoadedVaultChrome { + folders: FolderNode[] + views: ViewFile[] +} + +export function hasVaultPath({ vaultPath }: VaultPathOptions): boolean { + return vaultPath.trim().length > 0 +} + +export function tauriCall({ command, tauriArgs, mockArgs }: TauriCallOptions): Promise { + return isTauri() ? invoke(command, tauriArgs) : mockInvoke(command, mockArgs ?? tauriArgs) +} + +function loadVaultEntriesWithCommand({ vaultPath, command }: VaultPathOptions & { command: string }): Promise { + return tauriCall({ command, tauriArgs: { path: vaultPath } }) + .then((entries) => normalizeVaultEntries(entries, vaultPath)) +} + +function loadVaultEntries({ vaultPath }: VaultPathOptions): Promise { + const command = isTauri() ? 'reload_vault' : 'list_vault' + return loadVaultEntriesWithCommand({ vaultPath, command }) +} + +export function reloadVaultEntries({ vaultPath }: VaultPathOptions): Promise { + return loadVaultEntriesWithCommand({ vaultPath, command: 'reload_vault' }) +} + +export function loadVaultFolders({ vaultPath }: VaultPathOptions): Promise { + return tauriCall({ command: 'list_vault_folders', tauriArgs: { path: vaultPath } }) +} + +export function loadVaultViews({ vaultPath }: VaultPathOptions): Promise { + return tauriCall({ command: 'list_views', tauriArgs: { vaultPath } }) + .then(normalizeViewFiles) +} + +export async function loadVaultData({ vaultPath }: VaultPathOptions): Promise { + if (!isTauri()) console.info('[mock] Using mock Tauri data for browser testing') + const entries = await loadVaultEntries({ vaultPath }) + console.log(`Vault scan complete: ${entries.length} entries found`) + return { entries } +} + +export async function loadVaultChrome({ vaultPath }: VaultPathOptions): Promise { + const [folders, views] = await Promise.all([ + loadVaultFolders({ vaultPath }).catch(() => [] as FolderNode[]), + loadVaultViews({ vaultPath }).catch(() => [] as ViewFile[]), + ]) + + return { + folders: folders ?? [], + views: views ?? [], + } +} + +export async function commitWithPush({ vaultPath, message }: CommitWithPushOptions): Promise { + if (!isTauri()) { + await mockInvoke('git_commit', { message }) + return mockInvoke('git_push', {}) + } + await invoke('git_commit', { vaultPath, message }) + return invoke('git_push', { vaultPath }) +} diff --git a/src/utils/vaultMetadataNormalization.ts b/src/utils/vaultMetadataNormalization.ts new file mode 100644 index 00000000..ef636785 --- /dev/null +++ b/src/utils/vaultMetadataNormalization.ts @@ -0,0 +1,218 @@ +import type { FilterGroup, FilterNode, VaultEntry, ViewDefinition, ViewFile } from '../types' + +type UnknownRecord = Record + +interface EntryNormalizationArgs { + rawEntry: unknown + vaultPath: string + index: number +} + +interface EntryPathArgs { + explicitPath: string + filename: string + vaultPath: string +} + +interface ViewNormalizationArgs { + rawView: unknown + index: number +} + +interface ViewDefinitionArgs { + rawDefinition: unknown + filename: string + index: number +} + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function recordFrom(value: unknown): UnknownRecord { + return isRecord(value) ? value : {} +} + +function stringFrom(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +function nullableStringFrom(value: unknown): string | null { + const text = stringFrom(value).trim() + return text.length > 0 ? text : null +} + +function numberFrom(value: unknown, fallback = 0): number { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback +} + +function nullableNumberFrom(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function booleanFrom(value: unknown, fallback = false): boolean { + return typeof value === 'boolean' ? value : fallback +} + +function nullableBooleanFrom(value: unknown): boolean | null { + return typeof value === 'boolean' ? value : null +} + +function stringArrayFrom(value: unknown): string[] { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [] +} + +function filenameFromPath(path: string): string { + const normalizedPath = path.replace(/\\/g, '/') + return normalizedPath.split('/').filter(Boolean).pop() ?? '' +} + +function stripExtension(filename: string): string { + return filename.replace(/\.[^.]+$/, '') +} + +function fallbackEntryFilename(source: UnknownRecord, index: number): string { + return stringFrom(source.filename) || filenameFromPath(stringFrom(source.path)) || `untitled-${index + 1}.md` +} + +function resolveEntryPath({ explicitPath, filename, vaultPath }: EntryPathArgs): string { + if (explicitPath) return explicitPath + const root = vaultPath.replace(/\/+$/, '') + return root ? `${root}/${filename}` : filename +} + +function normalizeRelationships(value: unknown): Record { + const source = recordFrom(value) + const result: Record = {} + for (const [key, rawRefs] of Object.entries(source)) { + const refs = stringArrayFrom(rawRefs) + if (refs.length > 0) result[key] = refs + } + return result +} + +function normalizeProperties(value: unknown): VaultEntry['properties'] { + const source = recordFrom(value) + const result: VaultEntry['properties'] = {} + for (const [key, rawValue] of Object.entries(source)) { + if ( + rawValue === null + || typeof rawValue === 'string' + || typeof rawValue === 'boolean' + || (typeof rawValue === 'number' && Number.isFinite(rawValue)) + ) { + result[key] = rawValue + } + } + return result +} + +function normalizeFileKind(value: unknown): VaultEntry['fileKind'] { + if (value === 'markdown' || value === 'text' || value === 'binary') return value + return undefined +} + +function normalizeFilterGroup(value: unknown): FilterGroup { + const source = recordFrom(value) + if (Array.isArray(source.all)) return { all: source.all as FilterNode[] } + if (Array.isArray(source.any)) return { any: source.any as FilterNode[] } + return { all: [] } +} + +function fallbackViewName(filename: string, index: number): string { + const stem = stripExtension(filename).trim() + return stem && stem !== `view-${index + 1}` ? stem : `View ${index + 1}` +} + +function normalizeVaultEntry({ rawEntry, vaultPath, index }: EntryNormalizationArgs): VaultEntry { + const source = recordFrom(rawEntry) + const filename = fallbackEntryFilename(source, index) + const path = resolveEntryPath({ + explicitPath: stringFrom(source.path), + filename, + vaultPath, + }) + const title = stringFrom(source.title).trim() || stripExtension(filename) || 'Untitled' + const fileKind = normalizeFileKind(source.fileKind) + + const entry = { + ...(source as Partial), + path, + filename, + title, + isA: nullableStringFrom(source.isA), + aliases: stringArrayFrom(source.aliases), + belongsTo: stringArrayFrom(source.belongsTo), + relatedTo: stringArrayFrom(source.relatedTo), + status: nullableStringFrom(source.status), + archived: booleanFrom(source.archived), + modifiedAt: nullableNumberFrom(source.modifiedAt), + createdAt: nullableNumberFrom(source.createdAt), + fileSize: numberFrom(source.fileSize), + snippet: stringFrom(source.snippet), + wordCount: numberFrom(source.wordCount), + relationships: normalizeRelationships(source.relationships), + icon: nullableStringFrom(source.icon), + color: nullableStringFrom(source.color), + order: nullableNumberFrom(source.order), + sidebarLabel: nullableStringFrom(source.sidebarLabel), + template: nullableStringFrom(source.template), + sort: nullableStringFrom(source.sort), + view: nullableStringFrom(source.view), + visible: nullableBooleanFrom(source.visible), + organized: booleanFrom(source.organized), + favorite: booleanFrom(source.favorite), + favoriteIndex: nullableNumberFrom(source.favoriteIndex), + listPropertiesDisplay: stringArrayFrom(source.listPropertiesDisplay), + outgoingLinks: stringArrayFrom(source.outgoingLinks), + properties: normalizeProperties(source.properties), + hasH1: booleanFrom(source.hasH1), + } as VaultEntry + + if (fileKind) entry.fileKind = fileKind + return entry +} + +function normalizeViewDefinition({ rawDefinition, filename, index }: ViewDefinitionArgs): ViewDefinition { + const definition = recordFrom(rawDefinition) + const name = stringFrom(definition.name).trim() || fallbackViewName(filename, index) + + const normalized = { + ...(definition as Partial), + name, + icon: nullableStringFrom(definition.icon), + color: nullableStringFrom(definition.color), + sort: nullableStringFrom(definition.sort), + filters: normalizeFilterGroup(definition.filters), + } as ViewDefinition + + if ('order' in definition) normalized.order = nullableNumberFrom(definition.order) + if ('listPropertiesDisplay' in definition) { + normalized.listPropertiesDisplay = stringArrayFrom(definition.listPropertiesDisplay) + } + return normalized +} + +function normalizeViewFile({ rawView, index }: ViewNormalizationArgs): ViewFile { + const source = recordFrom(rawView) + const filename = stringFrom(source.filename) || `view-${index + 1}.yml` + + return { + filename, + definition: normalizeViewDefinition({ + rawDefinition: source.definition, + filename, + index, + }), + } +} + +export function normalizeVaultEntries(rawEntries: unknown, vaultPath: string): VaultEntry[] { + if (!Array.isArray(rawEntries)) return [] + return rawEntries.map((rawEntry, index) => normalizeVaultEntry({ rawEntry, vaultPath, index })) +} + +export function normalizeViewFiles(rawViews: unknown): ViewFile[] { + if (!Array.isArray(rawViews)) return [] + return rawViews.map((rawView, index) => normalizeViewFile({ rawView, index })) +} diff --git a/tests/helpers/fixtureVault.ts b/tests/helpers/fixtureVault.ts index 7c2e858e..1836824a 100644 --- a/tests/helpers/fixtureVault.ts +++ b/tests/helpers/fixtureVault.ts @@ -23,6 +23,7 @@ interface FixturePageArgs { interface FixtureVaultOptions { isGitRepo?: boolean + expectedReadyTitle?: string } interface CopyDirArgs { @@ -406,14 +407,18 @@ async function installFixtureVaultInitScript({ page, vaultPath, isGitRepo }: Fix return applyFixtureVaultOverrides(ref) ?? ref }, }) - }, { dismissedKey: CLAUDE_CODE_ONBOARDING_DISMISSED_KEY, initialIsGitRepo: isGitRepo, resolvedVaultPath: vaultPath }) + }, { + dismissedKey: CLAUDE_CODE_ONBOARDING_DISMISSED_KEY, + initialIsGitRepo: isGitRepo, + resolvedVaultPath: vaultPath, + }) } -async function waitForFixtureVaultReady({ page }: FixturePageArgs): Promise { +async function waitForFixtureVaultReady({ page, expectedTitle }: FixturePageArgs & { expectedTitle: string }): 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({ + await expect(page.getByText(expectedTitle, { exact: true }).first()).toBeVisible({ timeout: FIXTURE_VAULT_READY_TIMEOUT, }) } @@ -423,8 +428,15 @@ export async function openFixtureVault( vaultPath: string, options: FixtureVaultOptions = {}, ): Promise { - await installFixtureVaultInitScript({ page, vaultPath, isGitRepo: options.isGitRepo ?? true }) - await waitForFixtureVaultReady({ page }) + await installFixtureVaultInitScript({ + page, + vaultPath, + isGitRepo: options.isGitRepo ?? true, + }) + await waitForFixtureVaultReady({ + page, + expectedTitle: options.expectedReadyTitle ?? 'Alpha Project', + }) } async function installFixtureVaultDesktopBridge({ page }: FixturePageArgs): Promise { diff --git a/tests/smoke/missing-string-metadata-open-note.spec.ts b/tests/smoke/missing-string-metadata-open-note.spec.ts new file mode 100644 index 00000000..e03f1da6 --- /dev/null +++ b/tests/smoke/missing-string-metadata-open-note.spec.ts @@ -0,0 +1,87 @@ +import { test, expect, type Page } from '@playwright/test' +import { + createFixtureVaultCopy, + openFixtureVault, + removeFixtureVaultCopy, +} from '../helpers/fixtureVault' + +let tempVaultDir: string + +function isMissingStringMetadataCrash(message: string): boolean { + return ( + message.includes("Cannot read properties of undefined (reading 'replace')") || + message.includes('undefined is not an object') || + /undefined.*\.replace|\.replace.*undefined/.test(message) + ) +} + +function collectMissingMetadataCrashes(page: Page): string[] { + const errors: string[] = [] + page.on('pageerror', (error) => { + if (isMissingStringMetadataCrash(error.message)) errors.push(error.message) + }) + page.on('console', (message) => { + if (message.type() === 'error' && isMissingStringMetadataCrash(message.text())) { + errors.push(message.text()) + } + }) + return errors +} + +function removeAlphaProjectStringMetadata(entries: Array>) { + return entries.map((entry) => { + const entryPath = typeof entry.path === 'string' ? entry.path : '' + const title = typeof entry.title === 'string' ? entry.title : '' + if (title !== 'Alpha Project' && !entryPath.endsWith('/alpha-project.md')) return entry + return { + ...entry, + title: undefined, + filename: undefined, + aliases: undefined, + outgoingLinks: undefined, + relationships: undefined, + properties: undefined, + snippet: undefined, + } + }) +} + +test.beforeEach(async ({ page }, testInfo) => { + testInfo.setTimeout(60_000) + tempVaultDir = createFixtureVaultCopy() + await page.route('**/*', async (route) => { + const requestUrl = new URL(route.request().url()) + if (!requestUrl.pathname.endsWith('/api/vault/list')) { + await route.continue() + return + } + const response = await route.fetch() + const entries = await response.json() as Array> + await route.fulfill({ + response, + json: removeAlphaProjectStringMetadata(entries), + }) + }) + await openFixtureVault(page, tempVaultDir, { + expectedReadyTitle: 'alpha-project', + }) + await page.setViewportSize({ width: 1180, height: 760 }) +}) + +test.afterEach(() => { + removeFixtureVaultCopy(tempVaultDir) +}) + +test('@smoke note open tolerates missing string metadata from the vault scan', async ({ page }) => { + const errors = collectMissingMetadataCrashes(page) + const noteList = page.getByTestId('note-list-container') + + await noteList.getByText('alpha-project', { exact: true }).click() + await expect(page.getByRole('heading', { name: 'Alpha Project', level: 1 })).toBeVisible({ timeout: 5_000 }) + + await noteList.getByText('Note B', { exact: true }).click() + await noteList.getByText('alpha-project', { exact: true }).click() + await expect(page.getByRole('heading', { name: 'Alpha Project', level: 1 })).toBeVisible({ timeout: 5_000 }) + + expect(errors).toHaveLength(0) +})