* Support file uploads to agents * Harden prompt file upload handling * Fix duplicate upload retry handling * Update relay reconnect tests for queued messages * Make upload timeout track idle progress * Clean failed upload directories
3803 lines
120 KiB
TypeScript
3803 lines
120 KiB
TypeScript
import { execSync } from "child_process";
|
|
import { EventEmitter } from "events";
|
|
import { mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "fs";
|
|
import { homedir, tmpdir } from "os";
|
|
import { join, resolve as resolvePath } from "path";
|
|
import pino from "pino";
|
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
|
|
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
|
|
import {
|
|
decodeFileTransferFrame,
|
|
FileTransferOpcode,
|
|
} from "@getpaseo/protocol/binary-frames/index";
|
|
import { Session } from "./session.js";
|
|
import { StructuredAgentFallbackError } from "./agent/agent-response-loop.js";
|
|
import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
|
|
import type { SessionOptions } from "./session.js";
|
|
import type {
|
|
SpeechToTextProvider,
|
|
StreamingTranscriptionCommittedEvent,
|
|
StreamingTranscriptionEvent,
|
|
StreamingTranscriptionSession,
|
|
} from "./speech/speech-provider.js";
|
|
import type {
|
|
TurnDetectionProvider,
|
|
TurnDetectionSession,
|
|
} from "./speech/turn-detection-provider.js";
|
|
import {
|
|
asSessionInternals as asSessionInternalsHelper,
|
|
asAgentManager,
|
|
asAgentStorage,
|
|
asDownloadTokenStore,
|
|
asPushTokenStore,
|
|
asChatService,
|
|
asScheduleService,
|
|
asLoopService,
|
|
asCheckoutDiffManager,
|
|
asGitHubService,
|
|
asWorkspaceGitService,
|
|
asDaemonConfigStore,
|
|
createProviderSnapshotManagerStub,
|
|
} from "./test-utils/session-stubs.js";
|
|
import { isPlatform } from "../test-utils/platform.js";
|
|
import type { GitHubPullRequestStatusFacts } from "../services/github-service.js";
|
|
|
|
interface SessionHandlerInternals {
|
|
startVoiceTurnController(): Promise<void>;
|
|
stopVoiceTurnController(): Promise<void>;
|
|
handleSendAgentMessage(
|
|
agentId: string,
|
|
text: string,
|
|
messageId?: string,
|
|
images?: Array<{ data: string; mimeType: string }>,
|
|
attachments?: unknown[],
|
|
runOptions?: unknown,
|
|
options?: { spokenInput?: boolean },
|
|
): Promise<{ ok: true } | { ok: false; error: string }>;
|
|
handleCheckoutMergeRequest(params: unknown): Promise<unknown>;
|
|
handleCheckoutMergeFromBaseRequest(params: unknown): Promise<unknown>;
|
|
handleCheckoutCommitRequest(params: unknown): Promise<unknown>;
|
|
handleCheckoutPrCreateRequest(params: unknown): Promise<unknown>;
|
|
handleCheckoutPrMergeRequest(params: unknown): Promise<unknown>;
|
|
handleCheckoutGithubSetAutoMergeRequest(params: unknown): Promise<unknown>;
|
|
handleCheckoutPullRequest(params: unknown): Promise<unknown>;
|
|
handleCheckoutPushRequest(params: unknown): Promise<unknown>;
|
|
handleCheckoutRefreshRequest(params: unknown): Promise<unknown>;
|
|
handleCheckoutStatusRequest(params: unknown): Promise<unknown>;
|
|
describeWorkspaceRecord(...args: unknown[]): Promise<WorkspaceDescriptorPayload>;
|
|
describeWorkspaceRecordWithGitData(...args: unknown[]): Promise<WorkspaceDescriptorPayload>;
|
|
handleValidateBranchRequest(params: unknown): Promise<unknown>;
|
|
createBranchFromBase(params: unknown): Promise<unknown>;
|
|
handleCheckoutSwitchBranchRequest(params: unknown): Promise<unknown>;
|
|
handleBranchSuggestionsRequest(params: unknown): Promise<unknown>;
|
|
handleStashListRequest(params: unknown): Promise<unknown>;
|
|
handleStashSaveRequest(params: unknown): Promise<unknown>;
|
|
handleStashPopRequest(params: unknown): Promise<unknown>;
|
|
createPaseoWorktree(params: unknown): Promise<unknown>;
|
|
handleStartWorkspaceScriptRequest(params: unknown): Promise<unknown>;
|
|
sttManager: {
|
|
transcribe(audio: Buffer, format: string): Promise<unknown>;
|
|
};
|
|
}
|
|
|
|
function asSessionInternals(session: Session): SessionHandlerInternals {
|
|
return asSessionInternalsHelper<SessionHandlerInternals>(session);
|
|
}
|
|
|
|
function createBinaryMessageHandler(
|
|
binaryMessages: Uint8Array[] | undefined,
|
|
): ((frame: Uint8Array) => void) | undefined {
|
|
if (!binaryMessages) {
|
|
return undefined;
|
|
}
|
|
return (frame) => {
|
|
binaryMessages.push(frame);
|
|
};
|
|
}
|
|
|
|
const checkoutGitMocks = vi.hoisted(() => ({
|
|
checkoutResolvedBranch: vi.fn(),
|
|
commitChanges: vi.fn(),
|
|
createPullRequest: vi.fn(),
|
|
getCachedCheckoutShortstat: vi.fn(),
|
|
getCheckoutStatus: vi.fn(),
|
|
listBranchSuggestions: vi.fn(),
|
|
mergeFromBase: vi.fn(),
|
|
mergeToBase: vi.fn(),
|
|
pullCurrentBranch: vi.fn(),
|
|
pushCurrentBranch: vi.fn(),
|
|
renameCurrentBranch: vi.fn(),
|
|
resolveBranchCheckout: vi.fn(),
|
|
warmCheckoutShortstatInBackground: vi.fn(),
|
|
}));
|
|
|
|
const agentResponseMocks = vi.hoisted(() => ({
|
|
generateStructuredAgentResponseWithFallback: vi.fn(),
|
|
}));
|
|
|
|
const agentMetadataMocks = vi.hoisted(() => ({
|
|
scheduleAgentMetadataGeneration: vi.fn(),
|
|
}));
|
|
|
|
const spawnMocks = vi.hoisted(() => ({
|
|
execCommand: vi.fn(),
|
|
spawnWorkspaceScript: vi.fn(),
|
|
}));
|
|
|
|
const paseoWorktreeServiceMocks = vi.hoisted(() => ({
|
|
createPaseoWorktree: vi.fn(),
|
|
}));
|
|
|
|
interface Deferred<T> {
|
|
promise: Promise<T>;
|
|
resolve: (value: T) => void;
|
|
reject: (reason?: unknown) => void;
|
|
}
|
|
|
|
function deferred<T>(): Deferred<T> {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (reason?: unknown) => void;
|
|
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
|
resolve = resolvePromise;
|
|
reject = rejectPromise;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
vi.mock("../utils/checkout-git.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../utils/checkout-git.js")>();
|
|
return {
|
|
...actual,
|
|
checkoutResolvedBranch: checkoutGitMocks.checkoutResolvedBranch,
|
|
commitChanges: checkoutGitMocks.commitChanges,
|
|
createPullRequest: checkoutGitMocks.createPullRequest,
|
|
getCachedCheckoutShortstat: checkoutGitMocks.getCachedCheckoutShortstat,
|
|
getCheckoutStatus: checkoutGitMocks.getCheckoutStatus,
|
|
listBranchSuggestions: checkoutGitMocks.listBranchSuggestions,
|
|
mergeFromBase: checkoutGitMocks.mergeFromBase,
|
|
mergeToBase: checkoutGitMocks.mergeToBase,
|
|
pullCurrentBranch: checkoutGitMocks.pullCurrentBranch,
|
|
pushCurrentBranch: checkoutGitMocks.pushCurrentBranch,
|
|
renameCurrentBranch: checkoutGitMocks.renameCurrentBranch,
|
|
resolveBranchCheckout: checkoutGitMocks.resolveBranchCheckout,
|
|
warmCheckoutShortstatInBackground: checkoutGitMocks.warmCheckoutShortstatInBackground,
|
|
};
|
|
});
|
|
|
|
vi.mock("./paseo-worktree-service.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("./paseo-worktree-service.js")>();
|
|
return {
|
|
...actual,
|
|
createPaseoWorktree: paseoWorktreeServiceMocks.createPaseoWorktree,
|
|
};
|
|
});
|
|
|
|
vi.mock("../utils/spawn.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../utils/spawn.js")>();
|
|
return {
|
|
...actual,
|
|
execCommand: spawnMocks.execCommand,
|
|
};
|
|
});
|
|
|
|
vi.mock("./agent/agent-response-loop.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("./agent/agent-response-loop.js")>();
|
|
return {
|
|
...actual,
|
|
generateStructuredAgentResponseWithFallback:
|
|
agentResponseMocks.generateStructuredAgentResponseWithFallback,
|
|
};
|
|
});
|
|
|
|
vi.mock("./agent/agent-metadata-generator.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("./agent/agent-metadata-generator.js")>();
|
|
return {
|
|
...actual,
|
|
scheduleAgentMetadataGeneration: agentMetadataMocks.scheduleAgentMetadataGeneration,
|
|
};
|
|
});
|
|
|
|
vi.mock("./worktree-bootstrap.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("./worktree-bootstrap.js")>();
|
|
return {
|
|
...actual,
|
|
spawnWorkspaceScript: spawnMocks.spawnWorkspaceScript,
|
|
};
|
|
});
|
|
|
|
interface SessionForTestOptions {
|
|
github?: {
|
|
invalidate: ReturnType<typeof vi.fn>;
|
|
isAuthenticated?: ReturnType<typeof vi.fn>;
|
|
getPullRequestTimeline?: ReturnType<typeof vi.fn>;
|
|
searchIssuesAndPrs?: ReturnType<typeof vi.fn>;
|
|
};
|
|
checkoutDiffManager?: { scheduleRefreshForCwd: ReturnType<typeof vi.fn> };
|
|
workspaceGitService?: {
|
|
getCheckoutDiff?: ReturnType<typeof vi.fn>;
|
|
getSnapshot?: ReturnType<typeof vi.fn>;
|
|
suggestBranchesForCwd?: ReturnType<typeof vi.fn>;
|
|
listStashes?: ReturnType<typeof vi.fn>;
|
|
peekSnapshot?: ReturnType<typeof vi.fn>;
|
|
validateBranchRef?: ReturnType<typeof vi.fn>;
|
|
hasLocalBranch?: ReturnType<typeof vi.fn>;
|
|
resolveRepoRemoteUrl?: ReturnType<typeof vi.fn>;
|
|
resolveRepoRoot?: ReturnType<typeof vi.fn>;
|
|
getWorkspaceGitMetadata?: ReturnType<typeof vi.fn>;
|
|
};
|
|
workspaceRegistry?: { get: ReturnType<typeof vi.fn> };
|
|
projectRegistry?: Partial<SessionOptions["projectRegistry"]>;
|
|
terminalManager?: SessionOptions["terminalManager"];
|
|
serviceProxy?: SessionOptions["serviceProxy"];
|
|
scriptRuntimeStore?: SessionOptions["scriptRuntimeStore"];
|
|
getDaemonTcpPort?: () => number | null;
|
|
getDaemonTcpHost?: () => string | null;
|
|
providerSnapshotManager?: ProviderSnapshotManager;
|
|
stt?: SessionOptions["stt"];
|
|
voice?: SessionOptions["voice"];
|
|
paseoHome?: string;
|
|
messages?: unknown[];
|
|
binaryMessages?: Uint8Array[];
|
|
}
|
|
|
|
function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
|
const logger = pino({ level: "silent" });
|
|
const github = options.github ?? {
|
|
invalidate: vi.fn(),
|
|
searchIssuesAndPrs: vi.fn(),
|
|
createPullRequest: vi.fn(),
|
|
mergePullRequest: vi.fn(),
|
|
};
|
|
const checkoutDiffManager = options.checkoutDiffManager ?? {
|
|
scheduleRefreshForCwd: vi.fn(),
|
|
};
|
|
const workspaceGitService = options.workspaceGitService ?? {
|
|
getCheckoutDiff: vi.fn(),
|
|
getSnapshot: vi.fn(),
|
|
suggestBranchesForCwd: vi.fn(),
|
|
listStashes: vi.fn(),
|
|
peekSnapshot: vi.fn(),
|
|
validateBranchRef: vi.fn(),
|
|
hasLocalBranch: vi.fn(),
|
|
resolveRepoRemoteUrl: vi.fn(),
|
|
resolveRepoRoot: vi.fn(),
|
|
getWorkspaceGitMetadata: vi.fn(),
|
|
};
|
|
const messages = options.messages ?? [];
|
|
|
|
return new Session({
|
|
clientId: "test-client",
|
|
onMessage: (message) => messages.push(message),
|
|
onBinaryMessage: createBinaryMessageHandler(options.binaryMessages),
|
|
logger,
|
|
downloadTokenStore: asDownloadTokenStore(),
|
|
pushTokenStore: asPushTokenStore(),
|
|
paseoHome: options.paseoHome ?? "/tmp/paseo-home",
|
|
agentManager: asAgentManager({
|
|
listAgents: vi.fn(() => []),
|
|
subscribe: vi.fn(() => () => {}),
|
|
}),
|
|
agentStorage: asAgentStorage({
|
|
list: vi.fn().mockResolvedValue([]),
|
|
}),
|
|
projectRegistry: options.projectRegistry ?? {
|
|
list: vi.fn().mockResolvedValue([]),
|
|
get: vi.fn(),
|
|
upsert: vi.fn(),
|
|
archive: vi.fn(),
|
|
remove: vi.fn(),
|
|
initialize: vi.fn(),
|
|
existsOnDisk: vi.fn(),
|
|
},
|
|
workspaceRegistry: options.workspaceRegistry ?? {
|
|
get: vi.fn(),
|
|
list: vi.fn().mockResolvedValue([]),
|
|
},
|
|
chatService: asChatService(),
|
|
scheduleService: asScheduleService(),
|
|
loopService: asLoopService(),
|
|
checkoutDiffManager: asCheckoutDiffManager(checkoutDiffManager),
|
|
github: asGitHubService(github),
|
|
workspaceGitService: asWorkspaceGitService(workspaceGitService),
|
|
daemonConfigStore: asDaemonConfigStore({
|
|
get: vi.fn(() => ({
|
|
mcp: { injectIntoAgents: false },
|
|
providers: {},
|
|
})),
|
|
onChange: vi.fn(() => () => {}),
|
|
}),
|
|
stt: options.stt ?? null,
|
|
tts: null,
|
|
terminalManager: options.terminalManager ?? null,
|
|
providerSnapshotManager:
|
|
options.providerSnapshotManager ?? createProviderSnapshotManagerStub().manager,
|
|
serviceProxy: options.serviceProxy,
|
|
scriptRuntimeStore: options.scriptRuntimeStore,
|
|
getDaemonTcpPort: options.getDaemonTcpPort,
|
|
getDaemonTcpHost: options.getDaemonTcpHost,
|
|
voice: options.voice,
|
|
});
|
|
}
|
|
|
|
class FakeVoiceTurnDetectionSession extends EventEmitter implements TurnDetectionSession {
|
|
public readonly requiredSampleRate = 16000;
|
|
|
|
async connect(): Promise<void> {}
|
|
|
|
appendPcm16(_chunk: Buffer): void {}
|
|
|
|
flush(): void {}
|
|
reset(): void {}
|
|
close(): void {}
|
|
}
|
|
|
|
class FakeVoiceSttSession extends EventEmitter implements StreamingTranscriptionSession {
|
|
public readonly requiredSampleRate = 16000;
|
|
public commitCount = 0;
|
|
|
|
async connect(): Promise<void> {}
|
|
|
|
appendPcm16(_pcm16le: Buffer): void {}
|
|
|
|
commit(): void {
|
|
this.commitCount += 1;
|
|
}
|
|
|
|
clear(): void {}
|
|
close(): void {}
|
|
|
|
emitCommitted(event: StreamingTranscriptionCommittedEvent): void {
|
|
this.emit("committed", event);
|
|
}
|
|
|
|
emitTranscript(event: StreamingTranscriptionEvent): void {
|
|
this.emit("transcript", event);
|
|
}
|
|
}
|
|
|
|
function createVoiceSessionHarness() {
|
|
const messages: unknown[] = [];
|
|
const detector = new FakeVoiceTurnDetectionSession();
|
|
const sttSession = new FakeVoiceSttSession();
|
|
const sttProvider: SpeechToTextProvider = {
|
|
id: "local",
|
|
createSession: vi.fn(() => sttSession),
|
|
};
|
|
const turnDetection: TurnDetectionProvider = {
|
|
id: "local",
|
|
createSession: vi.fn(() => detector),
|
|
};
|
|
const session = createSessionForTest({
|
|
messages,
|
|
stt: sttProvider,
|
|
voice: { turnDetection },
|
|
});
|
|
Object.assign(session, {
|
|
isVoiceMode: true,
|
|
voiceModeAgentId: "11111111-1111-4111-8111-111111111111",
|
|
});
|
|
const internals = asSessionInternals(session);
|
|
const sendAgentMessage = vi
|
|
.spyOn(internals, "handleSendAgentMessage")
|
|
.mockResolvedValue({ ok: true });
|
|
const transcribe = vi.spyOn(asSessionInternals(session).sttManager, "transcribe");
|
|
|
|
return {
|
|
session,
|
|
internals,
|
|
messages,
|
|
detector,
|
|
sttSession,
|
|
sendAgentMessage,
|
|
transcribe,
|
|
};
|
|
}
|
|
|
|
async function settleVoiceSession(): Promise<void> {
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
}
|
|
|
|
describe("session voice mode streaming transcription", () => {
|
|
test("submits the streaming final transcript to the agent without batch transcribe", async () => {
|
|
const harness = createVoiceSessionHarness();
|
|
|
|
await harness.internals.startVoiceTurnController();
|
|
harness.detector.emit("speech_started");
|
|
await settleVoiceSession();
|
|
harness.detector.emit("speech_stopped");
|
|
await settleVoiceSession();
|
|
harness.sttSession.emitCommitted({ segmentId: "segment-1", previousSegmentId: null });
|
|
harness.sttSession.emitTranscript({
|
|
segmentId: "segment-1",
|
|
transcript: "ship the streaming final",
|
|
isFinal: true,
|
|
language: "en",
|
|
avgLogprob: -0.1,
|
|
isLowConfidence: false,
|
|
});
|
|
await settleVoiceSession();
|
|
|
|
expect(harness.sttSession.commitCount).toBe(1);
|
|
expect(harness.transcribe).not.toHaveBeenCalled();
|
|
expect(harness.sendAgentMessage).toHaveBeenCalledWith(
|
|
"11111111-1111-4111-8111-111111111111",
|
|
"ship the streaming final",
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
{ spokenInput: true },
|
|
);
|
|
expect(harness.messages).toContainEqual(
|
|
expect.objectContaining({
|
|
type: "transcription_result",
|
|
payload: expect.objectContaining({
|
|
text: "ship the streaming final",
|
|
language: "en",
|
|
avgLogprob: -0.1,
|
|
}),
|
|
}),
|
|
);
|
|
|
|
await harness.internals.stopVoiceTurnController();
|
|
});
|
|
|
|
test("uses the finalization timeout empty transcript path without agent submission", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const harness = createVoiceSessionHarness();
|
|
|
|
await harness.internals.startVoiceTurnController();
|
|
harness.detector.emit("speech_started");
|
|
await settleVoiceSession();
|
|
harness.detector.emit("speech_stopped");
|
|
await settleVoiceSession();
|
|
harness.sttSession.emitCommitted({ segmentId: "segment-1", previousSegmentId: null });
|
|
|
|
await vi.advanceTimersByTimeAsync(10_000);
|
|
await settleVoiceSession();
|
|
|
|
expect(harness.transcribe).not.toHaveBeenCalled();
|
|
expect(harness.sendAgentMessage).not.toHaveBeenCalled();
|
|
expect(harness.messages).toContainEqual(
|
|
expect.objectContaining({
|
|
type: "transcription_result",
|
|
payload: expect.objectContaining({
|
|
text: "",
|
|
}),
|
|
}),
|
|
);
|
|
|
|
await harness.internals.stopVoiceTurnController();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
test("filters low-confidence streaming finals without agent submission", async () => {
|
|
const harness = createVoiceSessionHarness();
|
|
|
|
await harness.internals.startVoiceTurnController();
|
|
harness.detector.emit("speech_started");
|
|
await settleVoiceSession();
|
|
harness.detector.emit("speech_stopped");
|
|
await settleVoiceSession();
|
|
harness.sttSession.emitCommitted({ segmentId: "segment-1", previousSegmentId: null });
|
|
harness.sttSession.emitTranscript({
|
|
segmentId: "segment-1",
|
|
transcript: "background noise",
|
|
isFinal: true,
|
|
avgLogprob: -2.5,
|
|
isLowConfidence: true,
|
|
});
|
|
await settleVoiceSession();
|
|
|
|
expect(harness.transcribe).not.toHaveBeenCalled();
|
|
expect(harness.sendAgentMessage).not.toHaveBeenCalled();
|
|
expect(harness.messages).toContainEqual(
|
|
expect.objectContaining({
|
|
type: "transcription_result",
|
|
payload: expect.objectContaining({
|
|
text: "",
|
|
avgLogprob: -2.5,
|
|
isLowConfidence: true,
|
|
}),
|
|
}),
|
|
);
|
|
|
|
await harness.internals.stopVoiceTurnController();
|
|
});
|
|
});
|
|
|
|
describe("file explorer binary responses", () => {
|
|
const tempDirs: string[] = [];
|
|
|
|
afterEach(() => {
|
|
for (const dir of tempDirs.splice(0)) {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
function makeRoot(): string {
|
|
const root = realpathSync(mkdtempSync(join(tmpdir(), "file-explorer-session-test-")));
|
|
tempDirs.push(root);
|
|
return root;
|
|
}
|
|
|
|
test("old clients get legacy JSON file content from a new daemon", async () => {
|
|
const cwd = makeRoot();
|
|
writeFileSync(join(cwd, "logo.png"), "hello");
|
|
const messages: unknown[] = [];
|
|
const binaryMessages: Uint8Array[] = [];
|
|
const session = createSessionForTest({ messages, binaryMessages });
|
|
|
|
await session.handleMessage({
|
|
type: "file_explorer_request",
|
|
cwd,
|
|
path: "logo.png",
|
|
mode: "file",
|
|
requestId: "req-old-client",
|
|
});
|
|
|
|
expect(binaryMessages).toEqual([]);
|
|
expect(messages).toEqual([
|
|
{
|
|
type: "file_explorer_response",
|
|
payload: expect.objectContaining({
|
|
cwd,
|
|
path: "logo.png",
|
|
mode: "file",
|
|
directory: null,
|
|
error: null,
|
|
requestId: "req-old-client",
|
|
file: expect.objectContaining({
|
|
kind: "image",
|
|
encoding: "base64",
|
|
content: "aGVsbG8=",
|
|
mimeType: "image/png",
|
|
size: 5,
|
|
}),
|
|
}),
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("new clients get binary file frames without legacy JSON content", async () => {
|
|
const cwd = makeRoot();
|
|
writeFileSync(join(cwd, "logo.png"), "hello");
|
|
const messages: unknown[] = [];
|
|
const binaryMessages: Uint8Array[] = [];
|
|
const session = createSessionForTest({ messages, binaryMessages });
|
|
|
|
await session.handleMessage({
|
|
type: "file_explorer_request",
|
|
cwd,
|
|
path: "logo.png",
|
|
mode: "file",
|
|
requestId: "req-new-client",
|
|
acceptBinary: true,
|
|
});
|
|
|
|
expect(messages).toEqual([]);
|
|
expect(binaryMessages).toHaveLength(3);
|
|
|
|
const frames = binaryMessages.map((frame) => decodeFileTransferFrame(frame));
|
|
expect(frames[0]).toEqual({
|
|
opcode: FileTransferOpcode.FileBegin,
|
|
requestId: "req-new-client",
|
|
metadata: {
|
|
mime: "image/png",
|
|
size: 5,
|
|
encoding: "binary",
|
|
modifiedAt: expect.any(String),
|
|
},
|
|
payload: new Uint8Array(),
|
|
});
|
|
expect(frames[1]).toEqual({
|
|
opcode: FileTransferOpcode.FileChunk,
|
|
requestId: "req-new-client",
|
|
payload: new TextEncoder().encode("hello"),
|
|
});
|
|
expect(frames[2]).toEqual({
|
|
opcode: FileTransferOpcode.FileEnd,
|
|
requestId: "req-new-client",
|
|
payload: new Uint8Array(),
|
|
});
|
|
});
|
|
});
|
|
|
|
function createProjectRecord(rootPath: string, archivedAt: string | null = null) {
|
|
return {
|
|
projectId: `project:${rootPath}`,
|
|
rootPath,
|
|
kind: "git" as const,
|
|
displayName: "Project",
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
archivedAt,
|
|
};
|
|
}
|
|
|
|
describe("project config RPC authorization", () => {
|
|
const tempDirs: string[] = [];
|
|
|
|
afterEach(() => {
|
|
for (const dir of tempDirs.splice(0)) {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
function makeRoot(): string {
|
|
const root = realpathSync(mkdtempSync(join(tmpdir(), "project-config-session-test-")));
|
|
tempDirs.push(root);
|
|
return root;
|
|
}
|
|
|
|
test("read_project_config_request accepts the same root with a trailing slash", async () => {
|
|
const repoRoot = makeRoot();
|
|
writeFileSync(join(repoRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "npm ci" } }));
|
|
const messages: unknown[] = [];
|
|
const session = createSessionForTest({
|
|
messages,
|
|
projectRegistry: { list: vi.fn().mockResolvedValue([createProjectRecord(repoRoot)]) },
|
|
});
|
|
|
|
await session.handleMessage({
|
|
type: "read_project_config_request",
|
|
requestId: "read-trailing-slash-1",
|
|
repoRoot: `${repoRoot}/`,
|
|
});
|
|
|
|
expect(messages).toEqual([
|
|
{
|
|
type: "read_project_config_response",
|
|
payload: {
|
|
requestId: "read-trailing-slash-1",
|
|
repoRoot,
|
|
ok: true,
|
|
config: { worktree: { setup: "npm ci" } },
|
|
revision: expect.objectContaining({
|
|
mtimeMs: expect.any(Number),
|
|
size: expect.any(Number),
|
|
}),
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
// POSIX-only: creates a directory symlink without Windows privileges.
|
|
test.skipIf(isPlatform("win32"))(
|
|
"read_project_config_request accepts a symlink to an active project root",
|
|
async () => {
|
|
const repoRoot = makeRoot();
|
|
writeFileSync(
|
|
join(repoRoot, "paseo.json"),
|
|
JSON.stringify({ worktree: { setup: "npm ci" } }),
|
|
);
|
|
const linkRoot = join(makeRoot(), "link");
|
|
symlinkSync(repoRoot, linkRoot, "dir");
|
|
const messages: unknown[] = [];
|
|
const session = createSessionForTest({
|
|
messages,
|
|
projectRegistry: { list: vi.fn().mockResolvedValue([createProjectRecord(repoRoot)]) },
|
|
});
|
|
|
|
await session.handleMessage({
|
|
type: "read_project_config_request",
|
|
requestId: "read-symlink-1",
|
|
repoRoot: linkRoot,
|
|
});
|
|
|
|
expect(messages).toEqual([
|
|
{
|
|
type: "read_project_config_response",
|
|
payload: {
|
|
requestId: "read-symlink-1",
|
|
repoRoot,
|
|
ok: true,
|
|
config: { worktree: { setup: "npm ci" } },
|
|
revision: expect.objectContaining({
|
|
mtimeMs: expect.any(Number),
|
|
size: expect.any(Number),
|
|
}),
|
|
},
|
|
},
|
|
]);
|
|
},
|
|
);
|
|
|
|
test("read_project_config_request rejects archived and unknown roots with project_not_found", async () => {
|
|
const archivedRoot = makeRoot();
|
|
const unknownRoot = makeRoot();
|
|
const messages: unknown[] = [];
|
|
const session = createSessionForTest({
|
|
messages,
|
|
projectRegistry: {
|
|
list: vi
|
|
.fn()
|
|
.mockResolvedValue([createProjectRecord(archivedRoot, "2026-01-02T00:00:00.000Z")]),
|
|
},
|
|
});
|
|
|
|
await session.handleMessage({
|
|
type: "read_project_config_request",
|
|
requestId: "archived-1",
|
|
repoRoot: archivedRoot,
|
|
});
|
|
await session.handleMessage({
|
|
type: "read_project_config_request",
|
|
requestId: "unknown-1",
|
|
repoRoot: unknownRoot,
|
|
});
|
|
|
|
expect(messages).toEqual([
|
|
{
|
|
type: "read_project_config_response",
|
|
payload: {
|
|
requestId: "archived-1",
|
|
repoRoot: archivedRoot,
|
|
ok: false,
|
|
error: { code: "project_not_found" },
|
|
},
|
|
},
|
|
{
|
|
type: "read_project_config_response",
|
|
payload: {
|
|
requestId: "unknown-1",
|
|
repoRoot: unknownRoot,
|
|
ok: false,
|
|
error: { code: "project_not_found" },
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("read_project_config_request emits raw lifecycle forms for a known project root", async () => {
|
|
const repoRoot = makeRoot();
|
|
writeFileSync(
|
|
join(repoRoot, "paseo.json"),
|
|
JSON.stringify({ worktree: { setup: "npm install", teardown: ["npm run clean"] } }),
|
|
);
|
|
const messages: unknown[] = [];
|
|
const session = createSessionForTest({
|
|
messages,
|
|
projectRegistry: { list: vi.fn().mockResolvedValue([createProjectRecord(repoRoot)]) },
|
|
});
|
|
|
|
await session.handleMessage({
|
|
type: "read_project_config_request",
|
|
requestId: "read-1",
|
|
repoRoot,
|
|
});
|
|
|
|
expect(messages).toEqual([
|
|
{
|
|
type: "read_project_config_response",
|
|
payload: {
|
|
requestId: "read-1",
|
|
repoRoot,
|
|
ok: true,
|
|
config: { worktree: { setup: "npm install", teardown: ["npm run clean"] } },
|
|
revision: expect.objectContaining({
|
|
mtimeMs: expect.any(Number),
|
|
size: expect.any(Number),
|
|
}),
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("write_project_config_request emits stale and write-failed inline domain failures", async () => {
|
|
const staleRoot = makeRoot();
|
|
writeFileSync(join(staleRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } }));
|
|
const writeFailedRoot = join(makeRoot(), "not-a-directory");
|
|
writeFileSync(writeFailedRoot, "file");
|
|
const messages: unknown[] = [];
|
|
const session = createSessionForTest({
|
|
messages,
|
|
projectRegistry: {
|
|
list: vi
|
|
.fn()
|
|
.mockResolvedValue([
|
|
createProjectRecord(staleRoot),
|
|
createProjectRecord(writeFailedRoot),
|
|
]),
|
|
},
|
|
});
|
|
|
|
await session.handleMessage({
|
|
type: "write_project_config_request",
|
|
requestId: "stale-1",
|
|
repoRoot: staleRoot,
|
|
config: { worktree: { setup: "new" } },
|
|
expectedRevision: { mtimeMs: 1, size: 1 },
|
|
});
|
|
await session.handleMessage({
|
|
type: "write_project_config_request",
|
|
requestId: "write-failed-1",
|
|
repoRoot: writeFailedRoot,
|
|
config: { worktree: { setup: "new" } },
|
|
expectedRevision: null,
|
|
});
|
|
|
|
expect(messages).toEqual([
|
|
{
|
|
type: "write_project_config_response",
|
|
payload: {
|
|
requestId: "stale-1",
|
|
repoRoot: staleRoot,
|
|
ok: false,
|
|
error: {
|
|
code: "stale_project_config",
|
|
currentRevision: expect.objectContaining({
|
|
mtimeMs: expect.any(Number),
|
|
size: expect.any(Number),
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: "write_project_config_response",
|
|
payload: {
|
|
requestId: "write-failed-1",
|
|
repoRoot: writeFailedRoot,
|
|
ok: false,
|
|
error: { code: "write_failed" },
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
function createWorkspaceGitSnapshot(
|
|
cwd: string,
|
|
overrides?: {
|
|
git?: Record<string, unknown>;
|
|
github?: Record<string, unknown>;
|
|
},
|
|
) {
|
|
return {
|
|
cwd,
|
|
git: {
|
|
isGit: true,
|
|
repoRoot: cwd,
|
|
mainRepoRoot: null,
|
|
currentBranch: "feature/service",
|
|
remoteUrl: "https://github.com/getpaseo/paseo.git",
|
|
isPaseoOwnedWorktree: false,
|
|
isDirty: true,
|
|
baseRef: "main",
|
|
aheadBehind: { ahead: 2, behind: 1 },
|
|
aheadOfOrigin: 2,
|
|
behindOfOrigin: 1,
|
|
hasRemote: true,
|
|
diffStat: { additions: 3, deletions: 1 },
|
|
...overrides?.git,
|
|
},
|
|
github: {
|
|
featuresEnabled: false,
|
|
pullRequest: null,
|
|
error: null,
|
|
...overrides?.github,
|
|
},
|
|
};
|
|
}
|
|
|
|
function createTerminalManagerStub(options?: { setTerminalTitle?: ReturnType<typeof vi.fn> }): {
|
|
setTerminalTitle: ReturnType<typeof vi.fn>;
|
|
subscribeTerminalsChanged: ReturnType<typeof vi.fn>;
|
|
} {
|
|
return {
|
|
setTerminalTitle: options?.setTerminalTitle ?? vi.fn(),
|
|
subscribeTerminalsChanged: vi.fn(() => () => {}),
|
|
};
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("session provider refresh cwd routing", () => {
|
|
test("routes no-cwd provider snapshot refreshes through settings refresh", async () => {
|
|
const {
|
|
manager: providerSnapshotManager,
|
|
refreshSettingsSnapshot,
|
|
refreshSnapshotForCwd,
|
|
} = createProviderSnapshotManagerStub();
|
|
const session = createSessionForTest({ providerSnapshotManager });
|
|
|
|
await session.handleMessage({
|
|
type: "refresh_providers_snapshot_request",
|
|
providers: ["codex"],
|
|
requestId: "refresh-settings",
|
|
});
|
|
|
|
expect(refreshSettingsSnapshot).toHaveBeenCalledWith({
|
|
providers: ["codex"],
|
|
});
|
|
expect(refreshSnapshotForCwd).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("routes cwd provider snapshot refreshes through workspace refresh", async () => {
|
|
const {
|
|
manager: providerSnapshotManager,
|
|
refreshSnapshotForCwd,
|
|
refreshSettingsSnapshot,
|
|
} = createProviderSnapshotManagerStub();
|
|
const session = createSessionForTest({ providerSnapshotManager });
|
|
|
|
await session.handleMessage({
|
|
type: "refresh_providers_snapshot_request",
|
|
cwd: "/tmp/workspace-refresh",
|
|
providers: ["codex"],
|
|
requestId: "refresh-workspace",
|
|
});
|
|
|
|
expect(refreshSnapshotForCwd).toHaveBeenCalledWith({
|
|
cwd: "/tmp/workspace-refresh",
|
|
providers: ["codex"],
|
|
});
|
|
expect(refreshSettingsSnapshot).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("get_providers_snapshot_request forwards cwd to the provider authority", async () => {
|
|
const messages: unknown[] = [];
|
|
const workspaceCwd = resolvePath("/tmp/session-provider-snapshot");
|
|
const { manager: providerSnapshotManager, getSnapshot } = createProviderSnapshotManagerStub();
|
|
const session = createSessionForTest({ messages, providerSnapshotManager });
|
|
|
|
await session.handleMessage({
|
|
type: "get_providers_snapshot_request",
|
|
cwd: workspaceCwd,
|
|
requestId: "snapshot-workspace",
|
|
});
|
|
|
|
expect(getSnapshot).toHaveBeenCalledWith(workspaceCwd);
|
|
});
|
|
|
|
test("normalizes legacy model and mode list requests without cwd to home", async () => {
|
|
const messages: unknown[] = [];
|
|
const {
|
|
manager: providerSnapshotManager,
|
|
getSnapshot,
|
|
warmUpSnapshotForCwd,
|
|
} = createProviderSnapshotManagerStub();
|
|
getSnapshot.mockReturnValue([
|
|
{
|
|
provider: "codex",
|
|
status: "loading",
|
|
enabled: true,
|
|
},
|
|
]);
|
|
const session = createSessionForTest({ messages, providerSnapshotManager });
|
|
|
|
await session.handleMessage({
|
|
type: "list_provider_models_request",
|
|
provider: "codex",
|
|
requestId: "models-home",
|
|
});
|
|
await session.handleMessage({
|
|
type: "list_provider_modes_request",
|
|
provider: "codex",
|
|
requestId: "modes-home",
|
|
});
|
|
|
|
expect(getSnapshot).toHaveBeenCalledWith(homedir());
|
|
expect(warmUpSnapshotForCwd).toHaveBeenCalledWith({
|
|
cwd: homedir(),
|
|
providers: ["codex"],
|
|
});
|
|
});
|
|
|
|
test("legacy model list request treats disabled snapshot entries as unavailable without warming", async () => {
|
|
const messages: unknown[] = [];
|
|
const { manager: providerSnapshotManager, warmUpSnapshotForCwd } =
|
|
createProviderSnapshotManagerStub();
|
|
providerSnapshotManager.getSnapshot = vi.fn(() => [
|
|
{
|
|
provider: "codex",
|
|
status: "loading",
|
|
enabled: false,
|
|
},
|
|
]);
|
|
const session = createSessionForTest({ messages, providerSnapshotManager });
|
|
|
|
await session.handleMessage({
|
|
type: "list_provider_models_request",
|
|
provider: "codex",
|
|
requestId: "models-disabled",
|
|
});
|
|
|
|
expect(warmUpSnapshotForCwd).not.toHaveBeenCalled();
|
|
expect(messages).toContainEqual({
|
|
type: "list_provider_models_response",
|
|
payload: {
|
|
provider: "codex",
|
|
error: "Provider codex is disabled",
|
|
fetchedAt: expect.any(String),
|
|
requestId: "models-disabled",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("legacy mode list request treats disabled snapshot entries as unavailable without warming", async () => {
|
|
const messages: unknown[] = [];
|
|
const { manager: providerSnapshotManager, warmUpSnapshotForCwd } =
|
|
createProviderSnapshotManagerStub();
|
|
providerSnapshotManager.getSnapshot = vi.fn(() => [
|
|
{
|
|
provider: "codex",
|
|
status: "loading",
|
|
enabled: false,
|
|
},
|
|
]);
|
|
const session = createSessionForTest({ messages, providerSnapshotManager });
|
|
|
|
await session.handleMessage({
|
|
type: "list_provider_modes_request",
|
|
provider: "codex",
|
|
requestId: "modes-disabled",
|
|
});
|
|
|
|
expect(warmUpSnapshotForCwd).not.toHaveBeenCalled();
|
|
expect(messages).toContainEqual({
|
|
type: "list_provider_modes_response",
|
|
payload: {
|
|
provider: "codex",
|
|
error: "Provider codex is disabled",
|
|
fetchedAt: expect.any(String),
|
|
requestId: "modes-disabled",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("list_provider_models_request awaits warmup and emits ready models", async () => {
|
|
const messages: unknown[] = [];
|
|
const warmupDeferred = deferred<void>();
|
|
const {
|
|
manager: providerSnapshotManager,
|
|
getSnapshot,
|
|
warmUpSnapshotForCwd,
|
|
} = createProviderSnapshotManagerStub();
|
|
getSnapshot.mockReturnValueOnce([
|
|
{
|
|
provider: "codex",
|
|
status: "loading",
|
|
enabled: true,
|
|
},
|
|
]);
|
|
getSnapshot.mockReturnValue([
|
|
{
|
|
provider: "codex",
|
|
status: "ready",
|
|
enabled: true,
|
|
models: [{ provider: "codex", id: "gpt-5.4", label: "GPT-5.4" }],
|
|
modes: [],
|
|
fetchedAt: "2026-05-28T00:00:00.000Z",
|
|
},
|
|
]);
|
|
warmUpSnapshotForCwd.mockReturnValue(warmupDeferred.promise);
|
|
const session = createSessionForTest({ messages, providerSnapshotManager });
|
|
|
|
const responsePromise = session.handleMessage({
|
|
type: "list_provider_models_request",
|
|
provider: "codex",
|
|
requestId: "models-loading-home",
|
|
});
|
|
|
|
expect(warmUpSnapshotForCwd).toHaveBeenCalledWith({
|
|
cwd: homedir(),
|
|
providers: ["codex"],
|
|
});
|
|
warmupDeferred.resolve();
|
|
await responsePromise;
|
|
|
|
expect(messages).toContainEqual({
|
|
type: "list_provider_models_response",
|
|
payload: {
|
|
provider: "codex",
|
|
models: [
|
|
{
|
|
provider: "codex",
|
|
id: "gpt-5.4",
|
|
label: "GPT-5.4",
|
|
},
|
|
],
|
|
error: null,
|
|
fetchedAt: "2026-05-28T00:00:00.000Z",
|
|
requestId: "models-loading-home",
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("session checkout merge handling", () => {
|
|
test("uses workspace git service snapshot for merge-to-base preflight", async () => {
|
|
const messages: unknown[] = [];
|
|
const github = { invalidate: vi.fn() };
|
|
const checkoutDiffManager = { scheduleRefreshForCwd: vi.fn() };
|
|
const workspaceGitService = {
|
|
getSnapshot: vi.fn().mockResolvedValue(
|
|
createWorkspaceGitSnapshot("/tmp/request-worktree", {
|
|
git: {
|
|
isGit: true,
|
|
baseRef: "main",
|
|
isDirty: false,
|
|
},
|
|
}),
|
|
),
|
|
};
|
|
const session = createSessionForTest({
|
|
github,
|
|
checkoutDiffManager,
|
|
workspaceGitService,
|
|
messages,
|
|
});
|
|
|
|
checkoutGitMocks.mergeToBase.mockResolvedValue("/tmp/base-worktree");
|
|
|
|
await asSessionInternals(session).handleCheckoutMergeRequest({
|
|
type: "checkout_merge_request",
|
|
cwd: "/tmp/request-worktree",
|
|
baseRef: "main",
|
|
requestId: "request-1",
|
|
});
|
|
|
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree");
|
|
expect(checkoutGitMocks.getCheckoutStatus).not.toHaveBeenCalled();
|
|
expect(checkoutGitMocks.mergeToBase).toHaveBeenCalledWith(
|
|
"/tmp/request-worktree",
|
|
{
|
|
baseRef: "main",
|
|
mode: "merge",
|
|
},
|
|
{ paseoHome: "/tmp/paseo-home" },
|
|
);
|
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/base-worktree", {
|
|
force: true,
|
|
reason: "merge-to-base",
|
|
});
|
|
expect(github.invalidate).toHaveBeenCalledTimes(1);
|
|
expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/base-worktree" });
|
|
expect(checkoutDiffManager.scheduleRefreshForCwd).toHaveBeenCalledWith("/tmp/request-worktree");
|
|
expect(messages).toContainEqual({
|
|
type: "checkout_merge_response",
|
|
payload: {
|
|
cwd: "/tmp/request-worktree",
|
|
success: true,
|
|
error: null,
|
|
requestId: "request-1",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("uses snapshot dirty state for merge-from-base clean target preflight", async () => {
|
|
const messages: unknown[] = [];
|
|
const workspaceGitService = {
|
|
getSnapshot: vi.fn().mockResolvedValue(
|
|
createWorkspaceGitSnapshot("/tmp/request-worktree", {
|
|
git: {
|
|
isDirty: true,
|
|
},
|
|
}),
|
|
),
|
|
};
|
|
const session = createSessionForTest({ workspaceGitService, messages });
|
|
|
|
await asSessionInternals(session).handleCheckoutMergeFromBaseRequest({
|
|
type: "checkout_merge_from_base_request",
|
|
cwd: "/tmp/request-worktree",
|
|
baseRef: "main",
|
|
requireCleanTarget: true,
|
|
requestId: "request-merge-from-base",
|
|
});
|
|
|
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree");
|
|
expect(messages).toContainEqual({
|
|
type: "checkout_merge_from_base_response",
|
|
payload: {
|
|
cwd: "/tmp/request-worktree",
|
|
success: false,
|
|
error: {
|
|
code: "UNKNOWN",
|
|
message: "Working directory has uncommitted changes.",
|
|
},
|
|
requestId: "request-merge-from-base",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("forces a workspace git snapshot refresh after merge-from-base succeeds", async () => {
|
|
const messages: unknown[] = [];
|
|
const github = { invalidate: vi.fn() };
|
|
const workspaceGitService = {
|
|
getSnapshot: vi.fn().mockResolvedValue(
|
|
createWorkspaceGitSnapshot("/tmp/request-worktree", {
|
|
git: {
|
|
isDirty: false,
|
|
},
|
|
}),
|
|
),
|
|
};
|
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
|
checkoutGitMocks.mergeFromBase.mockResolvedValue(undefined);
|
|
|
|
await asSessionInternals(session).handleCheckoutMergeFromBaseRequest({
|
|
type: "checkout_merge_from_base_request",
|
|
cwd: "/tmp/request-worktree",
|
|
baseRef: "main",
|
|
requireCleanTarget: true,
|
|
requestId: "request-merge-from-base-success",
|
|
});
|
|
|
|
expect(checkoutGitMocks.mergeFromBase).toHaveBeenCalledWith("/tmp/request-worktree", {
|
|
baseRef: "main",
|
|
requireCleanTarget: true,
|
|
});
|
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", {
|
|
force: true,
|
|
reason: "merge-from-base",
|
|
});
|
|
expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" });
|
|
expect(messages).toContainEqual({
|
|
type: "checkout_merge_from_base_response",
|
|
payload: {
|
|
cwd: "/tmp/request-worktree",
|
|
success: true,
|
|
error: null,
|
|
requestId: "request-merge-from-base-success",
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("session checkout commit handling", () => {
|
|
const tempDirs: string[] = [];
|
|
const PRE_CHANGE_COMMIT_PROMPT = `Write a concise git commit message for the changes below.
|
|
Return JSON only with a single field 'message'.
|
|
|
|
Files changed:
|
|
M\tfile.txt\t(+1 -0)
|
|
|
|
diff --git a/file.txt b/file.txt
|
|
+hello
|
|
`;
|
|
|
|
afterEach(() => {
|
|
for (const dir of tempDirs.splice(0)) {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
function makeRoot(): string {
|
|
const root = realpathSync(mkdtempSync(join(tmpdir(), "commit-metadata-session-test-")));
|
|
tempDirs.push(root);
|
|
return root;
|
|
}
|
|
|
|
function writeConfig(repoRoot: string, config: unknown): void {
|
|
writeFileSync(join(repoRoot, "paseo.json"), `${JSON.stringify(config)}\n`);
|
|
}
|
|
|
|
async function generateCommitPromptWithConfig(config: unknown): Promise<string> {
|
|
const repoRoot = makeRoot();
|
|
if (typeof config === "string") {
|
|
writeFileSync(join(repoRoot, "paseo.json"), config);
|
|
} else if (config !== undefined) {
|
|
writeConfig(repoRoot, config);
|
|
}
|
|
|
|
const workspaceGitService = {
|
|
getCheckoutDiff: vi.fn().mockResolvedValue({
|
|
diff: "diff --git a/file.txt b/file.txt\n+hello\n",
|
|
structured: [
|
|
{
|
|
path: "file.txt",
|
|
additions: 1,
|
|
deletions: 0,
|
|
isNew: false,
|
|
isDeleted: false,
|
|
hunks: [],
|
|
status: "ok",
|
|
},
|
|
],
|
|
}),
|
|
getSnapshot: vi.fn().mockResolvedValue({}),
|
|
resolveRepoRoot: vi.fn().mockResolvedValue(repoRoot),
|
|
};
|
|
agentResponseMocks.generateStructuredAgentResponseWithFallback.mockResolvedValue({
|
|
message: "Update file",
|
|
});
|
|
checkoutGitMocks.commitChanges.mockResolvedValue(undefined);
|
|
const session = createSessionForTest({ workspaceGitService });
|
|
|
|
await asSessionInternals(session).handleCheckoutCommitRequest({
|
|
type: "checkout_commit_request",
|
|
cwd: join(repoRoot, "nested"),
|
|
message: "",
|
|
addAll: true,
|
|
requestId: "request-generated-commit",
|
|
});
|
|
|
|
return String(
|
|
agentResponseMocks.generateStructuredAgentResponseWithFallback.mock.calls[0]?.[0].prompt,
|
|
);
|
|
}
|
|
|
|
test("forces a workspace git snapshot refresh after committing", async () => {
|
|
const messages: unknown[] = [];
|
|
const checkoutDiffManager = { scheduleRefreshForCwd: vi.fn() };
|
|
const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue({}) };
|
|
const session = createSessionForTest({ checkoutDiffManager, workspaceGitService, messages });
|
|
|
|
checkoutGitMocks.commitChanges.mockResolvedValue(undefined);
|
|
|
|
await asSessionInternals(session).handleCheckoutCommitRequest({
|
|
type: "checkout_commit_request",
|
|
cwd: "/tmp/request-worktree",
|
|
message: "Ship it",
|
|
addAll: true,
|
|
requestId: "request-commit",
|
|
});
|
|
|
|
expect(checkoutGitMocks.commitChanges).toHaveBeenCalledWith("/tmp/request-worktree", {
|
|
message: "Ship it",
|
|
addAll: true,
|
|
});
|
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledTimes(1);
|
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", {
|
|
force: true,
|
|
reason: "commit-changes",
|
|
});
|
|
expect(checkoutDiffManager.scheduleRefreshForCwd).toHaveBeenCalledWith("/tmp/request-worktree");
|
|
expect(messages).toContainEqual({
|
|
type: "checkout_commit_response",
|
|
payload: {
|
|
cwd: "/tmp/request-worktree",
|
|
success: true,
|
|
error: null,
|
|
requestId: "request-commit",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("generates commit messages from checkout diffs read through the workspace git service", async () => {
|
|
const messages: unknown[] = [];
|
|
const workspaceGitService = {
|
|
getCheckoutDiff: vi.fn().mockResolvedValue({
|
|
diff: "diff --git a/file.txt b/file.txt\n+hello\n",
|
|
structured: [
|
|
{
|
|
path: "file.txt",
|
|
additions: 1,
|
|
deletions: 0,
|
|
isNew: false,
|
|
isDeleted: false,
|
|
hunks: [],
|
|
status: "ok",
|
|
},
|
|
],
|
|
}),
|
|
getSnapshot: vi.fn().mockResolvedValue({}),
|
|
};
|
|
agentResponseMocks.generateStructuredAgentResponseWithFallback.mockResolvedValue({
|
|
message: "Update file",
|
|
});
|
|
checkoutGitMocks.commitChanges.mockResolvedValue(undefined);
|
|
const session = createSessionForTest({ workspaceGitService, messages });
|
|
|
|
await asSessionInternals(session).handleCheckoutCommitRequest({
|
|
type: "checkout_commit_request",
|
|
cwd: "/tmp/request-worktree",
|
|
message: "",
|
|
addAll: true,
|
|
requestId: "request-generated-commit",
|
|
});
|
|
|
|
expect(workspaceGitService.getCheckoutDiff).toHaveBeenCalledTimes(1);
|
|
expect(workspaceGitService.getCheckoutDiff).toHaveBeenCalledWith("/tmp/request-worktree", {
|
|
mode: "uncommitted",
|
|
includeStructured: true,
|
|
});
|
|
expect(agentResponseMocks.generateStructuredAgentResponseWithFallback).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
persistSession: false,
|
|
agentConfigOverrides: expect.objectContaining({
|
|
title: "Commit generator",
|
|
internal: true,
|
|
}),
|
|
}),
|
|
);
|
|
expect(checkoutGitMocks.commitChanges).toHaveBeenCalledWith("/tmp/request-worktree", {
|
|
message: "Update file",
|
|
addAll: true,
|
|
});
|
|
expect(messages).toContainEqual({
|
|
type: "checkout_commit_response",
|
|
payload: {
|
|
cwd: "/tmp/request-worktree",
|
|
success: true,
|
|
error: null,
|
|
requestId: "request-generated-commit",
|
|
},
|
|
});
|
|
});
|
|
|
|
test.each([
|
|
["paseo.json missing", undefined],
|
|
["paseo.json exists but invalid JSON", "{ nope"],
|
|
["paseo.json valid but missing metadataGeneration", {}],
|
|
["metadataGeneration is schema-invalid", { metadataGeneration: "not an object" }],
|
|
[
|
|
"metadataGeneration exists but missing commitMessage",
|
|
{ metadataGeneration: { pullRequest: { instructions: "Write a punchy PR." } } },
|
|
],
|
|
[
|
|
"commitMessage exists but instructions is undefined",
|
|
{ metadataGeneration: { commitMessage: {} } },
|
|
],
|
|
[
|
|
"commitMessage exists but instructions is empty",
|
|
{ metadataGeneration: { commitMessage: { instructions: "" } } },
|
|
],
|
|
[
|
|
"commitMessage exists but instructions is whitespace-only",
|
|
{ metadataGeneration: { commitMessage: { instructions: " \n\t " } } },
|
|
],
|
|
])("keeps the pre-change commit prompt byte-identical when %s", async (_name, config) => {
|
|
const prompt = await generateCommitPromptWithConfig(config);
|
|
|
|
expect(prompt).toBe(PRE_CHANGE_COMMIT_PROMPT);
|
|
});
|
|
|
|
test("injects commit instructions between the default rules and JSON contract", async () => {
|
|
const prompt = await generateCommitPromptWithConfig({
|
|
metadataGeneration: {
|
|
commitMessage: {
|
|
instructions: "Use conventional commits.\nAccept XML-ish <scope> text.",
|
|
},
|
|
},
|
|
});
|
|
|
|
const defaultRuleIndex = prompt.indexOf("Write a concise git commit message");
|
|
const openTagIndex = prompt.indexOf("<user-instructions>");
|
|
const noticeIndex = prompt.indexOf("override the guidelines above");
|
|
const userInstructionIndex = prompt.indexOf("Use conventional commits.");
|
|
const closeTagIndex = prompt.indexOf("</user-instructions>");
|
|
const jsonContractIndex = prompt.indexOf("Return JSON only");
|
|
const fileListIndex = prompt.indexOf("Files changed:");
|
|
const patchIndex = prompt.indexOf("diff --git");
|
|
|
|
expect(defaultRuleIndex).toBeGreaterThanOrEqual(0);
|
|
expect(defaultRuleIndex).toBeLessThan(openTagIndex);
|
|
expect(openTagIndex).toBeLessThan(noticeIndex);
|
|
expect(noticeIndex).toBeLessThan(userInstructionIndex);
|
|
expect(userInstructionIndex).toBeLessThan(closeTagIndex);
|
|
expect(closeTagIndex).toBeLessThan(jsonContractIndex);
|
|
expect(jsonContractIndex).toBeLessThan(fileListIndex);
|
|
expect(fileListIndex).toBeLessThan(patchIndex);
|
|
});
|
|
|
|
test("keeps the commit fallback when structured generation fails", async () => {
|
|
const messages: unknown[] = [];
|
|
const workspaceGitService = {
|
|
getCheckoutDiff: vi.fn().mockResolvedValue({
|
|
diff: "diff --git a/file.txt b/file.txt\n+hello\n",
|
|
structured: [],
|
|
}),
|
|
getSnapshot: vi.fn().mockResolvedValue({}),
|
|
resolveRepoRoot: vi.fn().mockResolvedValue(makeRoot()),
|
|
};
|
|
agentResponseMocks.generateStructuredAgentResponseWithFallback.mockRejectedValue(
|
|
new StructuredAgentFallbackError([]),
|
|
);
|
|
checkoutGitMocks.commitChanges.mockResolvedValue(undefined);
|
|
const session = createSessionForTest({ workspaceGitService, messages });
|
|
|
|
await asSessionInternals(session).handleCheckoutCommitRequest({
|
|
type: "checkout_commit_request",
|
|
cwd: "/tmp/request-worktree",
|
|
message: "",
|
|
addAll: true,
|
|
requestId: "request-generated-commit-fallback",
|
|
});
|
|
|
|
expect(checkoutGitMocks.commitChanges).toHaveBeenCalledWith("/tmp/request-worktree", {
|
|
message: "Update files",
|
|
addAll: true,
|
|
});
|
|
expect(messages).toContainEqual({
|
|
type: "checkout_commit_response",
|
|
payload: {
|
|
cwd: "/tmp/request-worktree",
|
|
success: true,
|
|
error: null,
|
|
requestId: "request-generated-commit-fallback",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("does not force a workspace git snapshot refresh when commit fails", async () => {
|
|
const messages: unknown[] = [];
|
|
const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue({}) };
|
|
const session = createSessionForTest({ workspaceGitService, messages });
|
|
checkoutGitMocks.commitChanges.mockRejectedValue(new Error("nothing to commit"));
|
|
|
|
await asSessionInternals(session).handleCheckoutCommitRequest({
|
|
type: "checkout_commit_request",
|
|
cwd: "/tmp/request-worktree",
|
|
message: "Ship it",
|
|
addAll: true,
|
|
requestId: "request-commit-failure",
|
|
});
|
|
|
|
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
|
expect(messages).toContainEqual({
|
|
type: "checkout_commit_response",
|
|
payload: {
|
|
cwd: "/tmp/request-worktree",
|
|
success: false,
|
|
error: {
|
|
code: "UNKNOWN",
|
|
message: "nothing to commit",
|
|
},
|
|
requestId: "request-commit-failure",
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("session checkout pull request creation", () => {
|
|
const tempDirs: string[] = [];
|
|
const PRE_CHANGE_PULL_REQUEST_PROMPT = `Write a pull request title and body for the changes below.
|
|
Return JSON only with fields 'title' and 'body'.
|
|
|
|
Files changed:
|
|
M\tfile.txt\t(+1 -0)
|
|
|
|
diff --git a/file.txt b/file.txt
|
|
+hello
|
|
`;
|
|
|
|
afterEach(() => {
|
|
for (const dir of tempDirs.splice(0)) {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
function makeRoot(): string {
|
|
const root = realpathSync(mkdtempSync(join(tmpdir(), "pr-metadata-session-test-")));
|
|
tempDirs.push(root);
|
|
return root;
|
|
}
|
|
|
|
function writeConfig(repoRoot: string, config: unknown): void {
|
|
writeFileSync(join(repoRoot, "paseo.json"), `${JSON.stringify(config)}\n`);
|
|
}
|
|
|
|
async function generatePullRequestCallWithConfig(config: unknown): Promise<unknown> {
|
|
const repoRoot = makeRoot();
|
|
if (typeof config === "string") {
|
|
writeFileSync(join(repoRoot, "paseo.json"), config);
|
|
} else if (config !== undefined) {
|
|
writeConfig(repoRoot, config);
|
|
}
|
|
|
|
const workspaceGitService = {
|
|
getCheckoutDiff: vi.fn().mockResolvedValue({
|
|
diff: "diff --git a/file.txt b/file.txt\n+hello\n",
|
|
structured: [
|
|
{
|
|
path: "file.txt",
|
|
additions: 1,
|
|
deletions: 0,
|
|
isNew: false,
|
|
isDeleted: false,
|
|
hunks: [],
|
|
status: "ok",
|
|
},
|
|
],
|
|
}),
|
|
resolveRepoRoot: vi.fn().mockResolvedValue(repoRoot),
|
|
};
|
|
agentResponseMocks.generateStructuredAgentResponseWithFallback.mockResolvedValue({
|
|
title: "Update file",
|
|
body: "Updates file.",
|
|
});
|
|
checkoutGitMocks.createPullRequest.mockResolvedValue({
|
|
url: "https://github.com/getpaseo/paseo/pull/1",
|
|
number: 1,
|
|
});
|
|
const session = createSessionForTest({ workspaceGitService });
|
|
|
|
await asSessionInternals(session).handleCheckoutPrCreateRequest({
|
|
type: "checkout_pr_create_request",
|
|
cwd: join(repoRoot, "nested"),
|
|
baseRef: "main",
|
|
title: "",
|
|
body: "",
|
|
requestId: "request-generated-pr",
|
|
});
|
|
|
|
return agentResponseMocks.generateStructuredAgentResponseWithFallback.mock.calls[0]?.[0];
|
|
}
|
|
|
|
async function generatePullRequestPromptWithConfig(config: unknown): Promise<string> {
|
|
const call = await generatePullRequestCallWithConfig(config);
|
|
return String((call as { prompt?: unknown } | undefined)?.prompt);
|
|
}
|
|
|
|
test("generates PR text from checkout diffs read through the workspace git service", async () => {
|
|
const messages: unknown[] = [];
|
|
const workspaceGitService = {
|
|
getCheckoutDiff: vi.fn().mockResolvedValue({
|
|
diff: "diff --git a/file.txt b/file.txt\n+hello\n",
|
|
structured: [
|
|
{
|
|
path: "file.txt",
|
|
additions: 1,
|
|
deletions: 0,
|
|
isNew: false,
|
|
isDeleted: false,
|
|
hunks: [],
|
|
status: "ok",
|
|
},
|
|
],
|
|
}),
|
|
};
|
|
agentResponseMocks.generateStructuredAgentResponseWithFallback.mockResolvedValue({
|
|
title: "Update file",
|
|
body: "Updates file.",
|
|
});
|
|
checkoutGitMocks.createPullRequest.mockResolvedValue({
|
|
url: "https://github.com/getpaseo/paseo/pull/1",
|
|
number: 1,
|
|
});
|
|
const session = createSessionForTest({ workspaceGitService, messages });
|
|
|
|
await asSessionInternals(session).handleCheckoutPrCreateRequest({
|
|
type: "checkout_pr_create_request",
|
|
cwd: "/tmp/request-worktree",
|
|
baseRef: "main",
|
|
title: "",
|
|
body: "",
|
|
requestId: "request-generated-pr",
|
|
});
|
|
|
|
expect(workspaceGitService.getCheckoutDiff).toHaveBeenCalledTimes(1);
|
|
expect(workspaceGitService.getCheckoutDiff).toHaveBeenCalledWith("/tmp/request-worktree", {
|
|
mode: "base",
|
|
baseRef: "main",
|
|
includeStructured: true,
|
|
});
|
|
expect(agentResponseMocks.generateStructuredAgentResponseWithFallback).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
persistSession: false,
|
|
agentConfigOverrides: expect.objectContaining({
|
|
title: "PR generator",
|
|
internal: true,
|
|
}),
|
|
}),
|
|
);
|
|
expect(checkoutGitMocks.createPullRequest).toHaveBeenCalledWith(
|
|
"/tmp/request-worktree",
|
|
{
|
|
title: "Update file",
|
|
body: "Updates file.",
|
|
base: "main",
|
|
},
|
|
expect.anything(),
|
|
);
|
|
expect(messages).toContainEqual({
|
|
type: "checkout_pr_create_response",
|
|
payload: {
|
|
cwd: "/tmp/request-worktree",
|
|
url: "https://github.com/getpaseo/paseo/pull/1",
|
|
number: 1,
|
|
error: null,
|
|
requestId: "request-generated-pr",
|
|
},
|
|
});
|
|
});
|
|
|
|
test.each([
|
|
["paseo.json missing", undefined],
|
|
["paseo.json exists but invalid JSON", "{ nope"],
|
|
["paseo.json valid but missing metadataGeneration", {}],
|
|
["metadataGeneration is schema-invalid", { metadataGeneration: "not an object" }],
|
|
[
|
|
"metadataGeneration exists but missing pullRequest",
|
|
{ metadataGeneration: { commitMessage: { instructions: "Use conventional commits." } } },
|
|
],
|
|
[
|
|
"pullRequest exists but instructions is undefined",
|
|
{ metadataGeneration: { pullRequest: {} } },
|
|
],
|
|
[
|
|
"pullRequest exists but instructions is empty",
|
|
{ metadataGeneration: { pullRequest: { instructions: "" } } },
|
|
],
|
|
[
|
|
"pullRequest exists but instructions is whitespace-only",
|
|
{ metadataGeneration: { pullRequest: { instructions: " \n\t " } } },
|
|
],
|
|
])("keeps the pre-change PR prompt byte-identical when %s", async (_name, config) => {
|
|
const prompt = await generatePullRequestPromptWithConfig(config);
|
|
|
|
expect(prompt).toBe(PRE_CHANGE_PULL_REQUEST_PROMPT);
|
|
});
|
|
|
|
test("injects PR instructions between the default rules and JSON contract", async () => {
|
|
const prompt = await generatePullRequestPromptWithConfig({
|
|
metadataGeneration: {
|
|
pullRequest: {
|
|
instructions: "Use a terse title.\nKeep literal <ticket> text.",
|
|
},
|
|
},
|
|
});
|
|
|
|
const defaultRuleIndex = prompt.indexOf("Write a pull request title and body");
|
|
const openTagIndex = prompt.indexOf("<user-instructions>");
|
|
const noticeIndex = prompt.indexOf("override the guidelines above");
|
|
const userInstructionIndex = prompt.indexOf("Use a terse title.");
|
|
const closeTagIndex = prompt.indexOf("</user-instructions>");
|
|
const jsonContractIndex = prompt.indexOf("Return JSON only");
|
|
const fileListIndex = prompt.indexOf("Files changed:");
|
|
const patchIndex = prompt.indexOf("diff --git");
|
|
|
|
expect(defaultRuleIndex).toBeGreaterThanOrEqual(0);
|
|
expect(defaultRuleIndex).toBeLessThan(openTagIndex);
|
|
expect(openTagIndex).toBeLessThan(noticeIndex);
|
|
expect(noticeIndex).toBeLessThan(userInstructionIndex);
|
|
expect(userInstructionIndex).toBeLessThan(closeTagIndex);
|
|
expect(closeTagIndex).toBeLessThan(jsonContractIndex);
|
|
expect(jsonContractIndex).toBeLessThan(fileListIndex);
|
|
expect(fileListIndex).toBeLessThan(patchIndex);
|
|
});
|
|
|
|
test("keeps PR generation as one structured call with title and body schema", async () => {
|
|
const call = await generatePullRequestCallWithConfig({
|
|
metadataGeneration: {
|
|
pullRequest: {
|
|
instructions: "Use release-note style.",
|
|
},
|
|
},
|
|
});
|
|
const schema = (call as { schema?: { safeParse?: (value: unknown) => { success: boolean } } })
|
|
.schema;
|
|
|
|
expect(agentResponseMocks.generateStructuredAgentResponseWithFallback).toHaveBeenCalledTimes(1);
|
|
expect(call).toMatchObject({
|
|
schemaName: "PullRequest",
|
|
persistSession: false,
|
|
agentConfigOverrides: {
|
|
title: "PR generator",
|
|
internal: true,
|
|
},
|
|
});
|
|
expect(schema?.safeParse?.({ title: "Update file", body: "Updates file." }).success).toBe(true);
|
|
expect(schema?.safeParse?.({ title: "Update file" }).success).toBe(false);
|
|
});
|
|
|
|
test("keeps the PR fallback when structured generation fails", async () => {
|
|
const messages: unknown[] = [];
|
|
const workspaceGitService = {
|
|
getCheckoutDiff: vi.fn().mockResolvedValue({
|
|
diff: "diff --git a/file.txt b/file.txt\n+hello\n",
|
|
structured: [],
|
|
}),
|
|
resolveRepoRoot: vi.fn().mockResolvedValue(makeRoot()),
|
|
};
|
|
agentResponseMocks.generateStructuredAgentResponseWithFallback.mockRejectedValue(
|
|
new StructuredAgentFallbackError([]),
|
|
);
|
|
checkoutGitMocks.createPullRequest.mockResolvedValue({
|
|
url: "https://github.com/getpaseo/paseo/pull/9",
|
|
number: 9,
|
|
});
|
|
const session = createSessionForTest({ workspaceGitService, messages });
|
|
|
|
await asSessionInternals(session).handleCheckoutPrCreateRequest({
|
|
type: "checkout_pr_create_request",
|
|
cwd: "/tmp/request-worktree",
|
|
baseRef: "main",
|
|
title: "",
|
|
body: "",
|
|
requestId: "request-generated-pr-fallback",
|
|
});
|
|
|
|
expect(checkoutGitMocks.createPullRequest).toHaveBeenCalledWith(
|
|
"/tmp/request-worktree",
|
|
{
|
|
title: "Update changes",
|
|
body: "Automated PR generated by Paseo.",
|
|
base: "main",
|
|
},
|
|
expect.anything(),
|
|
);
|
|
expect(messages).toContainEqual({
|
|
type: "checkout_pr_create_response",
|
|
payload: {
|
|
cwd: "/tmp/request-worktree",
|
|
url: "https://github.com/getpaseo/paseo/pull/9",
|
|
number: 9,
|
|
error: null,
|
|
requestId: "request-generated-pr-fallback",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("forces workspace git and GitHub refresh after creating a pull request", async () => {
|
|
const messages: unknown[] = [];
|
|
const github = { invalidate: vi.fn() };
|
|
const workspaceGitService = {
|
|
getSnapshot: vi.fn().mockResolvedValue({}),
|
|
};
|
|
checkoutGitMocks.createPullRequest.mockResolvedValue({
|
|
url: "https://github.com/getpaseo/paseo/pull/2",
|
|
number: 2,
|
|
});
|
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
|
|
|
await asSessionInternals(session).handleCheckoutPrCreateRequest({
|
|
type: "checkout_pr_create_request",
|
|
cwd: "/tmp/request-worktree",
|
|
baseRef: "main",
|
|
title: "Update file",
|
|
body: "Updates file.",
|
|
requestId: "request-pr-create",
|
|
});
|
|
|
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", {
|
|
force: true,
|
|
reason: "create-pr",
|
|
});
|
|
expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" });
|
|
expect(messages).toContainEqual({
|
|
type: "checkout_pr_create_response",
|
|
payload: {
|
|
cwd: "/tmp/request-worktree",
|
|
url: "https://github.com/getpaseo/paseo/pull/2",
|
|
number: 2,
|
|
error: null,
|
|
requestId: "request-pr-create",
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("session checkout pull request merge", () => {
|
|
test("merges the current pull request and refreshes GitHub state", async () => {
|
|
const messages: unknown[] = [];
|
|
const github = {
|
|
invalidate: vi.fn(),
|
|
mergePullRequest: vi.fn().mockResolvedValue({ success: true }),
|
|
};
|
|
const workspaceGitService = {
|
|
getSnapshot: vi.fn().mockResolvedValue({
|
|
github: {
|
|
pullRequest: {
|
|
number: 42,
|
|
github: {
|
|
mergeStateStatus: "CLEAN",
|
|
autoMergeRequest: null,
|
|
viewerCanEnableAutoMerge: false,
|
|
viewerCanDisableAutoMerge: false,
|
|
viewerCanMergeAsAdmin: false,
|
|
viewerCanUpdateBranch: false,
|
|
repository: {
|
|
autoMergeAllowed: true,
|
|
mergeCommitAllowed: true,
|
|
squashMergeAllowed: true,
|
|
rebaseMergeAllowed: true,
|
|
viewerDefaultMergeMethod: "SQUASH",
|
|
},
|
|
isMergeQueueEnabled: false,
|
|
isInMergeQueue: false,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
};
|
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
|
|
|
await asSessionInternals(session).handleCheckoutPrMergeRequest({
|
|
type: "checkout_pr_merge_request",
|
|
cwd: "/tmp/request-worktree",
|
|
mergeMethod: "squash",
|
|
requestId: "request-pr-merge",
|
|
});
|
|
|
|
expect(github.mergePullRequest).toHaveBeenCalledWith({
|
|
cwd: "/tmp/request-worktree",
|
|
prNumber: 42,
|
|
mergeMethod: "squash",
|
|
status: {
|
|
number: 42,
|
|
github: {
|
|
mergeStateStatus: "CLEAN",
|
|
autoMergeRequest: null,
|
|
viewerCanEnableAutoMerge: false,
|
|
viewerCanDisableAutoMerge: false,
|
|
viewerCanMergeAsAdmin: false,
|
|
viewerCanUpdateBranch: false,
|
|
repository: {
|
|
autoMergeAllowed: true,
|
|
mergeCommitAllowed: true,
|
|
squashMergeAllowed: true,
|
|
rebaseMergeAllowed: true,
|
|
viewerDefaultMergeMethod: "SQUASH",
|
|
},
|
|
isMergeQueueEnabled: false,
|
|
isInMergeQueue: false,
|
|
},
|
|
},
|
|
});
|
|
expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(1, "/tmp/request-worktree", {
|
|
force: true,
|
|
includeGitHub: true,
|
|
reason: "merge-pr-validation",
|
|
});
|
|
expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(2, "/tmp/request-worktree", {
|
|
force: true,
|
|
reason: "merge-pr",
|
|
});
|
|
expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" });
|
|
expect(messages).toContainEqual({
|
|
type: "checkout_pr_merge_response",
|
|
payload: {
|
|
cwd: "/tmp/request-worktree",
|
|
success: true,
|
|
error: null,
|
|
requestId: "request-pr-merge",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("rejects direct merge when fresh GitHub facts block a warm clean snapshot", async () => {
|
|
const messages: unknown[] = [];
|
|
const github = {
|
|
invalidate: vi.fn(),
|
|
mergePullRequest: vi.fn(
|
|
async (input: { status?: { github?: { mergeStateStatus?: string | null } } }) => {
|
|
if (input.status?.github?.mergeStateStatus === "BLOCKED") {
|
|
throw new Error("GitHub does not report this pull request as ready for direct merge");
|
|
}
|
|
return { success: true };
|
|
},
|
|
),
|
|
};
|
|
const createSnapshot = (mergeStateStatus: "CLEAN" | "BLOCKED") => ({
|
|
github: {
|
|
pullRequest: {
|
|
number: 42,
|
|
github: {
|
|
mergeStateStatus,
|
|
autoMergeRequest: null,
|
|
viewerCanEnableAutoMerge: false,
|
|
viewerCanDisableAutoMerge: false,
|
|
viewerCanMergeAsAdmin: false,
|
|
viewerCanUpdateBranch: false,
|
|
repository: {
|
|
autoMergeAllowed: true,
|
|
mergeCommitAllowed: true,
|
|
squashMergeAllowed: true,
|
|
rebaseMergeAllowed: true,
|
|
viewerDefaultMergeMethod: "SQUASH",
|
|
},
|
|
isMergeQueueEnabled: false,
|
|
isInMergeQueue: false,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
const workspaceGitService = {
|
|
getSnapshot: vi.fn(async (_cwd: string, options?: { force?: boolean }) =>
|
|
createSnapshot(options?.force ? "BLOCKED" : "CLEAN"),
|
|
),
|
|
};
|
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
|
|
|
await asSessionInternals(session).handleCheckoutPrMergeRequest({
|
|
type: "checkout_pr_merge_request",
|
|
cwd: "/tmp/request-worktree",
|
|
mergeMethod: "squash",
|
|
requestId: "request-pr-merge-fresh-blocked",
|
|
});
|
|
|
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledTimes(1);
|
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", {
|
|
force: true,
|
|
includeGitHub: true,
|
|
reason: "merge-pr-validation",
|
|
});
|
|
expect(github.mergePullRequest).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
status: expect.objectContaining({
|
|
github: expect.objectContaining({ mergeStateStatus: "BLOCKED" }),
|
|
}),
|
|
}),
|
|
);
|
|
expect(github.invalidate).not.toHaveBeenCalled();
|
|
expect(messages).toContainEqual({
|
|
type: "checkout_pr_merge_response",
|
|
payload: {
|
|
cwd: "/tmp/request-worktree",
|
|
success: false,
|
|
error: {
|
|
code: "UNKNOWN",
|
|
message: "GitHub does not report this pull request as ready for direct merge",
|
|
},
|
|
requestId: "request-pr-merge-fresh-blocked",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("rejects direct merge when the current pull request is missing GitHub merge facts", async () => {
|
|
const messages: unknown[] = [];
|
|
const github = {
|
|
invalidate: vi.fn(),
|
|
mergePullRequest: vi.fn().mockResolvedValue({ success: true }),
|
|
};
|
|
const workspaceGitService = {
|
|
getSnapshot: vi.fn().mockResolvedValue({
|
|
github: {
|
|
pullRequest: {
|
|
number: 42,
|
|
mergeable: "MERGEABLE",
|
|
},
|
|
},
|
|
}),
|
|
};
|
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
|
|
|
await asSessionInternals(session).handleCheckoutPrMergeRequest({
|
|
type: "checkout_pr_merge_request",
|
|
cwd: "/tmp/request-worktree",
|
|
mergeMethod: "squash",
|
|
requestId: "request-pr-merge-missing-github-facts",
|
|
});
|
|
|
|
expect(github.mergePullRequest).not.toHaveBeenCalled();
|
|
expect(github.invalidate).not.toHaveBeenCalled();
|
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", {
|
|
force: true,
|
|
includeGitHub: true,
|
|
reason: "merge-pr-validation",
|
|
});
|
|
expect(messages).toContainEqual({
|
|
type: "checkout_pr_merge_response",
|
|
payload: {
|
|
cwd: "/tmp/request-worktree",
|
|
success: false,
|
|
error: {
|
|
code: "UNKNOWN",
|
|
message: "GitHub merge facts are unavailable for this pull request",
|
|
},
|
|
requestId: "request-pr-merge-missing-github-facts",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("surfaces merge errors verbatim", async () => {
|
|
const messages: unknown[] = [];
|
|
const github = {
|
|
invalidate: vi.fn(),
|
|
mergePullRequest: vi.fn().mockRejectedValue(new Error("base branch has conflicts")),
|
|
};
|
|
const workspaceGitService = {
|
|
getSnapshot: vi.fn().mockResolvedValue({
|
|
github: {
|
|
pullRequest: {
|
|
number: 42,
|
|
github: {
|
|
mergeStateStatus: "CLEAN",
|
|
autoMergeRequest: null,
|
|
viewerCanEnableAutoMerge: false,
|
|
viewerCanDisableAutoMerge: false,
|
|
viewerCanMergeAsAdmin: false,
|
|
viewerCanUpdateBranch: false,
|
|
repository: {
|
|
autoMergeAllowed: true,
|
|
mergeCommitAllowed: true,
|
|
squashMergeAllowed: true,
|
|
rebaseMergeAllowed: true,
|
|
viewerDefaultMergeMethod: "SQUASH",
|
|
},
|
|
isMergeQueueEnabled: false,
|
|
isInMergeQueue: false,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
};
|
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
|
|
|
await asSessionInternals(session).handleCheckoutPrMergeRequest({
|
|
type: "checkout_pr_merge_request",
|
|
cwd: "/tmp/request-worktree",
|
|
mergeMethod: "merge",
|
|
requestId: "request-pr-merge-failure",
|
|
});
|
|
|
|
expect(messages).toContainEqual({
|
|
type: "checkout_pr_merge_response",
|
|
payload: {
|
|
cwd: "/tmp/request-worktree",
|
|
success: false,
|
|
error: {
|
|
code: "UNKNOWN",
|
|
message: "base branch has conflicts",
|
|
},
|
|
requestId: "request-pr-merge-failure",
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("session checkout pull request auto-merge", () => {
|
|
const autoMergeGithubFacts = (
|
|
overrides: Partial<GitHubPullRequestStatusFacts> = {},
|
|
): GitHubPullRequestStatusFacts => ({
|
|
mergeStateStatus: "BLOCKED",
|
|
autoMergeRequest: null,
|
|
viewerCanEnableAutoMerge: true,
|
|
viewerCanDisableAutoMerge: false,
|
|
viewerCanMergeAsAdmin: false,
|
|
viewerCanUpdateBranch: false,
|
|
repository: {
|
|
autoMergeAllowed: true,
|
|
mergeCommitAllowed: true,
|
|
squashMergeAllowed: true,
|
|
rebaseMergeAllowed: true,
|
|
viewerDefaultMergeMethod: "SQUASH",
|
|
},
|
|
isMergeQueueEnabled: false,
|
|
isInMergeQueue: false,
|
|
...overrides,
|
|
});
|
|
|
|
test("enables auto-merge for the current pull request and refreshes GitHub state", async () => {
|
|
const messages: unknown[] = [];
|
|
const github = {
|
|
invalidate: vi.fn(),
|
|
enablePullRequestAutoMerge: vi.fn().mockResolvedValue({ success: true }),
|
|
};
|
|
const workspaceGitService = {
|
|
getSnapshot: vi.fn().mockResolvedValue({
|
|
github: {
|
|
pullRequest: {
|
|
number: 42,
|
|
mergeable: "MERGEABLE",
|
|
github: autoMergeGithubFacts(),
|
|
},
|
|
},
|
|
}),
|
|
};
|
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
|
|
|
await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({
|
|
type: "checkout.github.set_auto_merge.request",
|
|
cwd: "/tmp/request-worktree",
|
|
enabled: true,
|
|
mergeMethod: "squash",
|
|
requestId: "request-pr-auto-merge-enable",
|
|
});
|
|
|
|
expect(github.enablePullRequestAutoMerge).toHaveBeenCalledWith({
|
|
cwd: "/tmp/request-worktree",
|
|
prNumber: 42,
|
|
mergeMethod: "squash",
|
|
status: {
|
|
number: 42,
|
|
mergeable: "MERGEABLE",
|
|
github: autoMergeGithubFacts(),
|
|
},
|
|
});
|
|
expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(1, "/tmp/request-worktree", {
|
|
force: true,
|
|
includeGitHub: true,
|
|
reason: "auto-merge-validation",
|
|
});
|
|
expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(2, "/tmp/request-worktree", {
|
|
force: true,
|
|
reason: "enable-pr-auto-merge",
|
|
});
|
|
expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" });
|
|
expect(messages).toContainEqual({
|
|
type: "checkout.github.set_auto_merge.response",
|
|
payload: {
|
|
cwd: "/tmp/request-worktree",
|
|
enabled: true,
|
|
success: true,
|
|
error: null,
|
|
requestId: "request-pr-auto-merge-enable",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("disables auto-merge for the current pull request and refreshes GitHub state", async () => {
|
|
const messages: unknown[] = [];
|
|
const github = {
|
|
invalidate: vi.fn(),
|
|
disablePullRequestAutoMerge: vi.fn().mockResolvedValue({ success: true }),
|
|
};
|
|
const workspaceGitService = {
|
|
getSnapshot: vi.fn().mockResolvedValue({
|
|
github: {
|
|
pullRequest: {
|
|
number: 42,
|
|
github: autoMergeGithubFacts({
|
|
autoMergeRequest: {
|
|
enabledAt: "2026-05-13T17:00:00Z",
|
|
mergeMethod: "SQUASH",
|
|
enabledBy: "moboudra",
|
|
},
|
|
viewerCanEnableAutoMerge: false,
|
|
viewerCanDisableAutoMerge: true,
|
|
}),
|
|
},
|
|
},
|
|
}),
|
|
};
|
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
|
|
|
await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({
|
|
type: "checkout.github.set_auto_merge.request",
|
|
cwd: "/tmp/request-worktree",
|
|
enabled: false,
|
|
requestId: "request-pr-auto-merge-disable",
|
|
});
|
|
|
|
expect(github.disablePullRequestAutoMerge).toHaveBeenCalledWith({
|
|
cwd: "/tmp/request-worktree",
|
|
prNumber: 42,
|
|
status: {
|
|
number: 42,
|
|
github: autoMergeGithubFacts({
|
|
autoMergeRequest: {
|
|
enabledAt: "2026-05-13T17:00:00Z",
|
|
mergeMethod: "SQUASH",
|
|
enabledBy: "moboudra",
|
|
},
|
|
viewerCanEnableAutoMerge: false,
|
|
viewerCanDisableAutoMerge: true,
|
|
}),
|
|
},
|
|
});
|
|
expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(1, "/tmp/request-worktree", {
|
|
force: true,
|
|
includeGitHub: true,
|
|
reason: "auto-merge-validation",
|
|
});
|
|
expect(workspaceGitService.getSnapshot).toHaveBeenNthCalledWith(2, "/tmp/request-worktree", {
|
|
force: true,
|
|
reason: "disable-pr-auto-merge",
|
|
});
|
|
expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" });
|
|
expect(messages).toContainEqual({
|
|
type: "checkout.github.set_auto_merge.response",
|
|
payload: {
|
|
cwd: "/tmp/request-worktree",
|
|
enabled: false,
|
|
success: true,
|
|
error: null,
|
|
requestId: "request-pr-auto-merge-disable",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("surfaces auto-merge errors verbatim", async () => {
|
|
const messages: unknown[] = [];
|
|
const github = {
|
|
invalidate: vi.fn(),
|
|
enablePullRequestAutoMerge: vi.fn().mockRejectedValue(new Error("auto-merge is disabled")),
|
|
};
|
|
const workspaceGitService = {
|
|
getSnapshot: vi.fn().mockResolvedValue({
|
|
github: {
|
|
pullRequest: {
|
|
number: 42,
|
|
github: autoMergeGithubFacts(),
|
|
},
|
|
},
|
|
}),
|
|
};
|
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
|
|
|
await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({
|
|
type: "checkout.github.set_auto_merge.request",
|
|
cwd: "/tmp/request-worktree",
|
|
enabled: true,
|
|
mergeMethod: "merge",
|
|
requestId: "request-pr-auto-merge-failure",
|
|
});
|
|
|
|
expect(messages).toContainEqual({
|
|
type: "checkout.github.set_auto_merge.response",
|
|
payload: {
|
|
cwd: "/tmp/request-worktree",
|
|
enabled: true,
|
|
success: false,
|
|
error: {
|
|
code: "UNKNOWN",
|
|
message: "auto-merge is disabled",
|
|
},
|
|
requestId: "request-pr-auto-merge-failure",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("rejects auto-merge enable when the requested method is disabled", async () => {
|
|
const messages: unknown[] = [];
|
|
const github = {
|
|
invalidate: vi.fn(),
|
|
enablePullRequestAutoMerge: vi.fn().mockResolvedValue({ success: true }),
|
|
};
|
|
const workspaceGitService = {
|
|
getSnapshot: vi.fn().mockResolvedValue({
|
|
github: {
|
|
pullRequest: {
|
|
number: 42,
|
|
github: autoMergeGithubFacts({
|
|
repository: {
|
|
autoMergeAllowed: true,
|
|
mergeCommitAllowed: true,
|
|
squashMergeAllowed: false,
|
|
rebaseMergeAllowed: true,
|
|
viewerDefaultMergeMethod: "MERGE",
|
|
},
|
|
}),
|
|
},
|
|
},
|
|
}),
|
|
};
|
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
|
|
|
await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({
|
|
type: "checkout.github.set_auto_merge.request",
|
|
cwd: "/tmp/request-worktree",
|
|
enabled: true,
|
|
mergeMethod: "squash",
|
|
requestId: "request-pr-auto-merge-method-disabled",
|
|
});
|
|
|
|
expect(github.enablePullRequestAutoMerge).not.toHaveBeenCalled();
|
|
expect(github.invalidate).not.toHaveBeenCalled();
|
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", {
|
|
force: true,
|
|
includeGitHub: true,
|
|
reason: "auto-merge-validation",
|
|
});
|
|
expect(messages).toContainEqual({
|
|
type: "checkout.github.set_auto_merge.response",
|
|
payload: {
|
|
cwd: "/tmp/request-worktree",
|
|
enabled: true,
|
|
success: false,
|
|
error: {
|
|
code: "UNKNOWN",
|
|
message: "Auto-merge is not available because squash is disabled",
|
|
},
|
|
requestId: "request-pr-auto-merge-method-disabled",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("rejects auto-merge disable when the viewer cannot disable it", async () => {
|
|
const messages: unknown[] = [];
|
|
const github = {
|
|
invalidate: vi.fn(),
|
|
disablePullRequestAutoMerge: vi.fn().mockResolvedValue({ success: true }),
|
|
};
|
|
const workspaceGitService = {
|
|
getSnapshot: vi.fn().mockResolvedValue({
|
|
github: {
|
|
pullRequest: {
|
|
number: 42,
|
|
github: autoMergeGithubFacts({
|
|
autoMergeRequest: {
|
|
enabledAt: "2026-05-13T17:00:00Z",
|
|
mergeMethod: "SQUASH",
|
|
enabledBy: "someone-else",
|
|
},
|
|
viewerCanEnableAutoMerge: false,
|
|
viewerCanDisableAutoMerge: false,
|
|
}),
|
|
},
|
|
},
|
|
}),
|
|
};
|
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
|
|
|
await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({
|
|
type: "checkout.github.set_auto_merge.request",
|
|
cwd: "/tmp/request-worktree",
|
|
enabled: false,
|
|
requestId: "request-pr-auto-merge-disable-forbidden",
|
|
});
|
|
|
|
expect(github.disablePullRequestAutoMerge).not.toHaveBeenCalled();
|
|
expect(github.invalidate).not.toHaveBeenCalled();
|
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", {
|
|
force: true,
|
|
includeGitHub: true,
|
|
reason: "auto-merge-validation",
|
|
});
|
|
expect(messages).toContainEqual({
|
|
type: "checkout.github.set_auto_merge.response",
|
|
payload: {
|
|
cwd: "/tmp/request-worktree",
|
|
enabled: false,
|
|
success: false,
|
|
error: {
|
|
code: "UNKNOWN",
|
|
message: "GitHub does not allow this viewer to disable auto-merge",
|
|
},
|
|
requestId: "request-pr-auto-merge-disable-forbidden",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("rejects auto-merge disable requests that include a merge method", async () => {
|
|
const messages: unknown[] = [];
|
|
const github = {
|
|
invalidate: vi.fn(),
|
|
disablePullRequestAutoMerge: vi.fn().mockResolvedValue({ success: true }),
|
|
};
|
|
const workspaceGitService = {
|
|
getSnapshot: vi.fn().mockResolvedValue({
|
|
github: {
|
|
pullRequest: {
|
|
number: 42,
|
|
github: autoMergeGithubFacts({
|
|
autoMergeRequest: {
|
|
enabledAt: "2026-05-13T17:00:00Z",
|
|
mergeMethod: "SQUASH",
|
|
enabledBy: "moboudra",
|
|
},
|
|
viewerCanEnableAutoMerge: false,
|
|
viewerCanDisableAutoMerge: true,
|
|
}),
|
|
},
|
|
},
|
|
}),
|
|
};
|
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
|
|
|
await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({
|
|
type: "checkout.github.set_auto_merge.request",
|
|
cwd: "/tmp/request-worktree",
|
|
enabled: false,
|
|
mergeMethod: "squash",
|
|
requestId: "request-pr-auto-merge-disable-with-method",
|
|
});
|
|
|
|
expect(github.disablePullRequestAutoMerge).not.toHaveBeenCalled();
|
|
expect(github.invalidate).not.toHaveBeenCalled();
|
|
expect(messages).toContainEqual({
|
|
type: "checkout.github.set_auto_merge.response",
|
|
payload: {
|
|
cwd: "/tmp/request-worktree",
|
|
enabled: false,
|
|
success: false,
|
|
error: {
|
|
code: "UNKNOWN",
|
|
message: "mergeMethod is not allowed when disabling auto-merge",
|
|
},
|
|
requestId: "request-pr-auto-merge-disable-with-method",
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("session checkout pull and push handling", () => {
|
|
test("forces workspace git and GitHub refresh after pulling", async () => {
|
|
const messages: unknown[] = [];
|
|
const github = { invalidate: vi.fn() };
|
|
const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue({}) };
|
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
|
checkoutGitMocks.pullCurrentBranch.mockResolvedValue(undefined);
|
|
|
|
await asSessionInternals(session).handleCheckoutPullRequest({
|
|
type: "checkout_pull_request",
|
|
cwd: "/tmp/request-worktree",
|
|
requestId: "request-pull",
|
|
});
|
|
|
|
expect(checkoutGitMocks.pullCurrentBranch).toHaveBeenCalledWith("/tmp/request-worktree");
|
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", {
|
|
force: true,
|
|
reason: "pull",
|
|
});
|
|
expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" });
|
|
expect(messages).toContainEqual({
|
|
type: "checkout_pull_response",
|
|
payload: {
|
|
cwd: "/tmp/request-worktree",
|
|
success: true,
|
|
error: null,
|
|
requestId: "request-pull",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("forces workspace git and GitHub refresh after pushing", async () => {
|
|
const messages: unknown[] = [];
|
|
const github = { invalidate: vi.fn() };
|
|
const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue({}) };
|
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
|
checkoutGitMocks.pushCurrentBranch.mockResolvedValue(undefined);
|
|
|
|
await asSessionInternals(session).handleCheckoutPushRequest({
|
|
type: "checkout_push_request",
|
|
cwd: "/tmp/request-worktree",
|
|
requestId: "request-push",
|
|
});
|
|
|
|
expect(checkoutGitMocks.pushCurrentBranch).toHaveBeenCalledWith("/tmp/request-worktree");
|
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", {
|
|
force: true,
|
|
reason: "push",
|
|
});
|
|
expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" });
|
|
expect(messages).toContainEqual({
|
|
type: "checkout_push_response",
|
|
payload: {
|
|
cwd: "/tmp/request-worktree",
|
|
success: true,
|
|
error: null,
|
|
requestId: "request-push",
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("session checkout refresh handling", () => {
|
|
test("forces a git, GitHub, and diff refresh on demand", async () => {
|
|
const messages: unknown[] = [];
|
|
const github = { invalidate: vi.fn() };
|
|
const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue({}) };
|
|
const checkoutDiffManager = { scheduleRefreshForCwd: vi.fn() };
|
|
const session = createSessionForTest({
|
|
github,
|
|
workspaceGitService,
|
|
checkoutDiffManager,
|
|
messages,
|
|
});
|
|
|
|
await asSessionInternals(session).handleCheckoutRefreshRequest({
|
|
type: "checkout.refresh.request",
|
|
cwd: "/tmp/request-worktree",
|
|
requestId: "request-refresh",
|
|
});
|
|
|
|
expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" });
|
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", {
|
|
force: true,
|
|
includeGitHub: true,
|
|
reason: "manual-refresh",
|
|
});
|
|
expect(checkoutDiffManager.scheduleRefreshForCwd).toHaveBeenCalledWith("/tmp/request-worktree");
|
|
expect(messages).toContainEqual({
|
|
type: "checkout.refresh.response",
|
|
payload: {
|
|
cwd: "/tmp/request-worktree",
|
|
success: true,
|
|
error: null,
|
|
requestId: "request-refresh",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("reports an error when the snapshot refresh fails", async () => {
|
|
const messages: unknown[] = [];
|
|
const github = { invalidate: vi.fn() };
|
|
const workspaceGitService = {
|
|
getSnapshot: vi.fn().mockRejectedValue(new Error("not a git repository")),
|
|
};
|
|
const checkoutDiffManager = { scheduleRefreshForCwd: vi.fn() };
|
|
const session = createSessionForTest({
|
|
github,
|
|
workspaceGitService,
|
|
checkoutDiffManager,
|
|
messages,
|
|
});
|
|
|
|
await asSessionInternals(session).handleCheckoutRefreshRequest({
|
|
type: "checkout.refresh.request",
|
|
cwd: "/tmp/request-worktree",
|
|
requestId: "request-refresh-error",
|
|
});
|
|
|
|
expect(checkoutDiffManager.scheduleRefreshForCwd).not.toHaveBeenCalled();
|
|
expect(messages).toContainEqual({
|
|
type: "checkout.refresh.response",
|
|
payload: {
|
|
cwd: "/tmp/request-worktree",
|
|
success: false,
|
|
error: { code: "UNKNOWN", message: "not a git repository" },
|
|
requestId: "request-refresh-error",
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("session checkout status handling", () => {
|
|
test("returns checkout status from the workspace git service snapshot", async () => {
|
|
const messages: unknown[] = [];
|
|
const workspaceGitService = {
|
|
getSnapshot: vi.fn().mockResolvedValue(createWorkspaceGitSnapshot("/tmp/service-worktree")),
|
|
peekSnapshot: vi.fn(),
|
|
};
|
|
const session = createSessionForTest({ workspaceGitService, messages });
|
|
|
|
await asSessionInternals(session).handleCheckoutStatusRequest({
|
|
type: "checkout_status_request",
|
|
cwd: "/tmp/service-worktree",
|
|
requestId: "request-status",
|
|
});
|
|
|
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledTimes(1);
|
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/service-worktree");
|
|
expect(checkoutGitMocks.getCheckoutStatus).not.toHaveBeenCalled();
|
|
expect(messages).toContainEqual({
|
|
type: "checkout_status_response",
|
|
payload: {
|
|
cwd: "/tmp/service-worktree",
|
|
isGit: true,
|
|
repoRoot: "/tmp/service-worktree",
|
|
mainRepoRoot: null,
|
|
currentBranch: "feature/service",
|
|
isDirty: true,
|
|
baseRef: "main",
|
|
aheadBehind: { ahead: 2, behind: 1 },
|
|
aheadOfOrigin: 2,
|
|
behindOfOrigin: 1,
|
|
hasRemote: true,
|
|
remoteUrl: "https://github.com/getpaseo/paseo.git",
|
|
isPaseoOwnedWorktree: false,
|
|
error: null,
|
|
requestId: "request-status",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("returns fresh service data on the first checkout status read for a cwd", async () => {
|
|
const messages: unknown[] = [];
|
|
const workspaceGitService = {
|
|
getSnapshot: vi.fn().mockResolvedValue(
|
|
createWorkspaceGitSnapshot("/tmp/cold-worktree", {
|
|
git: {
|
|
currentBranch: "fresh-branch",
|
|
isDirty: false,
|
|
aheadBehind: { ahead: 4, behind: 0 },
|
|
aheadOfOrigin: 4,
|
|
behindOfOrigin: 0,
|
|
},
|
|
}),
|
|
),
|
|
peekSnapshot: vi.fn(() => null),
|
|
};
|
|
const session = createSessionForTest({ workspaceGitService, messages });
|
|
|
|
await asSessionInternals(session).handleCheckoutStatusRequest({
|
|
type: "checkout_status_request",
|
|
cwd: "/tmp/cold-worktree",
|
|
requestId: "request-cold-status",
|
|
});
|
|
|
|
expect(workspaceGitService.peekSnapshot).not.toHaveBeenCalled();
|
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledTimes(1);
|
|
expect(messages).toContainEqual({
|
|
type: "checkout_status_response",
|
|
payload: expect.objectContaining({
|
|
cwd: "/tmp/cold-worktree",
|
|
isGit: true,
|
|
currentBranch: "fresh-branch",
|
|
isDirty: false,
|
|
aheadBehind: { ahead: 4, behind: 0 },
|
|
error: null,
|
|
requestId: "request-cold-status",
|
|
}),
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("session workspace descriptors", () => {
|
|
test("fetch_workspaces_request includes project placement for a GitHub-backed workspace", async () => {
|
|
const messages: unknown[] = [];
|
|
const workspace = {
|
|
workspaceId: "ws-gh",
|
|
projectId: "remote:github.com/acme/app",
|
|
cwd: "/repo/app",
|
|
kind: "local_checkout" as const,
|
|
displayName: "app",
|
|
archivedAt: null,
|
|
};
|
|
const project = {
|
|
projectId: "remote:github.com/acme/app",
|
|
rootPath: "/repo/app",
|
|
kind: "git" as const,
|
|
displayName: "acme/app",
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
archivedAt: null,
|
|
};
|
|
const session = createSessionForTest({
|
|
messages,
|
|
workspaceRegistry: { get: vi.fn(), list: vi.fn().mockResolvedValue([workspace]) },
|
|
projectRegistry: { list: vi.fn().mockResolvedValue([project]), get: vi.fn() },
|
|
workspaceGitService: {
|
|
getSnapshot: vi.fn(),
|
|
peekSnapshot: vi.fn(() =>
|
|
createWorkspaceGitSnapshot("/repo/app", {
|
|
git: {
|
|
remoteUrl: "https://github.com/acme/app.git",
|
|
currentBranch: "main",
|
|
isPaseoOwnedWorktree: false,
|
|
mainRepoRoot: null,
|
|
},
|
|
}),
|
|
),
|
|
registerWorkspace: vi.fn(() => () => {}),
|
|
},
|
|
});
|
|
|
|
await session.handleMessage({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "fetch-workspaces-gh",
|
|
});
|
|
|
|
expect(messages).toContainEqual({
|
|
type: "fetch_workspaces_response",
|
|
payload: expect.objectContaining({
|
|
requestId: "fetch-workspaces-gh",
|
|
entries: [
|
|
expect.objectContaining({
|
|
id: "ws-gh",
|
|
project: {
|
|
projectKey: "remote:github.com/acme/app",
|
|
projectName: "acme/app",
|
|
checkout: {
|
|
cwd: "/repo/app",
|
|
isGit: true,
|
|
currentBranch: "app",
|
|
remoteUrl: null,
|
|
worktreeRoot: "/repo/app",
|
|
isPaseoOwnedWorktree: false,
|
|
mainRepoRoot: null,
|
|
},
|
|
},
|
|
}),
|
|
],
|
|
}),
|
|
});
|
|
});
|
|
|
|
test("fetch_workspaces_request includes repo-root fallback placement for a workspace without remote", async () => {
|
|
const messages: unknown[] = [];
|
|
const workspace = {
|
|
workspaceId: "ws-local",
|
|
projectId: "/repo/local",
|
|
cwd: "/repo/local",
|
|
kind: "local_checkout" as const,
|
|
displayName: "local",
|
|
archivedAt: null,
|
|
};
|
|
const project = {
|
|
projectId: "/repo/local",
|
|
rootPath: "/repo/local",
|
|
kind: "git" as const,
|
|
displayName: "local",
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
archivedAt: null,
|
|
};
|
|
const session = createSessionForTest({
|
|
messages,
|
|
workspaceRegistry: { get: vi.fn(), list: vi.fn().mockResolvedValue([workspace]) },
|
|
projectRegistry: { list: vi.fn().mockResolvedValue([project]), get: vi.fn() },
|
|
workspaceGitService: {
|
|
getSnapshot: vi.fn(),
|
|
peekSnapshot: vi.fn(() =>
|
|
createWorkspaceGitSnapshot("/repo/local", {
|
|
git: {
|
|
remoteUrl: null,
|
|
currentBranch: "main",
|
|
isPaseoOwnedWorktree: false,
|
|
mainRepoRoot: null,
|
|
},
|
|
}),
|
|
),
|
|
registerWorkspace: vi.fn(() => () => {}),
|
|
},
|
|
});
|
|
|
|
await session.handleMessage({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "fetch-workspaces-local",
|
|
});
|
|
|
|
expect(messages).toContainEqual({
|
|
type: "fetch_workspaces_response",
|
|
payload: expect.objectContaining({
|
|
requestId: "fetch-workspaces-local",
|
|
entries: [
|
|
expect.objectContaining({
|
|
id: "ws-local",
|
|
project: {
|
|
projectKey: "/repo/local",
|
|
projectName: "local",
|
|
checkout: {
|
|
cwd: "/repo/local",
|
|
isGit: true,
|
|
currentBranch: "local",
|
|
remoteUrl: null,
|
|
worktreeRoot: "/repo/local",
|
|
isPaseoOwnedWorktree: false,
|
|
mainRepoRoot: null,
|
|
},
|
|
},
|
|
}),
|
|
],
|
|
}),
|
|
});
|
|
});
|
|
|
|
test("reads descriptor diff stat from the workspace git service snapshot", async () => {
|
|
const workspaceGitService = {
|
|
getSnapshot: vi.fn(),
|
|
peekSnapshot: vi.fn(() =>
|
|
createWorkspaceGitSnapshot("/tmp/workspace", {
|
|
git: { diffStat: { additions: 7, deletions: 2 } },
|
|
}),
|
|
),
|
|
};
|
|
const session = createSessionForTest({ workspaceGitService });
|
|
checkoutGitMocks.getCachedCheckoutShortstat.mockReturnValue({
|
|
additions: 99,
|
|
deletions: 88,
|
|
});
|
|
|
|
const descriptor = await asSessionInternals(session).describeWorkspaceRecord(
|
|
{
|
|
workspaceId: "workspace-1",
|
|
projectId: "project-1",
|
|
cwd: "/tmp/workspace",
|
|
kind: "checkout",
|
|
displayName: "Workspace",
|
|
},
|
|
{
|
|
projectId: "project-1",
|
|
rootPath: "/tmp/workspace",
|
|
displayName: "Project",
|
|
kind: "git",
|
|
},
|
|
);
|
|
|
|
expect(workspaceGitService.peekSnapshot).toHaveBeenCalledWith("/tmp/workspace");
|
|
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
|
expect(checkoutGitMocks.getCachedCheckoutShortstat).not.toHaveBeenCalled();
|
|
expect(checkoutGitMocks.warmCheckoutShortstatInBackground).not.toHaveBeenCalled();
|
|
expect(descriptor.diffStat).toEqual({ additions: 7, deletions: 2 });
|
|
});
|
|
|
|
test("does not cold-load git data while describing a workspace", async () => {
|
|
const workspaceGitService = {
|
|
getSnapshot: vi.fn().mockResolvedValue(createWorkspaceGitSnapshot("/tmp/workspace")),
|
|
peekSnapshot: vi.fn(() => null),
|
|
};
|
|
const session = createSessionForTest({ workspaceGitService });
|
|
|
|
const descriptor = await asSessionInternals(session).describeWorkspaceRecordWithGitData(
|
|
{
|
|
workspaceId: "workspace-1",
|
|
projectId: "project-1",
|
|
cwd: "/tmp/workspace",
|
|
kind: "checkout",
|
|
displayName: "Workspace",
|
|
},
|
|
{
|
|
projectId: "project-1",
|
|
rootPath: "/tmp/workspace",
|
|
displayName: "Project",
|
|
kind: "git",
|
|
},
|
|
);
|
|
|
|
expect(workspaceGitService.peekSnapshot).toHaveBeenCalledWith("/tmp/workspace");
|
|
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
|
expect(descriptor.diffStat).toBeNull();
|
|
expect(descriptor.gitRuntime).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("session branch validation", () => {
|
|
test("validates branches through the workspace git service", async () => {
|
|
const messages: unknown[] = [];
|
|
const workspaceGitService = {
|
|
getSnapshot: vi.fn(),
|
|
peekSnapshot: vi.fn(),
|
|
validateBranchRef: vi
|
|
.fn()
|
|
.mockResolvedValue({ kind: "remote-only", name: "feature", remoteRef: "origin/feature" }),
|
|
};
|
|
const session = createSessionForTest({ workspaceGitService, messages });
|
|
|
|
await asSessionInternals(session).handleValidateBranchRequest({
|
|
type: "validate_branch_request",
|
|
cwd: "/tmp/repo",
|
|
branchName: "feature",
|
|
requestId: "request-validate-service",
|
|
});
|
|
|
|
expect(workspaceGitService.validateBranchRef).toHaveBeenCalledTimes(1);
|
|
expect(workspaceGitService.validateBranchRef).toHaveBeenCalledWith("/tmp/repo", "feature");
|
|
expect(checkoutGitMocks.resolveBranchCheckout).not.toHaveBeenCalled();
|
|
expect(messages).toContainEqual({
|
|
type: "validate_branch_response",
|
|
payload: {
|
|
exists: true,
|
|
resolvedRef: "origin/feature",
|
|
isRemote: true,
|
|
error: null,
|
|
requestId: "request-validate-service",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("does not validate tags as branches", async () => {
|
|
const tempDir = mkdtempSync(join(tmpdir(), "paseo-session-branch-validation-"));
|
|
const repoDir = join(tempDir, "repo");
|
|
|
|
try {
|
|
execSync(`git init -b main ${repoDir}`);
|
|
execSync("git config user.email 'test@test.com'", { cwd: repoDir });
|
|
execSync("git config user.name 'Test'", { cwd: repoDir });
|
|
writeFileSync(join(repoDir, "README.md"), "hello\n");
|
|
execSync("git add README.md", { cwd: repoDir });
|
|
execSync("git -c commit.gpgsign=false commit -m init", { cwd: repoDir });
|
|
execSync("git tag v1", { cwd: repoDir });
|
|
|
|
const messages: unknown[] = [];
|
|
const workspaceGitService = {
|
|
getSnapshot: vi.fn(),
|
|
peekSnapshot: vi.fn(),
|
|
validateBranchRef: vi.fn().mockResolvedValue({ kind: "not-found" }),
|
|
};
|
|
const session = createSessionForTest({ workspaceGitService, messages });
|
|
|
|
await session.handleMessage({
|
|
type: "validate_branch_request",
|
|
cwd: repoDir,
|
|
branchName: "v1",
|
|
requestId: "request-validate-tag",
|
|
});
|
|
|
|
expect(messages).toContainEqual({
|
|
type: "validate_branch_response",
|
|
payload: {
|
|
exists: false,
|
|
resolvedRef: null,
|
|
isRemote: false,
|
|
error: null,
|
|
requestId: "request-validate-tag",
|
|
},
|
|
});
|
|
} finally {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("session branch creation handling", () => {
|
|
test("validates the base branch through the workspace git service", async () => {
|
|
const workspaceGitService = {
|
|
getSnapshot: vi.fn(),
|
|
validateBranchRef: vi.fn().mockResolvedValue({ kind: "not-found" }),
|
|
hasLocalBranch: vi.fn(),
|
|
};
|
|
const session = createSessionForTest({ workspaceGitService });
|
|
|
|
await expect(
|
|
asSessionInternals(session).createBranchFromBase({
|
|
cwd: "/tmp/repo",
|
|
baseBranch: "missing-base",
|
|
newBranchName: "feature/new-work",
|
|
}),
|
|
).rejects.toThrow("Base branch not found: missing-base");
|
|
|
|
expect(workspaceGitService.validateBranchRef).toHaveBeenCalledTimes(1);
|
|
expect(workspaceGitService.validateBranchRef).toHaveBeenCalledWith("/tmp/repo", "missing-base");
|
|
expect(workspaceGitService.hasLocalBranch).not.toHaveBeenCalled();
|
|
expect(spawnMocks.execCommand).not.toHaveBeenCalledWith(
|
|
"git",
|
|
["rev-parse", "--verify", "missing-base"],
|
|
{ cwd: "/tmp/repo" },
|
|
);
|
|
});
|
|
|
|
test("checks local branch existence through the workspace git service", async () => {
|
|
const workspaceGitService = {
|
|
getSnapshot: vi.fn(),
|
|
validateBranchRef: vi.fn().mockResolvedValue({ kind: "local", name: "main" }),
|
|
hasLocalBranch: vi.fn().mockResolvedValue(true),
|
|
};
|
|
const session = createSessionForTest({ workspaceGitService });
|
|
|
|
await expect(
|
|
asSessionInternals(session).createBranchFromBase({
|
|
cwd: "/tmp/repo",
|
|
baseBranch: "main",
|
|
newBranchName: "feature/existing",
|
|
}),
|
|
).rejects.toThrow("Branch already exists: feature/existing");
|
|
|
|
expect(workspaceGitService.validateBranchRef).toHaveBeenCalledWith("/tmp/repo", "main");
|
|
expect(workspaceGitService.hasLocalBranch).toHaveBeenCalledTimes(1);
|
|
expect(workspaceGitService.hasLocalBranch).toHaveBeenCalledWith(
|
|
"/tmp/repo",
|
|
"feature/existing",
|
|
);
|
|
expect(spawnMocks.execCommand).not.toHaveBeenCalledWith(
|
|
"git",
|
|
["show-ref", "--verify", "--quiet", "refs/heads/feature/existing"],
|
|
{ cwd: "/tmp/repo" },
|
|
);
|
|
});
|
|
|
|
test("forces a workspace git snapshot refresh after creating a branch", async () => {
|
|
const workspaceGitService = {
|
|
getSnapshot: vi.fn().mockResolvedValue(
|
|
createWorkspaceGitSnapshot("/tmp/repo", {
|
|
git: {
|
|
isDirty: false,
|
|
},
|
|
}),
|
|
),
|
|
validateBranchRef: vi.fn().mockResolvedValue({ kind: "local", name: "main" }),
|
|
hasLocalBranch: vi.fn().mockResolvedValue(false),
|
|
};
|
|
const session = createSessionForTest({ workspaceGitService });
|
|
spawnMocks.execCommand.mockResolvedValue({
|
|
stdout: "",
|
|
stderr: "",
|
|
exitCode: 0,
|
|
signal: null,
|
|
truncated: false,
|
|
});
|
|
|
|
await asSessionInternals(session).createBranchFromBase({
|
|
cwd: "/tmp/repo",
|
|
baseBranch: "main",
|
|
newBranchName: "feature/new-work",
|
|
});
|
|
|
|
expect(spawnMocks.execCommand).toHaveBeenCalledWith(
|
|
"git",
|
|
["checkout", "-b", "feature/new-work", "main"],
|
|
{ cwd: "/tmp/repo" },
|
|
);
|
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo", {
|
|
force: true,
|
|
reason: "create-branch",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("session checkout switch branch handling", () => {
|
|
test("forces a workspace git snapshot refresh after switching branches", async () => {
|
|
const messages: unknown[] = [];
|
|
const github = { invalidate: vi.fn() };
|
|
const workspaceGitService = {
|
|
getSnapshot: vi.fn().mockResolvedValue(
|
|
createWorkspaceGitSnapshot("/tmp/repo", {
|
|
git: {
|
|
isDirty: false,
|
|
},
|
|
}),
|
|
),
|
|
validateBranchRef: vi.fn().mockResolvedValue({ kind: "local", name: "release" }),
|
|
};
|
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
|
checkoutGitMocks.checkoutResolvedBranch.mockResolvedValue({ source: "local" });
|
|
|
|
await asSessionInternals(session).handleCheckoutSwitchBranchRequest({
|
|
type: "checkout_switch_branch_request",
|
|
cwd: "/tmp/repo",
|
|
branch: "release",
|
|
requestId: "request-switch",
|
|
});
|
|
|
|
expect(checkoutGitMocks.checkoutResolvedBranch).toHaveBeenCalledWith({
|
|
cwd: "/tmp/repo",
|
|
resolution: { kind: "local", name: "release" },
|
|
});
|
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo", {
|
|
force: true,
|
|
reason: "switch-branch",
|
|
});
|
|
expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/repo" });
|
|
expect(messages).toContainEqual({
|
|
type: "checkout_switch_branch_response",
|
|
payload: {
|
|
cwd: "/tmp/repo",
|
|
success: true,
|
|
branch: "release",
|
|
source: "local",
|
|
error: null,
|
|
requestId: "request-switch",
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("session checkout rename branch handling", () => {
|
|
test("rejects invalid branch slugs without renaming", async () => {
|
|
const messages: unknown[] = [];
|
|
const workspaceGitService = {
|
|
getSnapshot: vi.fn(),
|
|
peekSnapshot: vi.fn(),
|
|
};
|
|
const session = createSessionForTest({ workspaceGitService, messages });
|
|
|
|
await session.handleMessage({
|
|
type: "checkout.rename_branch.request",
|
|
cwd: "/tmp/repo",
|
|
branch: "Feature Name",
|
|
requestId: "request-rename-invalid",
|
|
});
|
|
|
|
expect(checkoutGitMocks.renameCurrentBranch).not.toHaveBeenCalled();
|
|
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
|
expect(messages).toContainEqual({
|
|
type: "checkout.rename_branch.response",
|
|
payload: {
|
|
cwd: "/tmp/repo",
|
|
success: false,
|
|
currentBranch: null,
|
|
error: {
|
|
code: "UNKNOWN",
|
|
message:
|
|
"Branch name must contain only lowercase letters, numbers, hyphens, and forward slashes",
|
|
},
|
|
requestId: "request-rename-invalid",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("reports null current branch when branch rename fails", async () => {
|
|
const messages: unknown[] = [];
|
|
const workspaceGitService = {
|
|
getSnapshot: vi.fn(),
|
|
peekSnapshot: vi.fn(),
|
|
};
|
|
const session = createSessionForTest({ workspaceGitService, messages });
|
|
checkoutGitMocks.renameCurrentBranch.mockRejectedValue(new Error("branch already exists"));
|
|
|
|
await session.handleMessage({
|
|
type: "checkout.rename_branch.request",
|
|
cwd: "/tmp/repo",
|
|
branch: "feature/new-name",
|
|
requestId: "request-rename-failure",
|
|
});
|
|
|
|
expect(checkoutGitMocks.renameCurrentBranch).toHaveBeenCalledWith(
|
|
"/tmp/repo",
|
|
"feature/new-name",
|
|
);
|
|
expect(workspaceGitService.peekSnapshot).not.toHaveBeenCalled();
|
|
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
|
expect(messages).toContainEqual({
|
|
type: "checkout.rename_branch.response",
|
|
payload: {
|
|
cwd: "/tmp/repo",
|
|
success: false,
|
|
currentBranch: null,
|
|
error: {
|
|
code: "UNKNOWN",
|
|
message: "branch already exists",
|
|
},
|
|
requestId: "request-rename-failure",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("forces workspace git refresh after renaming the current branch", async () => {
|
|
const messages: unknown[] = [];
|
|
const github = { invalidate: vi.fn() };
|
|
const workspaceGitService = {
|
|
getSnapshot: vi.fn().mockResolvedValue(
|
|
createWorkspaceGitSnapshot("/tmp/repo", {
|
|
git: {
|
|
currentBranch: "feature/new-name",
|
|
isDirty: false,
|
|
},
|
|
}),
|
|
),
|
|
peekSnapshot: vi.fn(() =>
|
|
createWorkspaceGitSnapshot("/tmp/repo", {
|
|
git: { currentBranch: "feature/old-name" },
|
|
}),
|
|
),
|
|
};
|
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
|
checkoutGitMocks.renameCurrentBranch.mockResolvedValue({
|
|
previousBranch: "feature/old-name",
|
|
currentBranch: "feature/new-name",
|
|
});
|
|
|
|
await session.handleMessage({
|
|
type: "checkout.rename_branch.request",
|
|
cwd: "/tmp/repo",
|
|
branch: "feature/new-name",
|
|
requestId: "request-rename-success",
|
|
});
|
|
|
|
expect(checkoutGitMocks.renameCurrentBranch).toHaveBeenCalledWith(
|
|
"/tmp/repo",
|
|
"feature/new-name",
|
|
);
|
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo", {
|
|
force: true,
|
|
reason: "rename-branch",
|
|
});
|
|
expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/repo" });
|
|
expect(messages).toContainEqual({
|
|
type: "checkout.rename_branch.response",
|
|
payload: {
|
|
cwd: "/tmp/repo",
|
|
success: true,
|
|
currentBranch: "feature/new-name",
|
|
error: null,
|
|
requestId: "request-rename-success",
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("session terminal rename handling", () => {
|
|
test("rejects an empty terminal title without calling the terminal manager", async () => {
|
|
const messages: unknown[] = [];
|
|
const terminalManager = createTerminalManagerStub();
|
|
const session = createSessionForTest({ terminalManager, messages });
|
|
|
|
await session.handleMessage({
|
|
type: "terminal.rename.request",
|
|
terminalId: "terminal-1",
|
|
title: " ",
|
|
requestId: "request-empty-title",
|
|
});
|
|
|
|
expect(terminalManager.setTerminalTitle).not.toHaveBeenCalled();
|
|
expect(messages).toContainEqual({
|
|
type: "terminal.rename.response",
|
|
payload: {
|
|
requestId: "request-empty-title",
|
|
success: false,
|
|
error: "Title is required",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("reports when the terminal manager cannot find the terminal", async () => {
|
|
const messages: unknown[] = [];
|
|
const terminalManager = createTerminalManagerStub({
|
|
setTerminalTitle: vi.fn(() => false),
|
|
});
|
|
const session = createSessionForTest({ terminalManager, messages });
|
|
|
|
await session.handleMessage({
|
|
type: "terminal.rename.request",
|
|
terminalId: "missing-terminal",
|
|
title: "Renamed terminal",
|
|
requestId: "request-missing-terminal",
|
|
});
|
|
|
|
expect(terminalManager.setTerminalTitle).toHaveBeenCalledWith(
|
|
"missing-terminal",
|
|
"Renamed terminal",
|
|
);
|
|
expect(messages).toContainEqual({
|
|
type: "terminal.rename.response",
|
|
payload: {
|
|
requestId: "request-missing-terminal",
|
|
success: false,
|
|
error: "Terminal not found",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("trims and sets a valid terminal title", async () => {
|
|
const messages: unknown[] = [];
|
|
const terminalManager = createTerminalManagerStub({
|
|
setTerminalTitle: vi.fn(() => true),
|
|
});
|
|
const session = createSessionForTest({ terminalManager, messages });
|
|
|
|
await session.handleMessage({
|
|
type: "terminal.rename.request",
|
|
terminalId: "terminal-1",
|
|
title: " Renamed terminal ",
|
|
requestId: "request-title-success",
|
|
});
|
|
|
|
expect(terminalManager.setTerminalTitle).toHaveBeenCalledWith("terminal-1", "Renamed terminal");
|
|
expect(messages).toContainEqual({
|
|
type: "terminal.rename.response",
|
|
payload: {
|
|
requestId: "request-title-success",
|
|
success: true,
|
|
error: null,
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("session branch suggestions handling", () => {
|
|
test("lists branch suggestions through the workspace git service", async () => {
|
|
const messages: unknown[] = [];
|
|
const branchDetails = [
|
|
{ name: "feature/service", committerDate: 10, hasLocal: true, hasRemote: false },
|
|
];
|
|
const workspaceGitService = {
|
|
getSnapshot: vi.fn(),
|
|
suggestBranchesForCwd: vi.fn().mockResolvedValue(branchDetails),
|
|
peekSnapshot: vi.fn(),
|
|
};
|
|
const session = createSessionForTest({ workspaceGitService, messages });
|
|
|
|
await asSessionInternals(session).handleBranchSuggestionsRequest({
|
|
type: "branch_suggestions_request",
|
|
cwd: "/tmp/repo",
|
|
query: "service",
|
|
limit: 5,
|
|
requestId: "request-branches",
|
|
});
|
|
|
|
expect(workspaceGitService.suggestBranchesForCwd).toHaveBeenCalledTimes(1);
|
|
expect(workspaceGitService.suggestBranchesForCwd).toHaveBeenCalledWith("/tmp/repo", {
|
|
query: "service",
|
|
limit: 5,
|
|
});
|
|
expect(checkoutGitMocks.listBranchSuggestions).not.toHaveBeenCalled();
|
|
expect(messages).toContainEqual({
|
|
type: "branch_suggestions_response",
|
|
payload: {
|
|
branches: ["feature/service"],
|
|
branchDetails,
|
|
error: null,
|
|
requestId: "request-branches",
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("session stash list handling", () => {
|
|
test("lists stashes through the workspace git service", async () => {
|
|
const messages: unknown[] = [];
|
|
const entries = [
|
|
{
|
|
index: 0,
|
|
message: "paseo-auto-stash: feature",
|
|
branch: "feature",
|
|
isPaseo: true,
|
|
},
|
|
];
|
|
const workspaceGitService = {
|
|
getSnapshot: vi.fn(),
|
|
listStashes: vi.fn().mockResolvedValue(entries),
|
|
peekSnapshot: vi.fn(),
|
|
};
|
|
const session = createSessionForTest({ workspaceGitService, messages });
|
|
|
|
await asSessionInternals(session).handleStashListRequest({
|
|
type: "stash_list_request",
|
|
cwd: "/tmp/repo",
|
|
paseoOnly: true,
|
|
requestId: "request-stashes",
|
|
});
|
|
|
|
expect(workspaceGitService.listStashes).toHaveBeenCalledTimes(1);
|
|
expect(workspaceGitService.listStashes).toHaveBeenCalledWith("/tmp/repo", {
|
|
paseoOnly: true,
|
|
});
|
|
expect(messages).toContainEqual({
|
|
type: "stash_list_response",
|
|
payload: { cwd: "/tmp/repo", entries, error: null, requestId: "request-stashes" },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("session stash mutation handling", () => {
|
|
test("forces a workspace git snapshot refresh after pushing a stash", async () => {
|
|
const messages: unknown[] = [];
|
|
const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue({}) };
|
|
const session = createSessionForTest({ workspaceGitService, messages });
|
|
spawnMocks.execCommand.mockResolvedValue({
|
|
stdout: "",
|
|
stderr: "",
|
|
exitCode: 0,
|
|
signal: null,
|
|
truncated: false,
|
|
});
|
|
|
|
await asSessionInternals(session).handleStashSaveRequest({
|
|
type: "stash_save_request",
|
|
cwd: "/tmp/repo",
|
|
branch: "feature",
|
|
requestId: "request-stash-push",
|
|
});
|
|
|
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo", {
|
|
force: true,
|
|
reason: "stash-push",
|
|
});
|
|
expect(messages).toContainEqual({
|
|
type: "stash_save_response",
|
|
payload: {
|
|
cwd: "/tmp/repo",
|
|
success: true,
|
|
error: null,
|
|
requestId: "request-stash-push",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("forces a workspace git snapshot refresh after popping a stash", async () => {
|
|
const messages: unknown[] = [];
|
|
const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue({}) };
|
|
const session = createSessionForTest({ workspaceGitService, messages });
|
|
spawnMocks.execCommand.mockResolvedValue({
|
|
stdout: "",
|
|
stderr: "",
|
|
exitCode: 0,
|
|
signal: null,
|
|
truncated: false,
|
|
});
|
|
|
|
await asSessionInternals(session).handleStashPopRequest({
|
|
type: "stash_pop_request",
|
|
cwd: "/tmp/repo",
|
|
stashIndex: 0,
|
|
requestId: "request-stash-pop",
|
|
});
|
|
|
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo", {
|
|
force: true,
|
|
reason: "stash-pop",
|
|
});
|
|
expect(messages).toContainEqual({
|
|
type: "stash_pop_response",
|
|
payload: {
|
|
cwd: "/tmp/repo",
|
|
success: true,
|
|
error: null,
|
|
requestId: "request-stash-pop",
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("session paseo worktree creation handling", () => {
|
|
test("forces workspace git refreshes for the source repo and created worktree", async () => {
|
|
const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue({}) };
|
|
const session = createSessionForTest({ workspaceGitService });
|
|
paseoWorktreeServiceMocks.createPaseoWorktree.mockResolvedValue({
|
|
repoRoot: "/tmp/repo",
|
|
worktree: {
|
|
branchName: "feature/new-worktree",
|
|
worktreePath: "/tmp/paseo/worktrees/new-worktree",
|
|
},
|
|
workspace: {
|
|
workspaceId: "workspace-new-worktree",
|
|
projectId: "project-repo",
|
|
cwd: "/tmp/paseo/worktrees/new-worktree",
|
|
kind: "worktree",
|
|
displayName: "feature/new-worktree",
|
|
},
|
|
created: true,
|
|
});
|
|
|
|
await asSessionInternals(session).createPaseoWorktree({
|
|
cwd: "/tmp/repo",
|
|
worktreeSlug: "new-worktree",
|
|
runSetup: false,
|
|
});
|
|
|
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo", {
|
|
force: true,
|
|
reason: "create-worktree",
|
|
});
|
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(
|
|
"/tmp/paseo/worktrees/new-worktree",
|
|
{
|
|
force: true,
|
|
reason: "create-worktree",
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("session workspace script handling", () => {
|
|
test("passes service-owned git metadata into workspace script spawning", async () => {
|
|
const messages: unknown[] = [];
|
|
const workspaceGitService = {
|
|
peekSnapshot: vi.fn(() => null),
|
|
getWorkspaceGitMetadata: vi.fn().mockResolvedValue({
|
|
projectKind: "git",
|
|
projectDisplayName: "getpaseo/paseo",
|
|
workspaceDisplayName: "feature/service-scripts",
|
|
projectSlug: "paseo",
|
|
currentBranch: "feature/service-scripts",
|
|
}),
|
|
};
|
|
const workspaceRegistry = {
|
|
get: vi.fn().mockResolvedValue({
|
|
workspaceId: "workspace-1",
|
|
cwd: "/tmp/repo",
|
|
}),
|
|
};
|
|
spawnMocks.spawnWorkspaceScript.mockResolvedValue({
|
|
scriptName: "api",
|
|
terminalId: "terminal-1",
|
|
});
|
|
const session = createSessionForTest({
|
|
workspaceGitService,
|
|
workspaceRegistry,
|
|
terminalManager: { subscribeTerminalsChanged: vi.fn(() => () => {}) },
|
|
serviceProxy: { listRoutesForWorkspace: vi.fn(() => []) },
|
|
scriptRuntimeStore: { listForWorkspace: vi.fn(() => []) },
|
|
getDaemonTcpPort: () => 6767,
|
|
getDaemonTcpHost: () => "127.0.0.1",
|
|
messages,
|
|
});
|
|
|
|
await asSessionInternals(session).handleStartWorkspaceScriptRequest({
|
|
type: "start_workspace_script_request",
|
|
workspaceId: "workspace-1",
|
|
scriptName: "api",
|
|
requestId: "request-script",
|
|
});
|
|
|
|
expect(workspaceGitService.getWorkspaceGitMetadata).toHaveBeenCalledTimes(1);
|
|
expect(workspaceGitService.getWorkspaceGitMetadata).toHaveBeenCalledWith("/tmp/repo");
|
|
expect(spawnMocks.spawnWorkspaceScript).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
repoRoot: "/tmp/repo",
|
|
workspaceId: "workspace-1",
|
|
projectSlug: "paseo",
|
|
branchName: "feature/service-scripts",
|
|
scriptName: "api",
|
|
daemonPort: 6767,
|
|
daemonListenHost: "127.0.0.1",
|
|
}),
|
|
);
|
|
expect(messages).toContainEqual({
|
|
type: "start_workspace_script_response",
|
|
payload: {
|
|
requestId: "request-script",
|
|
workspaceId: "workspace-1",
|
|
scriptName: "api",
|
|
terminalId: "terminal-1",
|
|
error: null,
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("session pull request timeline handling", () => {
|
|
test("routes GitHub search requests through GitHubService", async () => {
|
|
const messages: unknown[] = [];
|
|
const github = {
|
|
invalidate: vi.fn(),
|
|
searchIssuesAndPrs: vi.fn().mockResolvedValue({
|
|
githubFeaturesEnabled: true,
|
|
items: [
|
|
{
|
|
kind: "pr",
|
|
number: 42,
|
|
title: "Ship search",
|
|
url: "https://github.com/getpaseo/paseo/pull/42",
|
|
state: "OPEN",
|
|
body: null,
|
|
labels: [],
|
|
baseRefName: "main",
|
|
headRefName: "feature",
|
|
updatedAt: "2026-04-18T13:00:00Z",
|
|
},
|
|
],
|
|
}),
|
|
};
|
|
const session = createSessionForTest({ github, messages });
|
|
|
|
await session.handleMessage({
|
|
type: "github_search_request",
|
|
cwd: "/tmp/repo",
|
|
query: "search",
|
|
limit: 5,
|
|
kinds: ["github-pr"],
|
|
requestId: "request-search",
|
|
});
|
|
|
|
expect(github.searchIssuesAndPrs).toHaveBeenCalledWith({
|
|
cwd: "/tmp/repo",
|
|
query: "search",
|
|
limit: 5,
|
|
kinds: ["github-pr"],
|
|
});
|
|
expect(messages).toContainEqual({
|
|
type: "github_search_response",
|
|
payload: {
|
|
items: [
|
|
{
|
|
kind: "pr",
|
|
number: 42,
|
|
title: "Ship search",
|
|
url: "https://github.com/getpaseo/paseo/pull/42",
|
|
state: "OPEN",
|
|
body: null,
|
|
labels: [],
|
|
baseRefName: "main",
|
|
headRefName: "feature",
|
|
updatedAt: "2026-04-18T13:00:00Z",
|
|
},
|
|
],
|
|
githubFeaturesEnabled: true,
|
|
error: null,
|
|
requestId: "request-search",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("passes request identity to GitHubService and emits timeline items", async () => {
|
|
const messages: unknown[] = [];
|
|
const github = {
|
|
invalidate: vi.fn(),
|
|
isAuthenticated: vi.fn().mockResolvedValue(true),
|
|
getPullRequestTimeline: vi.fn().mockResolvedValue({
|
|
prNumber: 42,
|
|
repoOwner: "getpaseo",
|
|
repoName: "paseo",
|
|
items: [
|
|
{
|
|
id: "review-1",
|
|
kind: "review",
|
|
author: "octocat",
|
|
authorUrl: "https://github.com/octocat",
|
|
body: "Looks good",
|
|
createdAt: 1710000000000,
|
|
url: "https://github.com/getpaseo/paseo/pull/42#pullrequestreview-1",
|
|
reviewState: "approved",
|
|
},
|
|
],
|
|
truncated: false,
|
|
error: null,
|
|
}),
|
|
};
|
|
const session = createSessionForTest({ github, messages });
|
|
|
|
await session.handleMessage({
|
|
type: "pull_request_timeline_request",
|
|
cwd: "/tmp/repo",
|
|
prNumber: 42,
|
|
repoOwner: "getpaseo",
|
|
repoName: "paseo",
|
|
requestId: "request-1",
|
|
});
|
|
|
|
expect(github.getPullRequestTimeline).toHaveBeenCalledWith({
|
|
cwd: "/tmp/repo",
|
|
prNumber: 42,
|
|
repoOwner: "getpaseo",
|
|
repoName: "paseo",
|
|
});
|
|
expect(messages).toContainEqual({
|
|
type: "pull_request_timeline_response",
|
|
payload: {
|
|
cwd: "/tmp/repo",
|
|
prNumber: 42,
|
|
items: [
|
|
{
|
|
id: "review-1",
|
|
kind: "review",
|
|
author: "octocat",
|
|
body: "Looks good",
|
|
createdAt: 1710000000000,
|
|
url: "https://github.com/getpaseo/paseo/pull/42#pullrequestreview-1",
|
|
reviewState: "approved",
|
|
},
|
|
],
|
|
truncated: false,
|
|
error: null,
|
|
requestId: "request-1",
|
|
githubFeaturesEnabled: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
test.each([
|
|
{ prNumber: 0, repoOwner: "getpaseo", repoName: "paseo" },
|
|
{ prNumber: -1, repoOwner: "getpaseo", repoName: "paseo" },
|
|
{ prNumber: 42, repoOwner: "get paseo", repoName: "paseo" },
|
|
{ prNumber: 42, repoOwner: "getpaseo/cli", repoName: "paseo" },
|
|
{ prNumber: 42, repoOwner: "get$paseo", repoName: "paseo" },
|
|
{ prNumber: 42, repoOwner: "getpaseo", repoName: "pa seo" },
|
|
{ prNumber: 42, repoOwner: "getpaseo", repoName: "paseo/app" },
|
|
{ prNumber: 42, repoOwner: "getpaseo", repoName: "paseo!" },
|
|
])("returns an unknown error when request identity is invalid: %j", async (identity) => {
|
|
const messages: unknown[] = [];
|
|
const github = {
|
|
invalidate: vi.fn(),
|
|
isAuthenticated: vi.fn().mockResolvedValue(true),
|
|
getPullRequestTimeline: vi.fn(),
|
|
};
|
|
const session = createSessionForTest({ github, messages });
|
|
|
|
await session.handleMessage({
|
|
type: "pull_request_timeline_request",
|
|
cwd: "/tmp/repo",
|
|
...identity,
|
|
requestId: "request-invalid",
|
|
});
|
|
|
|
expect(github.isAuthenticated).not.toHaveBeenCalled();
|
|
expect(github.getPullRequestTimeline).not.toHaveBeenCalled();
|
|
expect(messages).toContainEqual({
|
|
type: "pull_request_timeline_response",
|
|
payload: {
|
|
cwd: "/tmp/repo",
|
|
prNumber: identity.prNumber,
|
|
items: [],
|
|
truncated: false,
|
|
error: {
|
|
kind: "unknown",
|
|
message: "Pull request timeline request has invalid PR identity",
|
|
},
|
|
requestId: "request-invalid",
|
|
githubFeaturesEnabled: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
test("disables GitHub features when gh auth is unavailable", async () => {
|
|
const messages: unknown[] = [];
|
|
const github = {
|
|
invalidate: vi.fn(),
|
|
isAuthenticated: vi.fn().mockResolvedValue(false),
|
|
getPullRequestTimeline: vi.fn(),
|
|
};
|
|
const session = createSessionForTest({ github, messages });
|
|
|
|
await session.handleMessage({
|
|
type: "pull_request_timeline_request",
|
|
cwd: "/tmp/repo",
|
|
prNumber: 42,
|
|
repoOwner: "getpaseo",
|
|
repoName: "paseo",
|
|
requestId: "request-3",
|
|
});
|
|
|
|
expect(github.getPullRequestTimeline).not.toHaveBeenCalled();
|
|
expect(messages).toContainEqual({
|
|
type: "pull_request_timeline_response",
|
|
payload: {
|
|
cwd: "/tmp/repo",
|
|
prNumber: 42,
|
|
items: [],
|
|
truncated: false,
|
|
error: {
|
|
kind: "unknown",
|
|
message: "GitHub CLI is unavailable or not authenticated",
|
|
},
|
|
requestId: "request-3",
|
|
githubFeaturesEnabled: false,
|
|
},
|
|
});
|
|
});
|
|
});
|