Skip to content

Commit 8241f6e

Browse files
committed
ship: automate pass — pty chat terminal contract tests
1 parent b9410b3 commit 8241f6e

1 file changed

Lines changed: 177 additions & 0 deletions

File tree

apps/desktop/src/main/services/pty/ptyService.test.ts

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1709,4 +1709,181 @@ describe("ptyService", () => {
17091709
);
17101710
});
17111711
});
1712+
1713+
describe("chat terminal contract", () => {
1714+
/**
1715+
* Augment the harness's session service with the methods listTerminals/activeForChat rely on.
1716+
* Returned `service` is a fresh ptyService bound to the augmented sessionService, so we can
1717+
* exercise the chat-linked terminal surface end-to-end.
1718+
*/
1719+
function createChatHarness() {
1720+
const harness = createHarness();
1721+
const sessionStore = new Map<string, any>();
1722+
1723+
const sessionService = {
1724+
...harness.sessionService,
1725+
create: vi.fn((args: any) => {
1726+
sessionStore.set(args.sessionId, {
1727+
...args,
1728+
id: args.sessionId,
1729+
status: "running",
1730+
laneId: args.laneId,
1731+
laneName: "Test lane",
1732+
ptyId: args.ptyId ?? null,
1733+
title: args.title,
1734+
transcriptPath: args.transcriptPath,
1735+
startedAt: args.startedAt,
1736+
endedAt: null,
1737+
exitCode: null,
1738+
chatSessionId: args.chatSessionId ?? null,
1739+
});
1740+
}),
1741+
end: vi.fn((args: any) => {
1742+
const s = sessionStore.get(args.sessionId);
1743+
if (s) {
1744+
s.status = args.status;
1745+
s.exitCode = args.exitCode;
1746+
s.endedAt = args.endedAt;
1747+
s.ptyId = null;
1748+
}
1749+
}),
1750+
get: vi.fn((id: string) => sessionStore.get(id) ?? null),
1751+
list: vi.fn((args: { laneId?: string; limit?: number } = {}) => {
1752+
const all = Array.from(sessionStore.values()) as any[];
1753+
return all
1754+
.filter((s) => (args.laneId ? s.laneId === args.laneId : true))
1755+
.slice(0, args.limit ?? all.length);
1756+
}),
1757+
setChatSessionId: vi.fn((sessionId: string, chatSessionId: string | null) => {
1758+
const s = sessionStore.get(sessionId);
1759+
if (s) s.chatSessionId = chatSessionId;
1760+
}),
1761+
readTranscriptTail: vi.fn(async (
1762+
_path: string,
1763+
_max: number,
1764+
_opts?: { raw?: boolean },
1765+
) => "transcript-bytes"),
1766+
};
1767+
1768+
const service = createPtyService({
1769+
projectRoot: "/tmp/test-project",
1770+
transcriptsDir: "/tmp/transcripts",
1771+
laneService: harness.laneService as any,
1772+
sessionService: sessionService as any,
1773+
logger: harness.logger as any,
1774+
broadcastData: harness.broadcastData,
1775+
broadcastExit: harness.broadcastExit,
1776+
onSessionEnded: harness.onSessionEnded,
1777+
onSessionRuntimeSignal: harness.onSessionRuntimeSignal,
1778+
loadPty: harness.loadPty as any,
1779+
});
1780+
1781+
return { ...harness, sessionService, sessionStore, service };
1782+
}
1783+
1784+
it("propagates chatSessionId through sessionService.create on a fresh terminal", async () => {
1785+
const { service, sessionService } = createChatHarness();
1786+
1787+
const created = await service.create({
1788+
laneId: "lane-1",
1789+
title: "Chat-linked terminal",
1790+
cols: 80,
1791+
rows: 24,
1792+
chatSessionId: "chat-42",
1793+
});
1794+
1795+
expect(sessionService.create).toHaveBeenCalledWith(
1796+
expect.objectContaining({
1797+
sessionId: created.sessionId,
1798+
chatSessionId: "chat-42",
1799+
}),
1800+
);
1801+
const active = service.activeForChat({ chatSessionId: "chat-42" });
1802+
expect(active).not.toBeNull();
1803+
expect(active!.terminalId).toBe(created.sessionId);
1804+
expect(active!.chatSessionId).toBe("chat-42");
1805+
expect(active!.active).toBe(true);
1806+
});
1807+
1808+
it("listTerminals filters to the requested chat session and orders active first", async () => {
1809+
const { service } = createChatHarness();
1810+
1811+
const a = await service.create({ laneId: "lane-1", title: "A", cols: 80, rows: 24, chatSessionId: "chat-1" });
1812+
const b = await service.create({ laneId: "lane-1", title: "B", cols: 80, rows: 24, chatSessionId: "chat-1" });
1813+
await service.create({ laneId: "lane-1", title: "C", cols: 80, rows: 24, chatSessionId: "chat-other" });
1814+
1815+
const list = service.listTerminals({ chatSessionId: "chat-1" });
1816+
const ids = list.map((s) => s.terminalId);
1817+
expect(ids).toContain(a.sessionId);
1818+
expect(ids).toContain(b.sessionId);
1819+
expect(ids).not.toContain(expect.stringMatching(/chat-other/));
1820+
// The most recently created terminal is the active one for the chat and must sort first.
1821+
expect(list[0]?.terminalId).toBe(b.sessionId);
1822+
expect(list[0]?.active).toBe(true);
1823+
});
1824+
1825+
it("readTerminal returns transcript bytes from `since` and reports nextSince", async () => {
1826+
const { service, sessionService } = createChatHarness();
1827+
const created = await service.create({
1828+
laneId: "lane-1",
1829+
title: "Reader",
1830+
cols: 80,
1831+
rows: 24,
1832+
chatSessionId: "chat-7",
1833+
});
1834+
sessionService.readTranscriptTail.mockResolvedValueOnce("0123456789");
1835+
1836+
const read = await service.readTerminal({ chatSessionId: "chat-7", since: 4, maxBytes: 1024 });
1837+
expect(read.terminalId).toBe(created.sessionId);
1838+
expect(read.data).toBe("456789");
1839+
expect(read.nextSince).toBe(4 + "456789".length);
1840+
});
1841+
1842+
it("writeTerminal routes data via the active chat terminal and the underlying PTY", async () => {
1843+
const { service, mockPty } = createChatHarness();
1844+
await service.create({
1845+
laneId: "lane-1",
1846+
title: "Writer",
1847+
cols: 80,
1848+
rows: 24,
1849+
chatSessionId: "chat-write",
1850+
});
1851+
1852+
const result = service.writeTerminal({ chatSessionId: "chat-write", data: "y\n" });
1853+
expect(result).toEqual({ ok: true });
1854+
expect(mockPty.write).toHaveBeenCalledWith("y\n");
1855+
});
1856+
1857+
it("signalTerminal sends ^C for SIGINT and forwards SIGTERM to pty.kill", async () => {
1858+
const { service, mockPty } = createChatHarness();
1859+
await service.create({
1860+
laneId: "lane-1",
1861+
title: "Signal",
1862+
cols: 80,
1863+
rows: 24,
1864+
chatSessionId: "chat-signal",
1865+
});
1866+
1867+
service.signalTerminal({ chatSessionId: "chat-signal", signal: "SIGINT" });
1868+
expect(mockPty.write).toHaveBeenCalledWith("\x03");
1869+
1870+
service.signalTerminal({ chatSessionId: "chat-signal", signal: "SIGTERM" });
1871+
expect(mockPty.kill).toHaveBeenCalledWith("SIGTERM");
1872+
});
1873+
1874+
it("fails loudly when chat terminal calls cannot resolve a target", async () => {
1875+
const { service } = createChatHarness();
1876+
1877+
await expect(service.readTerminal({ chatSessionId: "no-such-chat" })).rejects.toThrow(
1878+
/terminal\.read requires/,
1879+
);
1880+
expect(() => service.writeTerminal({ chatSessionId: "no-such-chat", data: "x" })).toThrow(
1881+
/terminal\.write requires/,
1882+
);
1883+
expect(() => service.signalTerminal({ chatSessionId: "no-such-chat", signal: "SIGINT" })).toThrow(
1884+
/No running terminal/,
1885+
);
1886+
expect(service.activeForChat({ chatSessionId: "no-such-chat" })).toBeNull();
1887+
});
1888+
});
17121889
});

0 commit comments

Comments
 (0)