From c3f18df062b618fa137e59d163db718570f3621c Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 14 Apr 2026 21:39:16 +0300 Subject: [PATCH] fix: harden recent project recovery and path matching --- .../ListDashboardRecentProjectsUseCase.ts | 8 +- .../CodexRecentProjectsSourceAdapter.ts | 35 +++++- .../codex/CodexAppServerClient.ts | 106 ++++++++++++++---- .../utils/recentProjectOpenHistory.ts | 104 ++++++++++++----- ...ListDashboardRecentProjectsUseCase.test.ts | 68 ++++++++++- .../CodexRecentProjectsSourceAdapter.test.ts | 102 +++++++++++++++++ .../CodexAppServerClient.test.ts | 44 +++++++- .../utils/recentProjectOpenHistory.test.ts | 32 ++++++ 8 files changed, 443 insertions(+), 56 deletions(-) create mode 100644 test/features/recent-projects/main/adapters/output/CodexRecentProjectsSourceAdapter.test.ts diff --git a/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts b/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts index 5745aacd..fefd56d8 100644 --- a/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts +++ b/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts @@ -64,8 +64,11 @@ export class ListDashboardRecentProjectsUseCase { const successful = results.flatMap((result) => result.candidates); const hasDegradedSources = results.some((result) => result.degraded); + const response: ListDashboardRecentProjectsResponse = { + projects: mergeRecentProjectCandidates(successful), + }; - if (hasDegradedSources && stale) { + if (hasDegradedSources && stale && response.projects.length === 0) { await this.deps.cache.set(cacheKey, stale, this.#degradedCacheTtlMs); this.deps.logger.info('recent-projects served stale cache', { cacheKey, @@ -76,9 +79,6 @@ export class ListDashboardRecentProjectsUseCase { return stale; } - const response: ListDashboardRecentProjectsResponse = { - projects: mergeRecentProjectCandidates(successful), - }; const viewModel = this.deps.output.present(response); const cacheTtlMs = hasDegradedSources ? Math.min(this.#cacheTtlMs, this.#degradedCacheTtlMs) diff --git a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts index dea0542d..b346ab50 100644 --- a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts +++ b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts @@ -17,8 +17,10 @@ const CODEX_LIVE_FETCH_TIMEOUT_MS = 4_500; const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 2_500; const CODEX_SESSION_OVERHEAD_TIMEOUT_MS = 1_500; const CODEX_TOTAL_FETCH_TIMEOUT_MS = - CODEX_LIVE_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_TIMEOUT_MS; + CODEX_LIVE_FETCH_TIMEOUT_MS + CODEX_ARCHIVED_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_TIMEOUT_MS; const CODEX_SOURCE_TIMEOUT_MS = CODEX_TOTAL_FETCH_TIMEOUT_MS + 500; +const CODEX_LIVE_ONLY_FALLBACK_TOTAL_TIMEOUT_MS = + CODEX_LIVE_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_TIMEOUT_MS + 1_500; function isInteractiveSource(source: unknown): boolean { return source === 'vscode' || source === 'cli'; @@ -116,6 +118,37 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor this.deps.logger.warn('codex recent-projects thread list session failed', { error: message, }); + + if (message.toLowerCase().includes('timed out')) { + return { + live: { threads: [], error: message }, + archived: { threads: [], error: message }, + }; + } + + try { + const liveFallback = await this.deps.appServerClient.listRecentLiveThreads(binaryPath, { + limit: CODEX_THREAD_LIMIT, + requestTimeoutMs: CODEX_LIVE_FETCH_TIMEOUT_MS, + totalTimeoutMs: CODEX_LIVE_ONLY_FALLBACK_TOTAL_TIMEOUT_MS, + }); + + this.deps.logger.info('codex recent-projects recovered with live-only fallback', { + liveCount: liveFallback.threads.length, + }); + + return { + live: liveFallback, + archived: { threads: [], error: message }, + }; + } catch (fallbackError) { + const fallbackMessage = + fallbackError instanceof Error ? fallbackError.message : String(fallbackError); + this.deps.logger.warn('codex recent-projects live-only fallback failed', { + error: fallbackMessage, + }); + } + return { live: { threads: [], error: message }, archived: { threads: [], error: message }, diff --git a/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts b/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts index b58b870f..10013c01 100644 --- a/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts +++ b/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts @@ -1,4 +1,4 @@ -import type { JsonRpcStdioClient } from './JsonRpcStdioClient'; +import type { JsonRpcSession, JsonRpcStdioClient } from './JsonRpcStdioClient'; const DEFAULT_REQUEST_TIMEOUT_MS = 3_000; const DEFAULT_TOTAL_TIMEOUT_MS = 8_000; @@ -49,9 +49,55 @@ export interface CodexRecentThreadsResult { archived: CodexThreadSegmentResult; } +interface ThreadListSessionOptions { + binaryPath: string; + requestTimeoutMs: number; + totalTimeoutMs: number; + label: string; +} + export class CodexAppServerClient { constructor(private readonly rpcClient: JsonRpcStdioClient) {} + async listRecentLiveThreads( + binaryPath: string, + options: { + limit: number; + requestTimeoutMs?: number; + totalTimeoutMs?: number; + } + ): Promise { + const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const totalTimeoutMs = Math.max( + options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS, + requestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS + ); + + return this.#withThreadListSession( + { + binaryPath, + requestTimeoutMs, + totalTimeoutMs, + label: 'codex app-server thread/list live', + }, + async (session) => { + const live = await session.request( + 'thread/list', + { + archived: false, + limit: options.limit, + sortKey: 'updated_at', + }, + requestTimeoutMs + ); + + return { + threads: live.data ?? [], + }; + } + ); + } + async listRecentThreads( binaryPath: string, options: { @@ -66,36 +112,17 @@ export class CodexAppServerClient { const sessionRequestTimeoutMs = Math.max(liveRequestTimeoutMs, archivedRequestTimeoutMs); const totalTimeoutMs = Math.max( options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS, - sessionRequestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS + liveRequestTimeoutMs + archivedRequestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS ); - return this.rpcClient.withSession( + return this.#withThreadListSession( { binaryPath, - args: ['app-server'], requestTimeoutMs: sessionRequestTimeoutMs, totalTimeoutMs, label: 'codex app-server thread/list', }, async (session) => { - await session.request( - 'initialize', - { - clientInfo: { - name: 'claude-agent-teams-ui', - title: 'Claude Agent Teams UI', - version: '0.1.0', - }, - capabilities: { - experimentalApi: false, - optOutNotificationMethods: SUPPRESSED_NOTIFICATION_METHODS, - }, - }, - sessionRequestTimeoutMs - ); - - await session.notify('initialized'); - const [live, archived] = await Promise.allSettled([ session.request( 'thread/list', @@ -139,4 +166,39 @@ export class CodexAppServerClient { } ); } + + async #withThreadListSession( + options: ThreadListSessionOptions, + handler: (session: JsonRpcSession) => Promise + ): Promise { + return this.rpcClient.withSession( + { + binaryPath: options.binaryPath, + args: ['app-server'], + requestTimeoutMs: options.requestTimeoutMs, + totalTimeoutMs: options.totalTimeoutMs, + label: options.label, + }, + async (session) => { + await session.request( + 'initialize', + { + clientInfo: { + name: 'claude-agent-teams-ui', + title: 'Claude Agent Teams UI', + version: '0.1.0', + }, + capabilities: { + experimentalApi: false, + optOutNotificationMethods: SUPPRESSED_NOTIFICATION_METHODS, + }, + }, + options.requestTimeoutMs + ); + + await session.notify('initialized'); + return handler(session); + } + ); + } } diff --git a/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts b/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts index e7b8de4f..25e60cab 100644 --- a/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts +++ b/src/features/recent-projects/renderer/utils/recentProjectOpenHistory.ts @@ -1,5 +1,3 @@ -import { normalizePath } from '@renderer/utils/pathNormalize'; - import type { DashboardRecentProject } from '@features/recent-projects/contracts'; const RECENT_PROJECT_OPEN_HISTORY_KEY = 'recent-projects:open-history'; @@ -22,11 +20,20 @@ function canUseLocalStorage(): boolean { } function normalizeHistoryPath(projectPath: string): string | null { - const trimmed = projectPath.trim(); - if (!trimmed) { + let normalizedPath = projectPath.trim().replace(/\\/g, '/'); + if (!normalizedPath) { return null; } - return normalizePath(trimmed); + if (normalizedPath !== '/' && !/^[A-Za-z]:\/$/.test(normalizedPath)) { + while (normalizedPath.endsWith('/')) { + normalizedPath = normalizedPath.slice(0, -1); + } + } + return normalizedPath; +} + +function foldHistoryPath(projectPath: string): string { + return projectPath.toLowerCase(); } function readHistoryState(): RecentProjectOpenHistoryState { @@ -99,16 +106,72 @@ function writeHistoryEntries(entries: readonly RecentProjectOpenHistoryEntry[]): } } -function createHistoryLookup(): Map { - return new Map(readHistoryState().entries.map((entry) => [entry.path, entry.openedAt])); +interface HistoryLookup { + exact: Map; + folded: Map< + string, + { + openedAt: number; + exactPaths: Set; + } + >; } -function getProjectPaths( +function createHistoryLookup(): HistoryLookup { + const exact = new Map(); + const folded = new Map }>(); + + for (const entry of readHistoryState().entries) { + const normalizedPath = normalizeHistoryPath(entry.path); + if (!normalizedPath) { + continue; + } + + exact.set(normalizedPath, Math.max(exact.get(normalizedPath) ?? 0, entry.openedAt)); + + const foldedKey = foldHistoryPath(normalizedPath); + const existingFolded = folded.get(foldedKey); + if (existingFolded) { + existingFolded.openedAt = Math.max(existingFolded.openedAt, entry.openedAt); + existingFolded.exactPaths.add(normalizedPath); + } else { + folded.set(foldedKey, { + openedAt: entry.openedAt, + exactPaths: new Set([normalizedPath]), + }); + } + } + + return { exact, folded }; +} + +function resolveHistoryOpenedAt(lookup: HistoryLookup, projectPath: string): number { + const normalizedPath = normalizeHistoryPath(projectPath); + if (!normalizedPath) { + return 0; + } + + const exactMatch = lookup.exact.get(normalizedPath); + if (exactMatch != null) { + return exactMatch; + } + + const foldedMatch = lookup.folded.get(foldHistoryPath(normalizedPath)); + if (!foldedMatch || foldedMatch.exactPaths.size !== 1) { + return 0; + } + + return foldedMatch.openedAt; +} + +function getProjectLastOpenedAtFromLookup( + lookup: HistoryLookup, project: Pick -): string[] { - return [project.primaryPath, ...project.associatedPaths] - .map((projectPath) => normalizeHistoryPath(projectPath)) - .filter((projectPath): projectPath is string => Boolean(projectPath)); +): number { + return [project.primaryPath, ...project.associatedPaths].reduce( + (latest, projectPath) => Math.max(latest, resolveHistoryOpenedAt(lookup, projectPath)), + 0 + ); } export function recordRecentProjectOpenPaths( @@ -141,10 +204,7 @@ export function getRecentProjectLastOpenedAt( project: Pick ): number { const historyLookup = createHistoryLookup(); - return getProjectPaths(project).reduce( - (latest, projectPath) => Math.max(latest, historyLookup.get(projectPath) ?? 0), - 0 - ); + return getProjectLastOpenedAtFromLookup(historyLookup, project); } export function sortRecentProjectsByDisplayPriority( @@ -153,20 +213,12 @@ export function sortRecentProjectsByDisplayPriority( ): DashboardRecentProject[] { const historyLookup = createHistoryLookup(); - const getLastOpenedAt = ( - project: Pick - ): number => - getProjectPaths(project).reduce( - (latest, projectPath) => Math.max(latest, historyLookup.get(projectPath) ?? 0), - 0 - ); - const isPriorityOpen = (openedAt: number): boolean => openedAt > 0 && now - openedAt <= OPEN_PRIORITY_WINDOW_MS; return [...projects].sort((left, right) => { - const leftOpenedAt = getLastOpenedAt(left); - const rightOpenedAt = getLastOpenedAt(right); + const leftOpenedAt = getProjectLastOpenedAtFromLookup(historyLookup, left); + const rightOpenedAt = getProjectLastOpenedAtFromLookup(historyLookup, right); const leftPriority = isPriorityOpen(leftOpenedAt); const rightPriority = isPriorityOpen(rightOpenedAt); diff --git a/test/features/recent-projects/core/application/ListDashboardRecentProjectsUseCase.test.ts b/test/features/recent-projects/core/application/ListDashboardRecentProjectsUseCase.test.ts index 8ab8963a..14fedfc6 100644 --- a/test/features/recent-projects/core/application/ListDashboardRecentProjectsUseCase.test.ts +++ b/test/features/recent-projects/core/application/ListDashboardRecentProjectsUseCase.test.ts @@ -248,7 +248,7 @@ describe('ListDashboardRecentProjectsUseCase', () => { } }); - it('returns stale cached data when a source degrades after cache expiry', async () => { + it('prefers fresh healthy-source results over stale cache when degraded sources still leave usable projects', async () => { const stale: TestViewModel = { ids: ['repo:stale'], sources: ['mixed'] }; const cache: RecentProjectsCachePort = { get: vi.fn().mockResolvedValue(null), @@ -294,6 +294,72 @@ describe('ListDashboardRecentProjectsUseCase', () => { logger, }); + await expect(useCase.execute('recent-projects:stale')).resolves.toEqual({ + ids: ['repo:fresh'], + sources: ['claude'], + }); + expect(output.present).toHaveBeenCalledWith({ + projects: [ + expect.objectContaining({ + identity: 'repo:fresh', + source: 'claude', + }), + ], + }); + expect(cache.set).toHaveBeenCalledWith( + 'recent-projects:stale', + { ids: ['repo:fresh'], sources: ['claude'] }, + 1_500 + ); + expect(logger.info).toHaveBeenCalledWith('recent-projects loaded', { + cacheKey: 'recent-projects:stale', + count: 1, + degradedSources: 1, + cacheTtlMs: 1_500, + durationMs: 200, + }); + }); + + it('falls back to stale cache only when degraded sources leave no usable fresh projects', async () => { + const stale: TestViewModel = { ids: ['repo:stale'], sources: ['mixed'] }; + const cache: RecentProjectsCachePort = { + get: vi.fn().mockResolvedValue(null), + getStale: vi.fn().mockResolvedValue(stale), + set: vi.fn().mockResolvedValue(undefined), + }; + const output: ListDashboardRecentProjectsOutputPort = { + present: vi.fn((response: ListDashboardRecentProjectsResponse) => ({ + ids: response.projects.map((project) => project.identity), + sources: response.projects.map((project) => project.source), + })), + }; + const sources: RecentProjectsSourcePort[] = [ + { + sourceId: 'claude', + list: vi.fn().mockResolvedValue([]), + }, + { + sourceId: 'codex', + list: vi.fn().mockRejectedValue(new Error('codex unavailable')), + }, + ]; + const logger = createLogger(); + let now = 15_000; + + const useCase = new ListDashboardRecentProjectsUseCase({ + sources, + cache, + output, + clock: { + now: () => { + const current = now; + now += 200; + return current; + }, + }, + logger, + }); + await expect(useCase.execute('recent-projects:stale')).resolves.toEqual(stale); expect(output.present).not.toHaveBeenCalled(); expect(cache.set).toHaveBeenCalledWith('recent-projects:stale', stale, 1_500); diff --git a/test/features/recent-projects/main/adapters/output/CodexRecentProjectsSourceAdapter.test.ts b/test/features/recent-projects/main/adapters/output/CodexRecentProjectsSourceAdapter.test.ts new file mode 100644 index 00000000..89b1c855 --- /dev/null +++ b/test/features/recent-projects/main/adapters/output/CodexRecentProjectsSourceAdapter.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { CodexRecentProjectsSourceAdapter } from '@features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter'; + +import type { LoggerPort } from '@features/recent-projects/core/application/ports/LoggerPort'; +import type { CodexAppServerClient } from '@features/recent-projects/main/infrastructure/codex/CodexAppServerClient'; +import type { RecentProjectIdentityResolver } from '@features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver'; + +function createLogger(): LoggerPort & { + info: ReturnType; + warn: ReturnType; + error: ReturnType; +} { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; +} + +describe('CodexRecentProjectsSourceAdapter', () => { + it('falls back to live-only threads when the full app-server session fails fast', async () => { + const logger = createLogger(); + const appServerClient = { + listRecentThreads: vi + .fn() + .mockRejectedValue(new Error('JSON-RPC process exited unexpectedly (code=1 signal=null)')), + listRecentLiveThreads: vi.fn().mockResolvedValue({ + threads: [ + { + id: 'thread-live', + cwd: '/Users/belief/dev/projects/headless', + source: 'cli', + updatedAt: 1_700_000_000, + gitInfo: { branch: 'main' }, + }, + ], + }), + } as unknown as CodexAppServerClient; + const identityResolver = { + resolve: vi.fn().mockResolvedValue({ + id: 'repo:headless', + name: 'headless', + }), + } as unknown as RecentProjectIdentityResolver; + + const adapter = new CodexRecentProjectsSourceAdapter({ + getActiveContext: () => ({ type: 'local', id: 'local-1' }) as never, + getLocalContext: () => ({ type: 'local', id: 'local-1' }) as never, + resolveBinary: vi.fn().mockResolvedValue('/usr/local/bin/codex'), + appServerClient, + identityResolver, + logger, + }); + + await expect(adapter.list()).resolves.toEqual([ + expect.objectContaining({ + identity: 'repo:headless', + displayName: 'headless', + primaryPath: '/Users/belief/dev/projects/headless', + providerIds: ['codex'], + sourceKind: 'codex', + openTarget: { + type: 'synthetic-path', + path: '/Users/belief/dev/projects/headless', + }, + branchName: 'main', + }), + ]); + + expect(appServerClient.listRecentThreads).toHaveBeenCalledTimes(1); + expect(appServerClient.listRecentLiveThreads).toHaveBeenCalledTimes(1); + expect(logger.info).toHaveBeenCalledWith('codex recent-projects recovered with live-only fallback', { + liveCount: 1, + }); + }); + + it('does not spend extra time on live-only fallback after a full session timeout', async () => { + const logger = createLogger(); + const appServerClient = { + listRecentThreads: vi + .fn() + .mockRejectedValue(new Error('codex app-server thread/list timed out after 8500ms')), + listRecentLiveThreads: vi.fn(), + } as unknown as CodexAppServerClient; + const identityResolver = { + resolve: vi.fn(), + } as unknown as RecentProjectIdentityResolver; + + const adapter = new CodexRecentProjectsSourceAdapter({ + getActiveContext: () => ({ type: 'local', id: 'local-1' }) as never, + getLocalContext: () => ({ type: 'local', id: 'local-1' }) as never, + resolveBinary: vi.fn().mockResolvedValue('/usr/local/bin/codex'), + appServerClient, + identityResolver, + logger, + }); + + await expect(adapter.list()).resolves.toEqual([]); + expect(appServerClient.listRecentLiveThreads).not.toHaveBeenCalled(); + }); +}); diff --git a/test/features/recent-projects/main/infrastructure/CodexAppServerClient.test.ts b/test/features/recent-projects/main/infrastructure/CodexAppServerClient.test.ts index 76b39c08..549c8845 100644 --- a/test/features/recent-projects/main/infrastructure/CodexAppServerClient.test.ts +++ b/test/features/recent-projects/main/infrastructure/CodexAppServerClient.test.ts @@ -53,7 +53,7 @@ describe('CodexAppServerClient', () => { expect.objectContaining({ binaryPath: '/usr/local/bin/codex', requestTimeoutMs: 4500, - totalTimeoutMs: 6000, + totalTimeoutMs: 8500, }), expect.any(Function) ); @@ -135,9 +135,49 @@ describe('CodexAppServerClient', () => { expect(withSession).toHaveBeenCalledWith( expect.objectContaining({ - totalTimeoutMs: 6000, + totalTimeoutMs: 8500, }), expect.any(Function) ); }); + + it('can load only live threads in a dedicated fallback session', async () => { + const session = createSession( + vi.fn().mockImplementation((method: string, params?: { archived?: boolean }) => { + if (method === 'initialize') { + return Promise.resolve({}); + } + + if (method === 'thread/list' && params?.archived === false) { + return Promise.resolve({ + data: [{ id: 'live-1', cwd: '/Users/test/live-project', source: 'cli' }], + }); + } + + return Promise.reject(new Error(`Unexpected method: ${method}`)); + }) + ); + + const withSession = vi.fn().mockImplementation((_options, handler) => handler(session)); + const client = new CodexAppServerClient({ withSession } as unknown as JsonRpcStdioClient); + + const result = await client.listRecentLiveThreads('/usr/local/bin/codex', { + limit: 40, + requestTimeoutMs: 4500, + totalTimeoutMs: 6000, + }); + + expect(withSession).toHaveBeenCalledWith( + expect.objectContaining({ + binaryPath: '/usr/local/bin/codex', + requestTimeoutMs: 4500, + totalTimeoutMs: 6000, + label: 'codex app-server thread/list live', + }), + expect.any(Function) + ); + expect(result).toEqual({ + threads: [{ id: 'live-1', cwd: '/Users/test/live-project', source: 'cli' }], + }); + }); }); diff --git a/test/features/recent-projects/renderer/utils/recentProjectOpenHistory.test.ts b/test/features/recent-projects/renderer/utils/recentProjectOpenHistory.test.ts index 12df0fec..12b489e8 100644 --- a/test/features/recent-projects/renderer/utils/recentProjectOpenHistory.test.ts +++ b/test/features/recent-projects/renderer/utils/recentProjectOpenHistory.test.ts @@ -98,4 +98,36 @@ describe('recentProjectOpenHistory', () => { ).map((project) => project.id) ).toEqual(['repo:active', 'repo:opened']); }); + + it('does not collapse distinct case-variant paths when history contains ambiguous entries', () => { + recordRecentProjectOpenPaths(['/Work/Repo'], 5_000); + recordRecentProjectOpenPaths(['/work/repo'], 8_000); + + expect( + getRecentProjectLastOpenedAt( + makeProject({ + primaryPath: '/Work/Repo', + associatedPaths: ['/Work/Repo'], + }) + ) + ).toBe(5_000); + + expect( + getRecentProjectLastOpenedAt( + makeProject({ + primaryPath: '/work/repo', + associatedPaths: ['/work/repo'], + }) + ) + ).toBe(8_000); + + expect( + getRecentProjectLastOpenedAt( + makeProject({ + primaryPath: '/WORK/repo', + associatedPaths: ['/WORK/repo'], + }) + ) + ).toBe(0); + }); });