183 lines
7.6 KiB
TypeScript
183 lines
7.6 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { spawnSync } from "node:child_process";
|
|
import { randomUUID } from "node:crypto";
|
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import test from "node:test";
|
|
import { withTestEvidence } from "../src/ambient.ts";
|
|
import { createTestEvidence, judgeTestRun } from "../src/test-evidence.ts";
|
|
import { validate } from "../src/validate.ts";
|
|
import type { VisionRequest } from "../src/validate.ts";
|
|
import type { ScreenshotArtifact } from "../src/screenshot.ts";
|
|
|
|
function testScreenshot(hash: string): ScreenshotArtifact {
|
|
return {
|
|
png: Buffer.from("canned png"),
|
|
hash,
|
|
route: "#/workspace/ws_test/session",
|
|
visibleText: "OpenWork composer",
|
|
at: "2026-07-29T12:00:00.000Z",
|
|
};
|
|
}
|
|
|
|
test("validate describes, matches, computes failures, and caches", async () => {
|
|
const unique = randomUUID();
|
|
const expectations = [`Composer is visible ${unique}`, `No error is visible ${unique}`];
|
|
const requests: VisionRequest[] = [];
|
|
const ask = async (request: VisionRequest): Promise<string> => {
|
|
requests.push(request);
|
|
if (requests.length === 1) return JSON.stringify({ description: "A composer and an error banner are visible." });
|
|
return JSON.stringify({
|
|
results: [
|
|
{ expectation: expectations[0], passed: true, evidence: "The composer is centered on screen." },
|
|
{ expectation: expectations[1], passed: false, evidence: "An error banner is visible." },
|
|
],
|
|
});
|
|
};
|
|
|
|
const first = await validate(testScreenshot(unique), expectations, { ask });
|
|
assert.equal(first.cached, false);
|
|
assert.equal(first.ok, false);
|
|
assert.equal(first.description, "A composer and an error banner are visible.");
|
|
assert.equal(first.results[0]?.passed, true);
|
|
assert.equal(first.results[1]?.passed, false);
|
|
assert.match(first.why, /No error is visible/);
|
|
assert.match(first.why, /An error banner is visible/);
|
|
assert.equal(requests.length, 2);
|
|
assert.equal(requests[0]?.prompt.includes(unique), false, "description request must not know the expectations");
|
|
assert.equal(requests[1]?.prompt.includes(unique), true);
|
|
|
|
const second = await validate(testScreenshot(unique), expectations, { ask });
|
|
assert.equal(second.cached, true);
|
|
assert.equal(second.ok, false);
|
|
assert.equal(requests.length, 2, "cache hit must not call the transport");
|
|
});
|
|
|
|
test("validate rejects malformed model verdicts clearly", async () => {
|
|
let calls = 0;
|
|
const ask = async (): Promise<string> => {
|
|
calls += 1;
|
|
return calls === 1 ? JSON.stringify({ description: "A visible app window." }) : "not-json";
|
|
};
|
|
await assert.rejects(
|
|
() => validate(testScreenshot(randomUUID()), [`Visible app ${randomUUID()}`], { ask }),
|
|
/Vision model verdict response was not valid JSON/,
|
|
);
|
|
});
|
|
|
|
test("defer mode judges a caller-provided deterministic witness inline", async () => {
|
|
const previousMode = process.env.OPENWORK_EVAL_VISION;
|
|
try {
|
|
process.env.OPENWORK_EVAL_VISION = "defer";
|
|
const expectation = `Synthetic screenshot is visible ${randomUUID()}`;
|
|
let calls = 0;
|
|
const visualEvidence = await validate(testScreenshot(randomUUID()), [expectation], {
|
|
bypassCache: true,
|
|
ask: async () => {
|
|
calls += 1;
|
|
return calls === 1
|
|
? JSON.stringify({ description: "A synthetic screenshot is visible." })
|
|
: JSON.stringify({ results: [{ expectation, passed: false, evidence: "The expected detail is absent." }] });
|
|
},
|
|
});
|
|
|
|
assert.equal(visualEvidence.ok, false);
|
|
assert.equal(visualEvidence.results[0]?.passed, false);
|
|
assert.equal(visualEvidence.deferred, undefined);
|
|
assert.equal(calls, 2);
|
|
} finally {
|
|
if (previousMode === undefined) delete process.env.OPENWORK_EVAL_VISION;
|
|
else process.env.OPENWORK_EVAL_VISION = previousMode;
|
|
}
|
|
});
|
|
|
|
test("deferred validation records pending expectations that the judge resolves", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "openwork-deferred-vision-"));
|
|
const previousMode = process.env.OPENWORK_EVAL_VISION;
|
|
try {
|
|
const expectation = `Synthetic screenshot is visible ${randomUUID()}`;
|
|
const screenshot = testScreenshot(randomUUID());
|
|
const testEvidence = createTestEvidence({ name: "deferred vision", outDir: dir });
|
|
testEvidence.recordScreenshot(screenshot);
|
|
process.env.OPENWORK_EVAL_VISION = "defer";
|
|
const deferred = await withTestEvidence(testEvidence, () => validate(screenshot, [expectation], { bypassCache: true }));
|
|
assert.equal(deferred.ok, true);
|
|
assert.equal(deferred.deferred, true);
|
|
assert.equal(deferred.why, "vision judgment deferred");
|
|
await testEvidence.close();
|
|
|
|
const pendingTestRun = await readFile(join(dir, "test-run.json"), "utf8");
|
|
assert.match(pendingTestRun, /"state": "pending"/);
|
|
assert.match(pendingTestRun, /"pendingArtifacts": 1/);
|
|
assert.match(pendingTestRun, /"pendingJudgments": 1/);
|
|
|
|
const providerError = await judgeTestRun(dir, {
|
|
ask: async () => {
|
|
throw new Error("stub provider unavailable");
|
|
},
|
|
});
|
|
assert.equal(providerError.judgedValidations, 0);
|
|
assert.equal(providerError.pendingValidations, 1);
|
|
assert.match(providerError.errors[0], /stub provider unavailable/);
|
|
|
|
const requests: VisionRequest[] = [];
|
|
const judged = await judgeTestRun(dir, {
|
|
ask: async (request) => {
|
|
requests.push(request);
|
|
return requests.length === 1
|
|
? JSON.stringify({ description: "A synthetic screenshot is visible." })
|
|
: JSON.stringify({ results: [{ expectation, passed: true, evidence: "The synthetic screenshot is present." }] });
|
|
},
|
|
});
|
|
assert.equal(judged.judgedValidations, 1);
|
|
assert.equal(judged.failedValidations, 0);
|
|
assert.equal(judged.pendingValidations, 0);
|
|
assert.equal(requests.length, 2);
|
|
|
|
const resolvedTestRun = await readFile(join(dir, "test-run.json"), "utf8");
|
|
assert.match(resolvedTestRun, /"state": "passed"/);
|
|
assert.match(resolvedTestRun, /"reasoning": "The synthetic screenshot is present\."/);
|
|
assert.match(resolvedTestRun, /"pendingJudgments": 0/);
|
|
|
|
let idempotentCalls = 0;
|
|
const unchanged = await judgeTestRun(dir, {
|
|
ask: async () => {
|
|
idempotentCalls += 1;
|
|
throw new Error("resolved validations must not be re-judged");
|
|
},
|
|
});
|
|
assert.equal(unchanged.judgedValidations, 0);
|
|
assert.equal(unchanged.failedValidations, 0);
|
|
assert.equal(idempotentCalls, 0);
|
|
|
|
const cli = spawnSync(process.execPath, [
|
|
fileURLToPath(new URL("../bin/test-evidence-judge.mjs", import.meta.url)),
|
|
"--test-run",
|
|
dir,
|
|
], { encoding: "utf8" });
|
|
assert.equal(cli.status, 0, cli.stderr);
|
|
assert.match(cli.stdout, /Judged 0 visual validation\(s\): 0 failed, 0 pending\./);
|
|
|
|
let forcedCalls = 0;
|
|
const forced = await judgeTestRun(dir, {
|
|
force: true,
|
|
ask: async (request) => {
|
|
forcedCalls += 1;
|
|
return forcedCalls === 1
|
|
? JSON.stringify({ description: "A synthetic screenshot is visible." })
|
|
: JSON.stringify({ results: [{ expectation, passed: false, evidence: "The expected detail is absent." }] });
|
|
},
|
|
});
|
|
assert.equal(forced.judgedValidations, 1);
|
|
assert.equal(forced.failedValidations, 1);
|
|
assert.equal(forced.pendingValidations, 0);
|
|
assert.equal(forcedCalls, 2, "--force must bypass the cached judgment");
|
|
} finally {
|
|
if (previousMode === undefined) delete process.env.OPENWORK_EVAL_VISION;
|
|
else process.env.OPENWORK_EVAL_VISION = previousMode;
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|