agent-ecosystem/test/features/recent-projects/main/adapters/output/CodexRecentProjectsSourceAdapter.test.ts

212 lines
6.9 KiB
TypeScript

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<typeof vi.fn>;
warn: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
} {
return {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
}
describe('CodexRecentProjectsSourceAdapter', () => {
it('treats archived-only timeout as non-blocking degradation when live threads loaded', async () => {
const logger = createLogger();
const appServerClient = {
listRecentThreads: vi.fn().mockResolvedValue({
live: {
threads: [
{
id: 'thread-live',
cwd: '/Users/belief/dev/projects/headless',
source: 'cli',
updatedAt: 1_700_000_000,
gitInfo: { branch: 'main' },
},
],
},
archived: {
threads: [],
error: 'JSON-RPC request timed out: thread/list',
},
}),
listRecentLiveThreads: vi.fn(),
} 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({
candidates: [
expect.objectContaining({
identity: 'repo:headless',
primaryPath: '/Users/belief/dev/projects/headless',
}),
],
degraded: true,
});
expect(logger.info).toHaveBeenCalledWith(
'codex recent-projects archived thread list degraded',
{
error: 'JSON-RPC request timed out: thread/list',
}
);
expect(logger.warn).not.toHaveBeenCalledWith('codex recent-projects thread list failed', {
segment: 'archived',
error: 'JSON-RPC request timed out: thread/list',
});
});
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({
candidates: [
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',
}),
],
degraded: true,
});
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({
candidates: [],
degraded: true,
});
expect(appServerClient.listRecentLiveThreads).not.toHaveBeenCalled();
});
it('drops Codex appstyle temp workspaces from dashboard candidates', async () => {
const logger = createLogger();
const appServerClient = {
listRecentThreads: vi.fn().mockResolvedValue({
live: {
threads: [
{
id: 'thread-temp',
cwd: '/private/var/folders/7b/cache/T/codex-agent-teams-appstyle-zudek6i9',
source: 'cli',
updatedAt: 1_700_000_000,
},
],
},
archived: {
threads: [],
},
}),
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({
candidates: [],
degraded: false,
});
expect(identityResolver.resolve).not.toHaveBeenCalled();
});
});