feat: add fake session adapter
This commit is contained in:
41
packages/session/src/adapter.ts
Normal file
41
packages/session/src/adapter.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Backend, PromptEnvelope } from "@devflow/core";
|
||||
|
||||
export interface SessionAdapter {
|
||||
start(input: StartInput): Promise<SessionHandle>;
|
||||
sendPrompt(handle: SessionHandle, envelope: PromptEnvelope): Promise<{ promptId: string }>;
|
||||
probe(handle: SessionHandle): Promise<ProbeResult>;
|
||||
resume(handle: SessionHandle): Promise<SessionHandle>;
|
||||
rebootstrap(handle: SessionHandle): Promise<SessionHandle>;
|
||||
capture(handle: SessionHandle, fromSeq: bigint): AsyncIterable<TranscriptChunk>;
|
||||
dispose(handle: SessionHandle): Promise<void>;
|
||||
}
|
||||
|
||||
export interface StartInput {
|
||||
runId: string;
|
||||
roleId: string;
|
||||
backend: Backend;
|
||||
cwd: string;
|
||||
expectedArtifactPath?: string;
|
||||
expectedSchema?: string;
|
||||
envelopePrelude?: string;
|
||||
}
|
||||
|
||||
export interface SessionHandle {
|
||||
sessionId: string;
|
||||
pid?: number;
|
||||
tmuxSession?: string;
|
||||
tmuxWindow?: string;
|
||||
}
|
||||
|
||||
export interface ProbeResult {
|
||||
alive: boolean;
|
||||
paneActive: boolean;
|
||||
lastOutputAt?: Date;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface TranscriptChunk {
|
||||
seq: bigint;
|
||||
content: string;
|
||||
capturedAt: Date;
|
||||
}
|
||||
286
packages/session/src/fake.test.ts
Normal file
286
packages/session/src/fake.test.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
295
packages/session/src/fake.ts
Normal file
295
packages/session/src/fake.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
|
||||
import { DevflowError, type PromptEnvelope } from "@devflow/core";
|
||||
|
||||
import type {
|
||||
ProbeResult,
|
||||
SessionAdapter,
|
||||
SessionHandle,
|
||||
StartInput,
|
||||
TranscriptChunk,
|
||||
} from "./adapter.js";
|
||||
|
||||
export interface FakeSessionAdapterOptions {
|
||||
fixtureRoot?: string;
|
||||
writeDelayMs?: number;
|
||||
sessionIdFactory?: () => string;
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
interface FakeSessionRecord {
|
||||
handle: SessionHandle;
|
||||
runId: string;
|
||||
roleId: string;
|
||||
alive: boolean;
|
||||
disposed: boolean;
|
||||
transcript: TranscriptChunk[];
|
||||
sentDedupKeys: Set<string>;
|
||||
timers: Set<NodeJS.Timeout>;
|
||||
lastOutputAt?: Date;
|
||||
}
|
||||
|
||||
export class FakeSessionAdapter implements SessionAdapter {
|
||||
private readonly fixtureRoot: string;
|
||||
private readonly writeDelayMs: number;
|
||||
private readonly sessionIdFactory: () => string;
|
||||
private readonly now: () => Date;
|
||||
private readonly sessions = new Map<string, FakeSessionRecord>();
|
||||
|
||||
constructor(options: FakeSessionAdapterOptions = {}) {
|
||||
this.fixtureRoot = options.fixtureRoot ?? defaultFixtureRoot();
|
||||
this.writeDelayMs = options.writeDelayMs ?? 50;
|
||||
this.sessionIdFactory = options.sessionIdFactory ?? randomUUID;
|
||||
this.now = options.now ?? (() => new Date());
|
||||
}
|
||||
|
||||
async start(input: StartInput): Promise<SessionHandle> {
|
||||
if (input.backend !== "fake") {
|
||||
throw new DevflowError("FakeSessionAdapter only supports the fake backend", {
|
||||
class: "fatal",
|
||||
code: "backend_unavailable",
|
||||
runId: input.runId,
|
||||
});
|
||||
}
|
||||
|
||||
const handle: SessionHandle = { sessionId: this.sessionIdFactory() };
|
||||
const record: FakeSessionRecord = {
|
||||
handle,
|
||||
runId: input.runId,
|
||||
roleId: input.roleId,
|
||||
alive: true,
|
||||
disposed: false,
|
||||
transcript: [],
|
||||
sentDedupKeys: new Set(),
|
||||
timers: new Set(),
|
||||
};
|
||||
this.sessions.set(handle.sessionId, record);
|
||||
this.appendTranscript(record, `[fake] session started for ${input.roleId} in ${input.cwd}`);
|
||||
return handle;
|
||||
}
|
||||
|
||||
async sendPrompt(handle: SessionHandle, envelope: PromptEnvelope): Promise<{ promptId: string }> {
|
||||
const record = this.requireLiveSession(handle);
|
||||
if (envelope.runId !== record.runId || envelope.roleId !== record.roleId) {
|
||||
throw new DevflowError("Prompt does not match fake session run or role", {
|
||||
class: "fatal",
|
||||
code: "prompt_session_mismatch",
|
||||
runId: envelope.runId,
|
||||
});
|
||||
}
|
||||
if (record.sentDedupKeys.has(envelope.dedupKey)) {
|
||||
throw new DevflowError("Duplicate prompt dedup key refused by fake session", {
|
||||
class: "recoverable",
|
||||
code: "duplicate_prompt_dedup_key",
|
||||
runId: record.runId,
|
||||
});
|
||||
}
|
||||
|
||||
const scenarioName = scenarioFromInstructions(envelope.instructions);
|
||||
record.sentDedupKeys.add(envelope.dedupKey);
|
||||
|
||||
if (scenarioName === "crash") {
|
||||
this.appendTranscript(record, `[fake] received prompt ${envelope.uuid}; crashing`);
|
||||
throw new DevflowError("Fake session crash scenario", {
|
||||
class: "recoverable",
|
||||
code: "prompt_send_transient",
|
||||
runId: envelope.runId,
|
||||
});
|
||||
}
|
||||
|
||||
if (scenarioName === "timeout") {
|
||||
this.appendTranscript(record, `[fake] received prompt ${envelope.uuid}; timeout`);
|
||||
return { promptId: envelope.dedupKey };
|
||||
}
|
||||
|
||||
const fixturePath =
|
||||
scenarioName === "invalid"
|
||||
? undefined
|
||||
: resolveFixturePath(
|
||||
this.fixtureRoot,
|
||||
envelope.expectedSchema,
|
||||
scenarioName,
|
||||
envelope.runId,
|
||||
);
|
||||
|
||||
this.appendTranscript(
|
||||
record,
|
||||
`[fake] received prompt ${envelope.uuid}; will write ${envelope.expectedArtifact} in ${this.writeDelayMs}ms`,
|
||||
);
|
||||
const timer = setTimeout(() => {
|
||||
record.timers.delete(timer);
|
||||
if (!record.alive || record.disposed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (scenarioName === "invalid") {
|
||||
writeJsonArtifact(envelope.expectedArtifact, { fake: "invalid" });
|
||||
} else {
|
||||
copyFixtureArtifact(fixturePath, envelope.expectedArtifact);
|
||||
}
|
||||
} catch (cause) {
|
||||
record.alive = false;
|
||||
this.appendTranscript(
|
||||
record,
|
||||
`[fake] failed to write artifact ${envelope.expectedArtifact}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.appendTranscript(record, `[fake] wrote artifact ${envelope.expectedArtifact}`);
|
||||
}, this.writeDelayMs);
|
||||
record.timers.add(timer);
|
||||
|
||||
return { promptId: envelope.dedupKey };
|
||||
}
|
||||
|
||||
async probe(handle: SessionHandle): Promise<ProbeResult> {
|
||||
const record = this.sessions.get(handle.sessionId);
|
||||
if (record === undefined || !record.alive || record.disposed) {
|
||||
return { alive: false, paneActive: false, hint: "fake session is not active" };
|
||||
}
|
||||
const result: ProbeResult = { alive: true, paneActive: true };
|
||||
if (record.lastOutputAt !== undefined) {
|
||||
return { ...result, lastOutputAt: record.lastOutputAt };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async resume(handle: SessionHandle): Promise<SessionHandle> {
|
||||
return this.requireLiveSession(handle).handle;
|
||||
}
|
||||
|
||||
async rebootstrap(handle: SessionHandle): Promise<SessionHandle> {
|
||||
const record = this.sessions.get(handle.sessionId);
|
||||
if (record === undefined) {
|
||||
throw new DevflowError("Cannot rebootstrap unknown fake session", {
|
||||
class: "recoverable",
|
||||
code: "pane_briefly_unresponsive",
|
||||
});
|
||||
}
|
||||
for (const timer of record.timers) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
record.timers.clear();
|
||||
record.alive = true;
|
||||
record.disposed = false;
|
||||
this.appendTranscript(record, "[fake] rebootstrap complete");
|
||||
return record.handle;
|
||||
}
|
||||
|
||||
async *capture(handle: SessionHandle, fromSeq: bigint): AsyncIterable<TranscriptChunk> {
|
||||
const record = this.sessions.get(handle.sessionId);
|
||||
if (record === undefined) {
|
||||
return;
|
||||
}
|
||||
for (const chunk of record.transcript) {
|
||||
if (chunk.seq > fromSeq) {
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async dispose(handle: SessionHandle): Promise<void> {
|
||||
const record = this.sessions.get(handle.sessionId);
|
||||
if (record === undefined) {
|
||||
return;
|
||||
}
|
||||
for (const timer of record.timers) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
record.timers.clear();
|
||||
record.alive = false;
|
||||
record.disposed = true;
|
||||
}
|
||||
|
||||
private requireLiveSession(handle: SessionHandle): FakeSessionRecord {
|
||||
const record = this.sessions.get(handle.sessionId);
|
||||
if (record === undefined || !record.alive || record.disposed) {
|
||||
throw new DevflowError("Fake session is not active", {
|
||||
class: "recoverable",
|
||||
code: "pane_briefly_unresponsive",
|
||||
});
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
private appendTranscript(record: FakeSessionRecord, content: string): void {
|
||||
const capturedAt = this.now();
|
||||
record.lastOutputAt = capturedAt;
|
||||
record.transcript.push({
|
||||
seq: BigInt(record.transcript.length + 1),
|
||||
content,
|
||||
capturedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function scenarioFromInstructions(instructions: string): string {
|
||||
const match = /^Scenario:\s*([A-Za-z0-9_-]+)\s*$/m.exec(instructions);
|
||||
return match?.[1] ?? "ok";
|
||||
}
|
||||
|
||||
function resolveFixturePath(
|
||||
fixtureRoot: string,
|
||||
expectedSchema: string,
|
||||
scenarioName: string,
|
||||
runId: string,
|
||||
): string {
|
||||
const fixturePath = join(fixtureRoot, expectedSchema, `${scenarioName}.json`);
|
||||
if (!existsSync(fixturePath) || !statSync(fixturePath).isFile()) {
|
||||
throw new DevflowError(`Missing fake artifact fixture ${fixturePath}`, {
|
||||
class: "fatal",
|
||||
code: "fake_fixture_missing",
|
||||
runId,
|
||||
});
|
||||
}
|
||||
return fixturePath;
|
||||
}
|
||||
|
||||
function copyFixtureArtifact(fixturePath: string | undefined, expectedArtifact: string): void {
|
||||
if (fixturePath === undefined) {
|
||||
throw new DevflowError("Missing resolved fake artifact fixture path", {
|
||||
class: "fatal",
|
||||
code: "fake_fixture_missing",
|
||||
});
|
||||
}
|
||||
mkdirSync(dirname(expectedArtifact), { recursive: true });
|
||||
copyFileSync(fixturePath, expectedArtifact);
|
||||
}
|
||||
|
||||
function writeJsonArtifact(path: string, value: unknown): void {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, JSON.stringify(value));
|
||||
}
|
||||
|
||||
function defaultFixtureRoot(): string {
|
||||
const workspaceRoot = findWorkspaceRoot(process.cwd());
|
||||
return join(workspaceRoot, "tests", "fixtures", "fake-artifacts");
|
||||
}
|
||||
|
||||
function findWorkspaceRoot(start: string): string {
|
||||
let current = resolve(start);
|
||||
while (true) {
|
||||
const packageJsonPath = join(current, "package.json");
|
||||
const workspacePath = join(current, "pnpm-workspace.yaml");
|
||||
if (existsSync(packageJsonPath) && existsSync(workspacePath)) {
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { name?: unknown };
|
||||
if (packageJson.name === "devflow") {
|
||||
return current;
|
||||
}
|
||||
}
|
||||
const parent = dirname(current);
|
||||
if (parent === current) {
|
||||
return resolve(start);
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
2
packages/session/src/index.ts
Normal file
2
packages/session/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./adapter.js";
|
||||
export * from "./fake.js";
|
||||
Reference in New Issue
Block a user