Files
aryx/tests/main/appServiceGitRefresh.test.ts
T

371 lines
11 KiB
TypeScript

import { describe, expect, mock, test } from 'bun:test';
import type { RunTurnCommand } from '@shared/contracts/sidecar';
import type { PatternDefinition } from '@shared/domain/pattern';
import type {
ProjectGitRunChangeSummary,
ProjectGitWorkingTreeSnapshot,
ProjectRecord,
} from '@shared/domain/project';
import { SCRATCHPAD_PROJECT_ID } from '@shared/domain/project';
import type { SessionRecord } from '@shared/domain/session';
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
const TIMESTAMP = '2026-03-31T00:00:00.000Z';
mock.module('electron', () => {
const electronMock = {
app: {
isPackaged: false,
getAppPath: () => 'C:\\workspace\\personal\\repositories\\aryx',
getPath: () => 'C:\\workspace\\personal\\repositories\\aryx\\tests\\fixtures',
},
dialog: {
showOpenDialog: async () => ({ canceled: true, filePaths: [] }),
},
shell: {
openPath: async () => '',
},
};
return {
...electronMock,
default: electronMock,
};
});
mock.module('keytar', () => ({
default: {
getPassword: async () => null,
setPassword: async () => undefined,
deletePassword: async () => false,
},
}));
const { AryxAppService } = await import('@main/AryxAppService');
function createProject(overrides?: Partial<ProjectRecord>): ProjectRecord {
return {
id: 'project-alpha',
name: 'alpha',
path: 'C:\\workspace\\alpha',
addedAt: TIMESTAMP,
git: {
status: 'ready',
scannedAt: TIMESTAMP,
repoRoot: 'C:\\workspace\\alpha',
branch: 'main',
isDirty: false,
changedFileCount: 0,
changes: {
staged: 0,
unstaged: 0,
untracked: 0,
conflicted: 0,
},
},
...overrides,
};
}
function createSession(projectId: string, patternId: string, overrides?: Partial<SessionRecord>): SessionRecord {
return {
id: 'session-alpha',
projectId,
patternId,
title: 'Alpha session',
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
status: 'idle',
messages: [],
runs: [],
...overrides,
};
}
function createFixture(overrides?: {
project?: Partial<ProjectRecord>;
session?: Partial<SessionRecord>;
}): {
workspace: WorkspaceState;
pattern: PatternDefinition;
project: ProjectRecord;
session: SessionRecord;
} {
const workspace = createWorkspaceSeed();
const pattern = workspace.patterns.find((candidate) => candidate.mode === 'single');
if (!pattern) {
throw new Error('Expected the workspace seed to include a single-agent pattern.');
}
const project = createProject(overrides?.project);
const session = createSession(project.id, pattern.id, overrides?.session);
workspace.projects = [project];
workspace.sessions = [session];
workspace.selectedProjectId = project.id;
workspace.selectedPatternId = pattern.id;
workspace.selectedSessionId = session.id;
return { workspace, pattern, project, session };
}
function createSnapshot(): ProjectGitWorkingTreeSnapshot {
return {
scannedAt: TIMESTAMP,
repoRoot: 'C:\\workspace\\alpha',
branch: 'main',
changedFileCount: 2,
changes: {
staged: 1,
unstaged: 1,
untracked: 0,
conflicted: 0,
},
files: [
{
path: 'src\\auth.ts',
stagedStatus: 'modified',
},
{
path: 'tests\\auth.test.ts',
unstagedStatus: 'modified',
},
],
};
}
function createRunSummary(): ProjectGitRunChangeSummary {
return {
generatedAt: TIMESTAMP,
branchAtStart: 'main',
branchAtEnd: 'main',
fileCount: 1,
additions: 4,
deletions: 1,
counts: {
added: 1,
modified: 0,
deleted: 0,
renamed: 0,
copied: 0,
typeChanged: 0,
unmerged: 0,
untracked: 0,
cleaned: 0,
},
files: [
{
path: 'src\\generated.ts',
kind: 'added',
origin: 'run-created',
additions: 4,
deletions: 1,
canRevert: true,
preview: {
path: 'src\\generated.ts',
diff: '@@ -0,0 +1,4 @@\n+export const generated = true;\n',
},
},
],
};
}
function createService(
workspace: WorkspaceState,
pattern: PatternDefinition,
options?: {
snapshot?: ProjectGitWorkingTreeSnapshot;
runSummary?: ProjectGitRunChangeSummary;
onCaptureSnapshot?: (projectPath: string, scannedAt: string) => void;
onComputeRunSummary?: (projectPath: string) => void;
onScheduleRefresh?: (projectId?: string) => void;
runTurn?: (command: RunTurnCommand) => Promise<[]>;
},
): InstanceType<typeof AryxAppService> {
const service = new AryxAppService();
const internals = service as unknown as Record<string, unknown>;
internals.loadWorkspace = async () => {
internals.workspace = workspace;
return workspace;
};
internals.persistAndBroadcast = async (nextWorkspace: WorkspaceState) => nextWorkspace;
internals.buildEffectivePattern = async () => pattern;
internals.awaitFinalResponseApproval = async () => undefined;
internals.finalizeTurn = () => undefined;
internals.emitSessionEvent = () => undefined;
internals.pruneUnavailableApprovalTools = async () => false;
internals.pruneUnavailableSessionToolingSelections = () => false;
internals.scheduleProjectGitRefresh = (projectId?: string) => {
options?.onScheduleRefresh?.(projectId);
};
(
service as unknown as {
sidecar: {
runTurn: (command: RunTurnCommand) => Promise<[]>;
resolveApproval: () => Promise<void>;
resolveUserInput: () => Promise<void>;
};
gitService: {
captureWorkingTreeSnapshot: (
projectPath: string,
scannedAt: string,
) => Promise<ProjectGitWorkingTreeSnapshot | undefined>;
captureWorkingTreeBaseline: () => Promise<[]>;
computeRunChangeSummary: (projectPath: string) => Promise<ProjectGitRunChangeSummary | undefined>;
};
}
).sidecar = {
runTurn: async (command) => options?.runTurn ? options.runTurn(command) : [],
resolveApproval: async () => undefined,
resolveUserInput: async () => undefined,
};
(
service as unknown as {
gitService: {
captureWorkingTreeSnapshot: (
projectPath: string,
scannedAt: string,
) => Promise<ProjectGitWorkingTreeSnapshot | undefined>;
captureWorkingTreeBaseline: () => Promise<[]>;
computeRunChangeSummary: (projectPath: string) => Promise<ProjectGitRunChangeSummary | undefined>;
};
}
).gitService = {
captureWorkingTreeSnapshot: async (projectPath, scannedAt) => {
options?.onCaptureSnapshot?.(projectPath, scannedAt);
return options?.snapshot;
},
captureWorkingTreeBaseline: async () => [],
computeRunChangeSummary: async (projectPath) => {
options?.onComputeRunSummary?.(projectPath);
return options?.runSummary;
},
};
return service;
}
describe('AryxAppService git refresh integration', () => {
test('sendSessionMessage stores a pre-run git snapshot on the created run', async () => {
const { workspace, pattern, session } = createFixture();
const snapshot = createSnapshot();
const capturedProjectPaths: string[] = [];
const service = createService(workspace, pattern, {
snapshot,
onCaptureSnapshot: (projectPath) => {
capturedProjectPaths.push(projectPath);
},
});
await service.sendSessionMessage(session.id, 'Implement auth hardening.');
expect(capturedProjectPaths).toEqual(['C:\\workspace\\alpha']);
expect(workspace.sessions[0]?.runs[0]?.preRunGitSnapshot).toEqual(snapshot);
});
test('sendSessionMessage schedules a git refresh after a successful project turn', async () => {
const { workspace, pattern, project, session } = createFixture();
const scheduledProjectIds: Array<string | undefined> = [];
const service = createService(workspace, pattern, {
snapshot: createSnapshot(),
onScheduleRefresh: (projectId) => {
scheduledProjectIds.push(projectId);
},
});
await service.sendSessionMessage(session.id, 'Implement auth hardening.');
expect(scheduledProjectIds).toEqual([project.id]);
});
test('sendSessionMessage stores a post-run git summary on the completed run', async () => {
const { workspace, pattern, session, project } = createFixture();
const computedProjectPaths: string[] = [];
const service = createService(workspace, pattern, {
snapshot: createSnapshot(),
runSummary: createRunSummary(),
onComputeRunSummary: (projectPath) => {
computedProjectPaths.push(projectPath);
},
});
await service.sendSessionMessage(session.id, 'Implement auth hardening.');
expect(computedProjectPaths).toEqual([project.path]);
expect(workspace.sessions[0]?.runs[0]?.postRunGitSummary).toEqual(createRunSummary());
});
test('sendSessionMessage schedules a git refresh after a failed project turn', async () => {
const { workspace, pattern, project, session } = createFixture();
const scheduledProjectIds: Array<string | undefined> = [];
const service = createService(workspace, pattern, {
snapshot: createSnapshot(),
onScheduleRefresh: (projectId) => {
scheduledProjectIds.push(projectId);
},
runTurn: async () => {
throw new Error('boom');
},
});
await service.sendSessionMessage(session.id, 'Implement auth hardening.');
expect(scheduledProjectIds).toEqual([project.id]);
expect(session.status).toBe('error');
expect(session.lastError).toBe('boom');
expect(session.runs[0]?.status).toBe('error');
});
test('scratchpad turns skip git snapshot capture and refresh scheduling', async () => {
const { workspace, pattern, session } = createFixture({
project: {
id: SCRATCHPAD_PROJECT_ID,
name: 'Scratchpad',
path: 'C:\\workspace\\scratchpad',
git: undefined,
},
session: {
projectId: SCRATCHPAD_PROJECT_ID,
},
});
let didCaptureSnapshot = false;
const scheduledProjectIds: Array<string | undefined> = [];
const service = createService(workspace, pattern, {
snapshot: createSnapshot(),
onCaptureSnapshot: () => {
didCaptureSnapshot = true;
},
onScheduleRefresh: (projectId) => {
scheduledProjectIds.push(projectId);
},
});
await service.sendSessionMessage(session.id, 'Draft a quick note.');
expect(didCaptureSnapshot).toBe(false);
expect(scheduledProjectIds).toEqual([]);
expect(workspace.sessions[0]?.runs[0]?.preRunGitSnapshot).toBeUndefined();
});
test('setGitAutoRefreshEnabled persists the setting and returns updated workspace', async () => {
const { workspace, pattern } = createFixture();
const service = createService(workspace, pattern);
expect(service.isGitAutoRefreshEnabled()).toBe(true);
const result = await service.setGitAutoRefreshEnabled(false);
expect(result.settings.gitAutoRefreshEnabled).toBe(false);
expect(service.isGitAutoRefreshEnabled()).toBe(false);
});
test('isGitAutoRefreshEnabled defaults to true when setting is undefined', async () => {
const { workspace, pattern } = createFixture();
const service = createService(workspace, pattern);
expect(workspace.settings.gitAutoRefreshEnabled).toBeUndefined();
expect(service.isGitAutoRefreshEnabled()).toBe(true);
});
});