314 lines
10 KiB
TypeScript
314 lines
10 KiB
TypeScript
/**
|
|
* dev-engine-wrapper.test.ts — Contract tests for the dev engine wrapper layer (S02).
|
|
*
|
|
* Tests bridgeDispatchAction mapping, DevWorkflowEngine delegation,
|
|
* DevExecutionPolicy stubs, resolver routing, kill switch, and
|
|
* auto.ts engine ID accessors.
|
|
*/
|
|
|
|
import test, { describe, before, after } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
|
|
// ── bridgeDispatchAction mapping ────────────────────────────────────────────
|
|
|
|
describe("bridgeDispatchAction", () => {
|
|
test("maps dispatch action with step fields", async () => {
|
|
const { bridgeDispatchAction } = await import(
|
|
"../dev-workflow-engine.ts"
|
|
);
|
|
const result = bridgeDispatchAction({
|
|
action: "dispatch",
|
|
unitType: "execute-task",
|
|
unitId: "T01",
|
|
prompt: "do stuff",
|
|
matchedRule: "foo",
|
|
} as any);
|
|
|
|
assert.equal(result.action, "dispatch");
|
|
assert.ok("step" in result);
|
|
const step = (result as any).step;
|
|
assert.equal(step.unitType, "execute-task");
|
|
assert.equal(step.unitId, "T01");
|
|
assert.equal(step.prompt, "do stuff");
|
|
});
|
|
|
|
test("maps stop action with reason and level", async () => {
|
|
const { bridgeDispatchAction } = await import(
|
|
"../dev-workflow-engine.ts"
|
|
);
|
|
const result = bridgeDispatchAction({
|
|
action: "stop",
|
|
reason: "done",
|
|
level: "info",
|
|
matchedRule: "bar",
|
|
} as any);
|
|
|
|
assert.equal(result.action, "stop");
|
|
assert.equal((result as any).reason, "done");
|
|
assert.equal((result as any).level, "info");
|
|
});
|
|
|
|
test("maps skip action", async () => {
|
|
const { bridgeDispatchAction } = await import(
|
|
"../dev-workflow-engine.ts"
|
|
);
|
|
const result = bridgeDispatchAction({
|
|
action: "skip",
|
|
matchedRule: "baz",
|
|
} as any);
|
|
|
|
assert.equal(result.action, "skip");
|
|
});
|
|
});
|
|
|
|
// ── DevWorkflowEngine ───────────────────────────────────────────────────────
|
|
|
|
describe("DevWorkflowEngine", () => {
|
|
test("engineId is 'dev'", async () => {
|
|
const { DevWorkflowEngine } = await import("../dev-workflow-engine.ts");
|
|
const engine = new DevWorkflowEngine();
|
|
assert.equal(engine.engineId, "dev");
|
|
});
|
|
|
|
test("deriveState returns EngineState with expected fields", async (t) => {
|
|
const { DevWorkflowEngine } = await import("../dev-workflow-engine.ts");
|
|
const engine = new DevWorkflowEngine();
|
|
|
|
// Create a minimal temp .gsd structure for deriveState
|
|
const tempDir = mkdtempSync(join(tmpdir(), "gsd-engine-test-"));
|
|
mkdirSync(join(tempDir, ".gsd", "milestones"), { recursive: true });
|
|
|
|
t.after(() => rmSync(tempDir, { recursive: true, force: true }));
|
|
|
|
const state = await engine.deriveState(tempDir);
|
|
|
|
assert.equal(typeof state.phase, "string", "phase should be a string");
|
|
assert.ok(
|
|
"currentMilestoneId" in state,
|
|
"state should have currentMilestoneId",
|
|
);
|
|
assert.ok(
|
|
"activeSliceId" in state,
|
|
"state should have activeSliceId",
|
|
);
|
|
assert.ok(
|
|
"activeTaskId" in state,
|
|
"state should have activeTaskId",
|
|
);
|
|
assert.equal(
|
|
typeof state.isComplete,
|
|
"boolean",
|
|
"isComplete should be boolean",
|
|
);
|
|
assert.ok("raw" in state, "state should have raw field");
|
|
});
|
|
|
|
test("reconcile returns continue for non-complete state", async () => {
|
|
const { DevWorkflowEngine } = await import("../dev-workflow-engine.ts");
|
|
const engine = new DevWorkflowEngine();
|
|
|
|
const state = {
|
|
phase: "executing",
|
|
currentMilestoneId: "M001",
|
|
activeSliceId: "S01",
|
|
activeTaskId: "T01",
|
|
isComplete: false,
|
|
raw: {},
|
|
};
|
|
|
|
const result = await engine.reconcile(state, {
|
|
unitType: "execute-task",
|
|
unitId: "T01",
|
|
startedAt: Date.now() - 1000,
|
|
finishedAt: Date.now(),
|
|
});
|
|
|
|
assert.equal(result.outcome, "continue");
|
|
});
|
|
|
|
test("reconcile returns milestone-complete for complete state", async () => {
|
|
const { DevWorkflowEngine } = await import("../dev-workflow-engine.ts");
|
|
const engine = new DevWorkflowEngine();
|
|
|
|
const state = {
|
|
phase: "complete",
|
|
currentMilestoneId: "M001",
|
|
activeSliceId: null,
|
|
activeTaskId: null,
|
|
isComplete: true,
|
|
raw: {},
|
|
};
|
|
|
|
const result = await engine.reconcile(state, {
|
|
unitType: "execute-task",
|
|
unitId: "T01",
|
|
startedAt: Date.now() - 1000,
|
|
finishedAt: Date.now(),
|
|
});
|
|
|
|
assert.equal(result.outcome, "milestone-complete");
|
|
});
|
|
|
|
test("getDisplayMetadata returns expected fields", async () => {
|
|
const { DevWorkflowEngine } = await import("../dev-workflow-engine.ts");
|
|
const engine = new DevWorkflowEngine();
|
|
|
|
const state = {
|
|
phase: "executing",
|
|
currentMilestoneId: "M001",
|
|
activeSliceId: "S01",
|
|
activeTaskId: "T01",
|
|
isComplete: false,
|
|
raw: {},
|
|
};
|
|
|
|
const meta = engine.getDisplayMetadata(state);
|
|
|
|
assert.ok("engineLabel" in meta, "should have engineLabel");
|
|
assert.ok("currentPhase" in meta, "should have currentPhase");
|
|
assert.ok("progressSummary" in meta, "should have progressSummary");
|
|
assert.ok("stepCount" in meta, "should have stepCount");
|
|
assert.equal(meta.engineLabel, "GSD Dev");
|
|
});
|
|
});
|
|
|
|
// ── DevExecutionPolicy stubs ────────────────────────────────────────────────
|
|
|
|
describe("DevExecutionPolicy", () => {
|
|
test("verify returns 'continue'", async () => {
|
|
const { DevExecutionPolicy } = await import(
|
|
"../dev-execution-policy.ts"
|
|
);
|
|
const policy = new DevExecutionPolicy();
|
|
const result = await policy.verify("execute-task", "T01", {
|
|
basePath: "/tmp",
|
|
});
|
|
assert.equal(result, "continue");
|
|
});
|
|
|
|
test("selectModel returns null", async () => {
|
|
const { DevExecutionPolicy } = await import(
|
|
"../dev-execution-policy.ts"
|
|
);
|
|
const policy = new DevExecutionPolicy();
|
|
const result = await policy.selectModel("execute-task", "T01", {
|
|
basePath: "/tmp",
|
|
});
|
|
assert.equal(result, null);
|
|
});
|
|
|
|
test("recover returns { outcome: 'retry' }", async () => {
|
|
const { DevExecutionPolicy } = await import(
|
|
"../dev-execution-policy.ts"
|
|
);
|
|
const policy = new DevExecutionPolicy();
|
|
const result = await policy.recover("execute-task", "T01", {
|
|
basePath: "/tmp",
|
|
});
|
|
assert.deepEqual(result, { outcome: "retry" });
|
|
});
|
|
|
|
test("closeout returns { committed: false, artifacts: [] }", async () => {
|
|
const { DevExecutionPolicy } = await import(
|
|
"../dev-execution-policy.ts"
|
|
);
|
|
const policy = new DevExecutionPolicy();
|
|
const result = await policy.closeout("execute-task", "T01", {
|
|
basePath: "/tmp",
|
|
startedAt: Date.now(),
|
|
});
|
|
assert.deepEqual(result, { committed: false, artifacts: [] });
|
|
});
|
|
|
|
test("prepareWorkspace resolves without error", async () => {
|
|
const { DevExecutionPolicy } = await import(
|
|
"../dev-execution-policy.ts"
|
|
);
|
|
const policy = new DevExecutionPolicy();
|
|
await assert.doesNotReject(
|
|
() => policy.prepareWorkspace("/tmp", "M001"),
|
|
"prepareWorkspace should resolve without error",
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── Resolver routing ────────────────────────────────────────────────────────
|
|
|
|
describe("Resolver routing", () => {
|
|
test("resolveEngine returns dev engine for null activeEngineId", async () => {
|
|
const { resolveEngine } = await import("../engine-resolver.ts");
|
|
const result = resolveEngine({ activeEngineId: null });
|
|
assert.ok(result.engine, "should return engine");
|
|
assert.ok(result.policy, "should return policy");
|
|
assert.equal(result.engine.engineId, "dev");
|
|
});
|
|
|
|
test("resolveEngine returns dev engine for 'dev' activeEngineId", async () => {
|
|
const { resolveEngine } = await import("../engine-resolver.ts");
|
|
const result = resolveEngine({ activeEngineId: "dev" });
|
|
assert.ok(result.engine, "should return engine");
|
|
assert.ok(result.policy, "should return policy");
|
|
assert.equal(result.engine.engineId, "dev");
|
|
});
|
|
|
|
test("resolveEngine throws for unknown activeEngineId without activeRunDir", async () => {
|
|
const { resolveEngine } = await import("../engine-resolver.ts");
|
|
assert.throws(
|
|
() => resolveEngine({ activeEngineId: "unknown" }),
|
|
/requires activeRunDir/,
|
|
"should throw when activeRunDir is missing for non-dev engine",
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── Kill switch ─────────────────────────────────────────────────────────────
|
|
|
|
describe("Kill switch (GSD_ENGINE_BYPASS)", () => {
|
|
const originalBypass = process.env.GSD_ENGINE_BYPASS;
|
|
|
|
after(() => {
|
|
// Restore original env var state
|
|
if (originalBypass === undefined) {
|
|
delete process.env.GSD_ENGINE_BYPASS;
|
|
} else {
|
|
process.env.GSD_ENGINE_BYPASS = originalBypass;
|
|
}
|
|
});
|
|
|
|
test("GSD_ENGINE_BYPASS=1 does not affect resolveEngine (bypass checked in autoLoop)", async (t) => {
|
|
const { resolveEngine } = await import("../engine-resolver.ts");
|
|
process.env.GSD_ENGINE_BYPASS = "1";
|
|
t.after(() => delete process.env.GSD_ENGINE_BYPASS);
|
|
|
|
// resolveEngine should still resolve normally — bypass is checked in autoLoop
|
|
const { engine } = resolveEngine({ activeEngineId: null });
|
|
assert.ok(engine, "should return an engine even with bypass set");
|
|
});
|
|
});
|
|
|
|
// ── auto.ts engine ID accessors ─────────────────────────────────────────────
|
|
|
|
describe("auto.ts engine ID accessors", () => {
|
|
test("setActiveEngineId / getActiveEngineId round-trip", async () => {
|
|
const { setActiveEngineId, getActiveEngineId } = await import(
|
|
"../auto.ts"
|
|
);
|
|
|
|
setActiveEngineId("dev");
|
|
assert.equal(
|
|
getActiveEngineId(),
|
|
"dev",
|
|
"getActiveEngineId should return 'dev' after setting",
|
|
);
|
|
|
|
setActiveEngineId(null);
|
|
assert.equal(
|
|
getActiveEngineId(),
|
|
null,
|
|
"getActiveEngineId should return null after setting null",
|
|
);
|
|
});
|
|
});
|