agent-ecosystem/test/main/services/team/FileContentResolver.test.ts

372 lines
13 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { SnippetDiff } from '@shared/types';
vi.mock('fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs/promises')>();
const access = vi.fn();
const readFile = vi.fn();
return {
...actual,
access,
readFile,
// ESM interop: some code paths expect a default export
default: { ...actual, access, readFile },
};
});
describe('FileContentResolver', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
vi.useRealTimers();
});
it('treats empty on-disk content as valid for write-new reconstruction', async () => {
const fsPromises = await import('fs/promises');
const readFile = fsPromises.readFile as unknown as ReturnType<typeof vi.fn>;
readFile.mockResolvedValue('');
const { FileContentResolver } = await import('@main/services/team/FileContentResolver');
const logsFinder = {
findMemberLogPaths: vi.fn().mockResolvedValue([]),
};
const resolver = new FileContentResolver(logsFinder as any);
const snippets: SnippetDiff[] = [
{
toolUseId: 't1',
filePath: '/tmp/empty-new.txt',
toolName: 'Write',
type: 'write-new',
oldString: '',
newString: '',
replaceAll: false,
timestamp: new Date().toISOString(),
isError: false,
},
];
const content = await resolver.getFileContent('team', 'member', '/tmp/empty-new.txt', snippets);
expect(content.isNewFile).toBe(true);
expect(content.originalFullContent).toBe('');
expect(content.modifiedFullContent).toBe('');
expect(content.contentSource).toBe('snippet-reconstruction');
});
it('maps ledger create original content to empty string without disk reconstruction', async () => {
const { FileContentResolver } = await import('@main/services/team/FileContentResolver');
const resolver = new FileContentResolver({ findMemberLogPaths: vi.fn() } as any);
const content = await resolver.getFileContent('team', 'member', '/tmp/ledger-create.txt', [
{
toolUseId: 'ledger-1',
filePath: '/tmp/ledger-create.txt',
toolName: 'Bash',
type: 'shell-snapshot',
oldString: '',
newString: 'created\n',
replaceAll: false,
timestamp: '2026-03-01T10:00:00.000Z',
isError: false,
ledger: {
eventId: 'event-1',
source: 'ledger-snapshot',
confidence: 'high',
originalFullContent: null,
modifiedFullContent: 'created\n',
beforeHash: null,
afterHash: 'hash',
operation: 'create',
beforeState: { exists: false },
afterState: { exists: true, sha256: 'hash' },
},
},
]);
expect(content.originalFullContent).toBe('');
expect(content.modifiedFullContent).toBe('created\n');
expect(content.contentSource).toBe('ledger-snapshot');
});
it('maps ledger delete modified content to empty string for diff display', async () => {
const { FileContentResolver } = await import('@main/services/team/FileContentResolver');
const resolver = new FileContentResolver({ findMemberLogPaths: vi.fn() } as any);
const content = await resolver.getFileContent('team', 'member', '/tmp/ledger-delete.txt', [
{
toolUseId: 'ledger-1',
filePath: '/tmp/ledger-delete.txt',
toolName: 'Bash',
type: 'shell-snapshot',
oldString: 'deleted\n',
newString: '',
replaceAll: false,
timestamp: '2026-03-01T10:00:00.000Z',
isError: false,
ledger: {
eventId: 'event-1',
source: 'ledger-snapshot',
confidence: 'high',
originalFullContent: 'deleted\n',
modifiedFullContent: null,
beforeHash: 'hash',
afterHash: null,
operation: 'delete',
beforeState: { exists: true, sha256: 'hash' },
afterState: { exists: false },
},
},
]);
expect(content.originalFullContent).toBe('deleted\n');
expect(content.modifiedFullContent).toBe('');
expect(content.contentSource).toBe('ledger-snapshot');
});
it('does not synthesize empty text for metadata-only ledger lifecycle states', async () => {
const fsPromises = await import('fs/promises');
const readFile = fsPromises.readFile as unknown as ReturnType<typeof vi.fn>;
readFile.mockResolvedValue('current disk content that must not become ledger text');
const { FileContentResolver } = await import('@main/services/team/FileContentResolver');
const resolver = new FileContentResolver({ findMemberLogPaths: vi.fn().mockResolvedValue([]) } as any);
const content = await resolver.getFileContent('team', 'member', '/tmp/binary-create.bin', [
{
toolUseId: 'ledger-1',
filePath: '/tmp/binary-create.bin',
toolName: 'Bash',
type: 'shell-snapshot',
oldString: '',
newString: '',
replaceAll: false,
timestamp: '2026-03-01T10:00:00.000Z',
isError: false,
ledger: {
eventId: 'event-1',
source: 'ledger-snapshot',
confidence: 'high',
originalFullContent: null,
modifiedFullContent: null,
beforeHash: null,
afterHash: 'hash',
operation: 'create',
beforeState: { exists: false, unavailableReason: 'binary file' },
afterState: { exists: true, sha256: 'hash', unavailableReason: 'binary file' },
},
},
]);
expect(content.originalFullContent).toBeNull();
expect(content.modifiedFullContent).toBeNull();
expect(content.contentSource).toBe('unavailable');
expect(readFile).not.toHaveBeenCalled();
});
it('reuses cached content only when disk bytes and snippets are unchanged', async () => {
const fsPromises = await import('fs/promises');
const readFile = fsPromises.readFile as unknown as ReturnType<typeof vi.fn>;
readFile.mockResolvedValue('alpha');
const { FileContentResolver } = await import('@main/services/team/FileContentResolver');
const logsFinder = {
findMemberLogPaths: vi.fn().mockResolvedValue([]),
};
const resolver = new FileContentResolver(logsFinder as any);
await resolver.resolveFileContent('team', 'member', '/tmp/cache-hit.txt', []);
await resolver.resolveFileContent('team', 'member', '/tmp/cache-hit.txt', []);
expect(logsFinder.findMemberLogPaths).toHaveBeenCalledTimes(1);
});
it('misses cache when disk content changes even if snippets stay the same', async () => {
const fsPromises = await import('fs/promises');
const readFile = fsPromises.readFile as unknown as ReturnType<typeof vi.fn>;
readFile.mockResolvedValueOnce('alpha').mockResolvedValueOnce('beta');
const { FileContentResolver } = await import('@main/services/team/FileContentResolver');
const logsFinder = {
findMemberLogPaths: vi.fn().mockResolvedValue([]),
};
const resolver = new FileContentResolver(logsFinder as any);
await resolver.resolveFileContent('team', 'member', '/tmp/disk-change.txt', []);
await resolver.resolveFileContent('team', 'member', '/tmp/disk-change.txt', []);
expect(logsFinder.findMemberLogPaths).toHaveBeenCalledTimes(2);
});
it('misses cache when snippet fingerprint changes even if disk bytes stay the same', async () => {
const fsPromises = await import('fs/promises');
const readFile = fsPromises.readFile as unknown as ReturnType<typeof vi.fn>;
readFile.mockResolvedValue('alpha');
const { FileContentResolver } = await import('@main/services/team/FileContentResolver');
const logsFinder = {
findMemberLogPaths: vi.fn().mockResolvedValue([]),
};
const resolver = new FileContentResolver(logsFinder as any);
const firstSnippets: SnippetDiff[] = [];
const secondSnippets: SnippetDiff[] = [
{
toolUseId: 't-edit',
filePath: '/tmp/snippet-change.txt',
toolName: 'Edit',
type: 'edit',
oldString: 'before',
newString: 'after',
replaceAll: false,
timestamp: '2026-03-01T10:00:00.000Z',
isError: false,
},
];
await resolver.resolveFileContent('team', 'member', '/tmp/snippet-change.txt', firstSnippets);
await resolver.resolveFileContent('team', 'member', '/tmp/snippet-change.txt', secondSnippets);
expect(logsFinder.findMemberLogPaths).toHaveBeenCalledTimes(2);
});
it('misses cache when snippet order changes even if snippet content stays the same', async () => {
const fsPromises = await import('fs/promises');
const readFile = fsPromises.readFile as unknown as ReturnType<typeof vi.fn>;
readFile.mockResolvedValue('alpha');
const { FileContentResolver } = await import('@main/services/team/FileContentResolver');
const logsFinder = {
findMemberLogPaths: vi.fn().mockResolvedValue([]),
};
const resolver = new FileContentResolver(logsFinder as any);
const firstSnippets: SnippetDiff[] = [
{
toolUseId: 't-1',
filePath: '/tmp/snippet-order.txt',
toolName: 'Edit',
type: 'edit',
oldString: 'a',
newString: 'b',
replaceAll: false,
timestamp: '2026-03-01T10:00:00.000Z',
isError: false,
},
{
toolUseId: 't-2',
filePath: '/tmp/snippet-order.txt',
toolName: 'Edit',
type: 'edit',
oldString: 'c',
newString: 'd',
replaceAll: false,
timestamp: '2026-03-01T10:01:00.000Z',
isError: false,
},
];
const reversedSnippets = [...firstSnippets].reverse();
await resolver.resolveFileContent('team', 'member', '/tmp/snippet-order.txt', firstSnippets);
await resolver.resolveFileContent('team', 'member', '/tmp/snippet-order.txt', reversedSnippets);
expect(logsFinder.findMemberLogPaths).toHaveBeenCalledTimes(2);
});
it('distinguishes missing-file fingerprints from empty-file fingerprints', async () => {
const fsPromises = await import('fs/promises');
const readFile = fsPromises.readFile as unknown as ReturnType<typeof vi.fn>;
readFile.mockRejectedValueOnce(new Error('ENOENT')).mockResolvedValueOnce('');
const { FileContentResolver } = await import('@main/services/team/FileContentResolver');
const logsFinder = {
findMemberLogPaths: vi.fn().mockResolvedValue([]),
};
const resolver = new FileContentResolver(logsFinder as any);
const missing = await resolver.resolveFileContent(
'team',
'member',
'/tmp/missing-vs-empty.txt',
[]
);
const empty = await resolver.resolveFileContent(
'team',
'member',
'/tmp/missing-vs-empty.txt',
[]
);
expect(missing.source).toBe('unavailable');
expect(empty.source).toBe('disk-current');
expect(logsFinder.findMemberLogPaths).toHaveBeenCalledTimes(2);
});
it('uses the same provisional TTL for all content sources in this pass', async () => {
const { FileContentResolver } = await import('@main/services/team/FileContentResolver');
const resolver = new FileContentResolver({ findMemberLogPaths: vi.fn() } as any);
const getCacheTtlForSource = (
resolver as unknown as {
getCacheTtlForSource: (source: string) => number;
}
).getCacheTtlForSource.bind(resolver);
expect(getCacheTtlForSource('file-history')).toBe(5_000);
expect(getCacheTtlForSource('snippet-reconstruction')).toBe(5_000);
expect(getCacheTtlForSource('git-fallback')).toBe(5_000);
expect(getCacheTtlForSource('disk-current')).toBe(5_000);
expect(getCacheTtlForSource('unavailable')).toBe(5_000);
});
it('expires provisional cache entries after the short TTL window', async () => {
vi.useFakeTimers();
const fsPromises = await import('fs/promises');
const readFile = fsPromises.readFile as unknown as ReturnType<typeof vi.fn>;
readFile.mockResolvedValue('alpha');
const { FileContentResolver } = await import('@main/services/team/FileContentResolver');
const logsFinder = {
findMemberLogPaths: vi.fn().mockResolvedValue([]),
};
const resolver = new FileContentResolver(logsFinder as any);
await resolver.resolveFileContent('team', 'member', '/tmp/ttl-expiry.txt', []);
vi.advanceTimersByTime(5_001);
await resolver.resolveFileContent('team', 'member', '/tmp/ttl-expiry.txt', []);
expect(logsFinder.findMemberLogPaths).toHaveBeenCalledTimes(2);
});
it('invalidates cached Windows content across slash and case path variants', async () => {
const fsPromises = await import('fs/promises');
const readFile = fsPromises.readFile as unknown as ReturnType<typeof vi.fn>;
readFile.mockResolvedValue('same content');
const { FileContentResolver } = await import('@main/services/team/FileContentResolver');
const logsFinder = {
findMemberLogPaths: vi.fn().mockResolvedValue([]),
};
const resolver = new FileContentResolver(logsFinder as any);
await resolver.resolveFileContent('team', 'member', 'C:\\Repo\\SRC\\file.ts', []);
resolver.invalidateFile('c:/repo/src/file.ts');
await resolver.resolveFileContent('team', 'member', 'C:\\Repo\\SRC\\file.ts', []);
expect(logsFinder.findMemberLogPaths).toHaveBeenCalledTimes(2);
});
});