Files
dev-puppeteer/packages/session/src/fake.test.ts
2026-05-10 01:27:43 +09:00

287 lines
9.2 KiB
TypeScript

import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { PromptEnvelope } from "@devflow/core";
import { DevflowError } from "@devflow/core";
import { FakeSessionAdapter } from "./fake.js";
const runId = "00000000-0000-4000-8000-000000000001";
const dedupKey = "a".repeat(64);
const secondDedupKey = "b".repeat(64);
function envelope(overrides: Partial<PromptEnvelope> = {}): PromptEnvelope {
return {
uuid: "00000000-0000-4000-8000-000000000010",
runId,
roleId: "implementer",
phaseKey: "implement",
attempt: 0,
expectedArtifact: join(mkdtempSync(join(tmpdir(), "devflow-fake-artifact-")), "artifact.json"),
expectedSchema: "dev/spec@1",
dedupKey,
instructions: "Build the artifact",
...overrides,
};
}
function makeFixtureRoot(): string {
const root = mkdtempSync(join(tmpdir(), "devflow-fake-fixtures-"));
const schemaDir = join(root, "dev", "spec@1");
mkdirSync(schemaDir, { recursive: true });
writeFileSync(
join(schemaDir, "ok.json"),
JSON.stringify({
summary: "Fake spec",
requirements: [{ id: "REQ-1", description: "Write the file" }],
acceptanceCriteria: ["File is written"],
risks: [],
}),
);
return root;
}
async function waitForFile(path: string): Promise<void> {
const deadline = Date.now() + 500;
while (Date.now() < deadline) {
if (existsSync(path)) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 5));
}
throw new Error(`Timed out waiting for ${path}`);
}
async function collect<T>(iterable: AsyncIterable<T>): Promise<T[]> {
const items: T[] = [];
for await (const item of iterable) {
items.push(item);
}
return items;
}
describe("FakeSessionAdapter", () => {
const tempRoots: string[] = [];
afterEach(() => {
for (const root of tempRoots.splice(0)) {
rmSync(root, { recursive: true, force: true });
}
});
it("writes the ok fixture for the prompt schema and records transcript chunks", async () => {
const fixtureRoot = makeFixtureRoot();
tempRoots.push(fixtureRoot);
const adapter = new FakeSessionAdapter({ fixtureRoot, writeDelayMs: 0 });
const handle = await adapter.start({
runId,
roleId: "implementer",
backend: "fake",
cwd: fixtureRoot,
});
const prompt = envelope();
tempRoots.push(join(prompt.expectedArtifact, ".."));
await expect(adapter.sendPrompt(handle, prompt)).resolves.toEqual({ promptId: dedupKey });
await waitForFile(prompt.expectedArtifact);
expect(JSON.parse(readFileSync(prompt.expectedArtifact, "utf8"))).toMatchObject({
summary: "Fake spec",
});
const chunks = await collect(adapter.capture(handle, 0n));
expect(chunks.map((chunk) => chunk.content).join("\n")).toContain(
`[fake] received prompt ${prompt.uuid}; will write ${prompt.expectedArtifact} in 0ms`,
);
expect(chunks.every((chunk, index) => chunk.seq === BigInt(index + 1))).toBe(true);
});
it("refuses duplicate prompt dedup keys for the same session", async () => {
const fixtureRoot = makeFixtureRoot();
tempRoots.push(fixtureRoot);
const adapter = new FakeSessionAdapter({ fixtureRoot, writeDelayMs: 0 });
const handle = await adapter.start({
runId,
roleId: "implementer",
backend: "fake",
cwd: fixtureRoot,
});
const first = envelope();
const duplicate = envelope({
uuid: "00000000-0000-4000-8000-000000000011",
dedupKey,
});
tempRoots.push(join(first.expectedArtifact, ".."), join(duplicate.expectedArtifact, ".."));
await adapter.sendPrompt(handle, first);
await expect(adapter.sendPrompt(handle, duplicate)).rejects.toMatchObject({
code: "duplicate_prompt_dedup_key",
});
await waitForFile(first.expectedArtifact);
});
it("preserves prompt dedup history across crash and rebootstrap recovery", async () => {
const fixtureRoot = makeFixtureRoot();
tempRoots.push(fixtureRoot);
const adapter = new FakeSessionAdapter({ fixtureRoot, writeDelayMs: 0 });
const handle = await adapter.start({
runId,
roleId: "implementer",
backend: "fake",
cwd: fixtureRoot,
});
const crash = envelope({
dedupKey: "c".repeat(64),
instructions: "Scenario: crash\nCrash",
});
await expect(adapter.sendPrompt(handle, crash)).rejects.toMatchObject({
code: "prompt_send_transient",
});
await expect(adapter.sendPrompt(handle, crash)).rejects.toMatchObject({
code: "duplicate_prompt_dedup_key",
});
const ok = envelope({ dedupKey: "d".repeat(64) });
tempRoots.push(join(ok.expectedArtifact, ".."));
await adapter.sendPrompt(handle, ok);
await waitForFile(ok.expectedArtifact);
await adapter.rebootstrap(handle);
await expect(adapter.sendPrompt(handle, ok)).rejects.toMatchObject({
code: "duplicate_prompt_dedup_key",
});
});
it("rejects prompts whose run or role do not match the session", async () => {
const fixtureRoot = makeFixtureRoot();
tempRoots.push(fixtureRoot);
const adapter = new FakeSessionAdapter({ fixtureRoot, writeDelayMs: 0 });
const handle = await adapter.start({
runId,
roleId: "implementer",
backend: "fake",
cwd: fixtureRoot,
});
await expect(
adapter.sendPrompt(
handle,
envelope({
runId: "00000000-0000-4000-8000-000000000099",
dedupKey: "e".repeat(64),
}),
),
).rejects.toMatchObject({ code: "prompt_session_mismatch" });
await expect(
adapter.sendPrompt(
handle,
envelope({
roleId: "reviewer",
dedupKey: "f".repeat(64),
}),
),
).rejects.toMatchObject({ code: "prompt_session_mismatch" });
});
it("fails sendPrompt immediately when an ok fixture is missing", async () => {
const fixtureRoot = mkdtempSync(join(tmpdir(), "devflow-empty-fake-fixtures-"));
tempRoots.push(fixtureRoot);
const adapter = new FakeSessionAdapter({ fixtureRoot, writeDelayMs: 0 });
const handle = await adapter.start({
runId,
roleId: "implementer",
backend: "fake",
cwd: fixtureRoot,
});
const prompt = envelope();
tempRoots.push(join(prompt.expectedArtifact, ".."));
await expect(adapter.sendPrompt(handle, prompt)).rejects.toMatchObject({
class: "fatal",
code: "fake_fixture_missing",
});
await new Promise((resolve) => setTimeout(resolve, 20));
expect(existsSync(prompt.expectedArtifact)).toBe(false);
});
it("supports invalid, timeout, and crash sentinel scenarios", async () => {
const fixtureRoot = makeFixtureRoot();
tempRoots.push(fixtureRoot);
const adapter = new FakeSessionAdapter({ fixtureRoot, writeDelayMs: 0 });
const handle = await adapter.start({
runId,
roleId: "implementer",
backend: "fake",
cwd: fixtureRoot,
});
const invalid = envelope({
dedupKey: secondDedupKey,
instructions: "Scenario: invalid\nBuild an invalid artifact",
});
tempRoots.push(join(invalid.expectedArtifact, ".."));
await adapter.sendPrompt(handle, invalid);
await waitForFile(invalid.expectedArtifact);
expect(JSON.parse(readFileSync(invalid.expectedArtifact, "utf8"))).toEqual({
fake: "invalid",
});
const timeout = envelope({
dedupKey: "c".repeat(64),
instructions: "Scenario: timeout\nDo not write",
});
tempRoots.push(join(timeout.expectedArtifact, ".."));
await adapter.sendPrompt(handle, timeout);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(existsSync(timeout.expectedArtifact)).toBe(false);
const crash = envelope({
dedupKey: "d".repeat(64),
instructions: "Scenario: crash\nCrash",
});
await expect(adapter.sendPrompt(handle, crash)).rejects.toBeInstanceOf(DevflowError);
await expect(
adapter.sendPrompt(handle, {
...crash,
dedupKey: "e".repeat(64),
}),
).rejects.toMatchObject({
class: "recoverable",
code: "prompt_send_transient",
});
});
it("probes, resumes, rebootstraps, captures from a sequence, and disposes sessions", async () => {
const fixtureRoot = makeFixtureRoot();
tempRoots.push(fixtureRoot);
const adapter = new FakeSessionAdapter({ fixtureRoot, writeDelayMs: 0 });
const handle = await adapter.start({
runId,
roleId: "implementer",
backend: "fake",
cwd: fixtureRoot,
envelopePrelude: "Follow the fake protocol",
});
expect(await adapter.resume(handle)).toEqual(handle);
expect(await adapter.probe(handle)).toMatchObject({ alive: true, paneActive: true });
const rebootstrapped = await adapter.rebootstrap(handle);
expect(rebootstrapped.sessionId).toBe(handle.sessionId);
expect(await collect(adapter.capture(handle, 1n))).toEqual(
expect.arrayContaining([
expect.objectContaining({
seq: 2n,
content: "[fake] rebootstrap complete",
}),
]),
);
await adapter.dispose(handle);
expect(await adapter.probe(handle)).toMatchObject({ alive: false, paneActive: false });
});
});