mirror of
https://github.com/expo/explore.git
synced 2026-11-15 03:48:15 -10:00
449 lines
31 KiB
JavaScript
449 lines
31 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import test from "node:test";
|
|
|
|
import { tsImport } from "tsx/esm/api";
|
|
import { AGENT_DEVICE_VERSION, EAS_CLI_VERSION } from "../../dist/capture/run.js";
|
|
import { readExportedMap } from "../../dist/review/evidence-assessment.js";
|
|
import { ExploreStore, recordObservation } from "../../dist/map/store.js";
|
|
|
|
const { captureRecipeConfig, incompleteCaptureReason, restoreCaptureArtifactCache, validateEvidence, writeCaptureArtifactCache } = await tsImport("../../src/ci/artifact-cache.ts", import.meta.url);
|
|
const { mergeCaptureExports } = await tsImport("../../src/ci/export-merge.ts", import.meta.url);
|
|
const { captureHealthIssues, partitionCaptureHealthIssues } = await tsImport("../../src/capture/health.ts", import.meta.url);
|
|
const { readCaptureBundle } = await tsImport("../../src/review/capture-review.ts", import.meta.url);
|
|
const { CAPTURE_OBSERVER_HASH } = await tsImport("../../src/capture/observer.ts", import.meta.url);
|
|
const { AGENT_ACCEPTANCE_POLICY } = await tsImport("../../src/contracts/capture.ts", import.meta.url);
|
|
|
|
const COMMIT = "a".repeat(40);
|
|
const PNG = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64");
|
|
const RUNTIME = { deviceModel: "iPhone 17", osVersion: "26.0", locale: "en_US", orientation: "portrait", colorScheme: "light" };
|
|
const EXECUTION = {
|
|
provider: "eas-simulator", controller: "agent-device", controllerPackageVersion: AGENT_DEVICE_VERSION,
|
|
easCliVersion: EAS_CLI_VERSION, platform: "ios", ...RUNTIME, sessionLifecycle: "stopped",
|
|
};
|
|
const RECIPE = {
|
|
inventory: ["home", "settings"].map(id => ({
|
|
id, kind: "screen", route: `/${id}`, routeFile: `src/app/${id}.tsx`, sourceFiles: [],
|
|
provider: "expo-router", logicalPath: id, path: `/${id}`, variant: "default",
|
|
})),
|
|
profile: "explore:bundle", config: '{"capture":{"samples":3}}', toolsCommit: "c".repeat(40),
|
|
};
|
|
const AGENT_RECIPE = { ...RECIPE, config: JSON.stringify({ mode: "agent", agent: { acceptancePolicy: "agent-inspected-frame.v1" } }) };
|
|
|
|
function acceptedFrames(bundle) {
|
|
return { policy: AGENT_ACCEPTANCE_POLICY, frames: Object.fromEntries(Object.entries(bundle.images).map(([id, image]) => [id, {
|
|
frameId: `inspected-${id}`, imageSha256: image.sha256, capturedAt: image.capturedAt,
|
|
}])) };
|
|
}
|
|
|
|
async function fixture(t, { missingAccessibility = false, partial = false, appearances = false, darkFrames = appearances } = {}) {
|
|
const root = await mkdtemp(join(tmpdir(), "explore-capture-artifact-cache-"));
|
|
t.after(() => rm(root, { recursive: true, force: true }));
|
|
const store = new ExploreStore(join(root, "active"));
|
|
const timestamp = new Date().toISOString();
|
|
const map = {
|
|
contract: "expo.explore.local-map.v1", revision: 0,
|
|
run: {
|
|
id: "capture-run", name: "Cache evidence", platform: "ios", controller: "agent-device", status: "draft",
|
|
createdAt: timestamp, updatedAt: timestamp,
|
|
build: { id: "eas-build", gitCommit: COMMIT, source: "eas" },
|
|
environment: { ...RUNTIME, authState: "logged-out", ...(appearances ? { colorSchemes: ["light", "dark"] } : {}) },
|
|
limits: { maxScreens: 20, maxActions: 100, maxDurationMinutes: 60 },
|
|
},
|
|
screens: [], variants: [], observations: [], elements: [], artifacts: [],
|
|
actionAttempts: [], transitions: [], journeys: [], frontier: [], gaps: [],
|
|
};
|
|
await store.initialize(map, false);
|
|
const screenshotPath = join(root, "sample.png");
|
|
const snapshotPath = join(root, "sample.json");
|
|
await writeFile(screenshotPath, PNG);
|
|
const images = {};
|
|
for (const id of darkFrames ? ["home", "settings", "home@dark", "settings@dark"] : ["home", "settings"]) {
|
|
const snapshotText = JSON.stringify({ nodes: [{ type: "text", label: id }] });
|
|
await writeFile(snapshotPath, snapshotText);
|
|
const result = await recordObservation({
|
|
store, map, title: id, screenId: id, route: `/${id}`, variantName: "default", start: id === "home",
|
|
screenshotPath, snapshotText, ...(missingAccessibility ? {} : { snapshotPath }),
|
|
elements: [{ identifier: `${id}-title`, role: "text", label: id, actions: [], actionable: false, risk: "safe" }],
|
|
});
|
|
await store.write(map, result.preparedArtifacts);
|
|
const image = result.preparedArtifacts.find(entry => entry.artifact.kind === "screenshot").artifact;
|
|
images[id] = {
|
|
path: image.path, sha256: image.sha256, width: image.width, height: image.height,
|
|
capturedAt: result.observation.capturedAt, sourceCommit: COMMIT, buildId: "eas-build",
|
|
};
|
|
}
|
|
map.run.status = partial ? "partial" : "complete";
|
|
if (partial) map.run.stopReason = "Capture interrupted";
|
|
map.run.completedAt = new Date().toISOString();
|
|
await store.write(map);
|
|
const sourceDirectory = join(root, "export");
|
|
await store.exportTo(sourceDirectory, map);
|
|
const bundle = {
|
|
contract: "explore.bundle", side: "head", gitCommit: COMMIT,
|
|
easBuildId: "eas-build", easProjectId: "eas-project", profile: RECIPE.profile, runtimeProfile: appearances ? { ...RUNTIME, colorSchemes: "light,dark" } : RUNTIME,
|
|
selectedSurfaceIds: ["home", "settings"], images,
|
|
// A dark frame verified beside its light one has no outcome of its own, as the passes merge them.
|
|
outcomes: Object.fromEntries(Object.entries(images).filter(([id]) => !id.endsWith("@dark"))
|
|
.map(([id, image]) => [id, { status: "captured", attempts: 1, landing: "verified", screenshotSha256: image.sha256 }])),
|
|
};
|
|
await writeFile(join(sourceDirectory, "bundle.json"), JSON.stringify(bundle));
|
|
await writeFile(join(sourceDirectory, "execution.json"), JSON.stringify(EXECUTION));
|
|
// Construct actual exported, validated evidence; this fixture cannot pass with stub JSON maps.
|
|
assert.equal((await readExportedMap(sourceDirectory)).observations.length, darkFrames ? 4 : 2);
|
|
const options = { ...RECIPE, sourceDirectory, directory: join(root, "restored"), commit: COMMIT, selectedIds: ["home", "settings"], runtimeProfile: bundle.runtimeProfile };
|
|
return { root, sourceDirectory, map, bundle, options };
|
|
}
|
|
|
|
test("restores full accessibility and screenshot evidence with base provenance", async t => {
|
|
const f = await fixture(t);
|
|
assert.equal(await writeCaptureArtifactCache(f.sourceDirectory, RECIPE), true);
|
|
assert.equal(await restoreCaptureArtifactCache(f.options), true);
|
|
const restored = await readExportedMap(f.options.directory);
|
|
assert.deepEqual(restored, f.map);
|
|
assert.deepEqual(JSON.parse(await readFile(join(f.options.directory, "execution.json"), "utf8")), EXECUTION);
|
|
for (const artifact of restored.artifacts) {
|
|
assert.deepEqual(await readFile(join(f.options.directory, artifact.path)), await readFile(join(f.sourceDirectory, artifact.path)));
|
|
}
|
|
assert.equal(restored.artifacts.filter(artifact => artifact.kind === "accessibility").length, 2);
|
|
assert.deepEqual(await readCaptureBundle(f.options.directory), { ...f.bundle, side: "base" });
|
|
});
|
|
|
|
test("CI recipes reuse evidence regardless of object construction order", async t => {
|
|
const f = await fixture(t);
|
|
// capture:review adds each side's inventory after the shared recipe fields.
|
|
const { inventory, ...sharedRecipe } = RECIPE;
|
|
assert.equal(await writeCaptureArtifactCache(f.sourceDirectory, { ...sharedRecipe, inventory }), true);
|
|
assert.equal(await restoreCaptureArtifactCache(f.options), true);
|
|
assert.deepEqual(await readCaptureBundle(f.options.directory), { ...f.bundle, side: "base" });
|
|
});
|
|
|
|
test("nested recipe keys, JSON config keys and runtime profile order do not invalidate evidence", async t => {
|
|
const f = await fixture(t);
|
|
const config = { device: { name: "iPhone", locale: "en_US" }, parameters: { "/profile/[id]": { id: "demo", mode: "preview" } } };
|
|
const first = { ...RECIPE, config: JSON.stringify(config) };
|
|
assert.equal(await writeCaptureArtifactCache(f.sourceDirectory, first), true);
|
|
const reverse = value => Array.isArray(value) ? value.map(reverse) : value && typeof value === "object"
|
|
? Object.fromEntries(Object.entries(value).reverse().map(([key, child]) => [key, reverse(child)])) : value;
|
|
assert.equal(await restoreCaptureArtifactCache({ ...f.options, inventory: reverse(RECIPE.inventory), config: JSON.stringify(reverse(config)), runtimeProfile: reverse(RUNTIME) }), true);
|
|
assert.equal(await restoreCaptureArtifactCache({ ...f.options, directory: join(f.root,"different-params"), config: JSON.stringify({ ...config, parameters: { "/profile/[id]": { id:"other",mode:"preview" } } }) }), false);
|
|
});
|
|
|
|
test("capture recipes exclude display settings but bind agent instructions and capture inputs", () => {
|
|
const config = { mode: "deterministic", app: { name:"Display name", platform:"ios",bundleId:"example.app",easProfile:"simulator",scheme:"example" },
|
|
navigation: {provider:"auto"},device:{name:"iPhone"},exploration:{scope:"changes"},capture:{maxMinutes:10,maxActions:20},
|
|
setup:[{action:"tap",label:"Next"},{action:"wait",milliseconds:100}],parameters:{"/users/[id]":{id:"demo"}} };
|
|
const key=captureRecipeConfig(config);
|
|
assert.equal(JSON.parse(key).observer, CAPTURE_OBSERVER_HASH);
|
|
assert.match(JSON.parse(key).observer, /^[a-f0-9]{64}$/);
|
|
assert.equal(key,captureRecipeConfig({...config,app:{...config.app,name:"Renamed display"}}));
|
|
// How many sessions capture it does not change the evidence: a baseline captured under any ceiling serves the review.
|
|
assert.equal(key,captureRecipeConfig({...config,capture:{...config.capture,maxSessions:16}}));
|
|
assert.equal(captureRecipeConfig({...config,capture:{...config.capture,maxSessions:4}}),captureRecipeConfig({...config,capture:{...config.capture,maxSessions:16}}));
|
|
// Nor does how long a review waits for a baseline still being captured.
|
|
assert.equal(key,captureRecipeConfig({...config,capture:{...config.capture,baselineWaitMinutes:0}}));
|
|
// An unset repack is keyed as it resolves, so configs that never chose one keep the recipe of the old written-out default.
|
|
assert.equal(key,captureRecipeConfig({...config,capture:{...config.capture,repack:"eas"}}));
|
|
assert.equal(JSON.parse(key).capture.repack, "eas");
|
|
assert.equal(JSON.parse(captureRecipeConfig({...config,capture:{...config.capture,simulator:"local"}})).capture.repack, "local");
|
|
// agent-device recipes are unchanged by Argent support; an Argent capture is keyed to Argent and its version.
|
|
assert.equal(JSON.parse(key).controllerVersion, undefined);
|
|
const argent = JSON.parse(captureRecipeConfig({...config,capture:{...config.capture,controller:"argent"}}));
|
|
assert.equal(argent.capture.controller, "argent");
|
|
assert.match(argent.controllerVersion, /^\d+\.\d+\.\d+$/);
|
|
const agent = {...config,mode:"agent",agent:{provider:"openai",model:"another-model",instructions:".expo-explore/instructions.md"}};
|
|
assert.notEqual(key,captureRecipeConfig(agent,"a".repeat(64)));
|
|
assert.equal(JSON.parse(captureRecipeConfig(agent,"a".repeat(64))).agent.acceptancePolicy, AGENT_ACCEPTANCE_POLICY);
|
|
assert.equal(JSON.parse(key).agent, undefined, "Deterministic recipes do not acquire an agent policy");
|
|
assert.notEqual(captureRecipeConfig(agent,"a".repeat(64)),captureRecipeConfig(agent,"b".repeat(64)));
|
|
assert.notEqual(key,captureRecipeConfig({...config,setup:[...config.setup].reverse()}));
|
|
assert.notEqual(key,captureRecipeConfig({...config,app:{...config.app,scheme:"other"}}));
|
|
assert.notEqual(key,captureRecipeConfig({...config,parameters:{"/users/[id]":{id:"different"}}}));
|
|
});
|
|
|
|
test("restores a selected subset while retaining the complete runtime map and evidence", async t => {
|
|
const f = await fixture(t);
|
|
assert.equal(await writeCaptureArtifactCache(f.sourceDirectory, RECIPE), true);
|
|
assert.equal(await restoreCaptureArtifactCache({ ...f.options, selectedIds: ["settings"] }), true);
|
|
const bundle = await readCaptureBundle(f.options.directory);
|
|
assert.deepEqual(bundle.selectedSurfaceIds, ["settings"]);
|
|
assert.deepEqual(Object.keys(bundle.images), ["settings"]);
|
|
assert.deepEqual(await readExportedMap(f.options.directory), f.map);
|
|
assert.equal(await restoreCaptureArtifactCache({ ...f.options, directory: join(f.root, "missing-selection"), selectedIds: ["uncaptured"] }), false);
|
|
});
|
|
|
|
test("agent cache restores only captures matching the established review context", async t => {
|
|
const f = await fixture(t);
|
|
const context = { description: "Review account", parameters: { id: "reviewer" }, requiredLabels: ["account-title"] };
|
|
f.bundle.captureContexts = { home: context, settings: context };
|
|
f.bundle.agentAcceptance = acceptedFrames(f.bundle);
|
|
await writeFile(join(f.sourceDirectory, "bundle.json"), JSON.stringify(f.bundle));
|
|
assert.equal(await writeCaptureArtifactCache(f.sourceDirectory, AGENT_RECIPE), true);
|
|
const captureContexts = { home: context, settings: { ...context, parameters: { id: "another" } } };
|
|
assert.equal(await restoreCaptureArtifactCache({ ...f.options, ...AGENT_RECIPE, captureContexts }), true);
|
|
const restored = await readCaptureBundle(f.options.directory);
|
|
assert.deepEqual(restored.selectedSurfaceIds, ["home"]);
|
|
assert.deepEqual(restored.captureContexts, { home: context });
|
|
assert.deepEqual(restored.agentAcceptance, { policy: AGENT_ACCEPTANCE_POLICY, frames: { home: f.bundle.agentAcceptance.frames.home } });
|
|
assert.equal(await writeCaptureArtifactCache(f.options.directory, AGENT_RECIPE), true, "Restored subsets retain revalidatable acceptance receipts");
|
|
assert.equal(await restoreCaptureArtifactCache({ ...f.options, ...AGENT_RECIPE, directory: join(f.root, "no-context"), captureContexts: {} }), false);
|
|
});
|
|
|
|
test("agent caches require inspected-frame receipts even when older evidence is otherwise verified", async t => {
|
|
const f = await fixture(t);
|
|
assert.equal(await writeCaptureArtifactCache(f.sourceDirectory, AGENT_RECIPE), false);
|
|
assert.equal(await writeCaptureArtifactCache(f.sourceDirectory, RECIPE), true, "Legacy deterministic evidence remains cacheable");
|
|
assert.equal(await restoreCaptureArtifactCache({ ...f.options, captureContexts: {} }), false, "Agent reuse cannot adopt a deterministic cache without receipts");
|
|
f.bundle.agentAcceptance = acceptedFrames(f.bundle);
|
|
await writeFile(join(f.sourceDirectory, "bundle.json"), JSON.stringify(f.bundle));
|
|
assert.equal(await writeCaptureArtifactCache(f.sourceDirectory, AGENT_RECIPE), true);
|
|
const metadataPath = join(f.sourceDirectory, "cache-metadata.json");
|
|
const metadata = JSON.parse(await readFile(metadataPath, "utf8"));
|
|
delete f.bundle.agentAcceptance;
|
|
const bytes = Buffer.from(JSON.stringify(f.bundle));
|
|
await writeFile(join(f.sourceDirectory, "bundle.json"), bytes);
|
|
const { createHash } = await import("node:crypto");
|
|
Object.assign(metadata.files.find(file => file.path === "bundle.json"), {
|
|
bytes: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
});
|
|
await writeFile(metadataPath, JSON.stringify(metadata));
|
|
assert.equal(await restoreCaptureArtifactCache({ ...f.options, ...AGENT_RECIPE }), false, "Even matching checksums cannot promote pre-policy evidence");
|
|
});
|
|
|
|
test("agent acceptance cannot be detached from its exact exported frame or observation", async t => {
|
|
for (const mutation of ["policy", "missing-receipt", "extra-receipt", "duplicate-frame", "hash", "time", "observation-time", "image-bytes"]) {
|
|
await t.test(mutation, async t => {
|
|
const f = await fixture(t);
|
|
f.bundle.agentAcceptance = acceptedFrames(f.bundle);
|
|
const frame = f.bundle.agentAcceptance.frames.home;
|
|
if (mutation === "policy") f.bundle.agentAcceptance.policy = "agent-inspected-frame.v0";
|
|
if (mutation === "missing-receipt") delete f.bundle.agentAcceptance.frames.home;
|
|
if (mutation === "extra-receipt") f.bundle.agentAcceptance.frames.foreign = { ...frame, frameId: "foreign" };
|
|
if (mutation === "duplicate-frame") f.bundle.agentAcceptance.frames.settings.frameId = frame.frameId;
|
|
if (mutation === "hash") frame.imageSha256 = "0".repeat(64);
|
|
if (mutation === "time") frame.capturedAt = "2020-01-01T00:00:00.000Z";
|
|
if (mutation === "observation-time") {
|
|
// Receipt and image agree, but neither may claim a timestamp absent from the actual map.
|
|
frame.capturedAt = "2020-01-01T00:00:00.000Z";
|
|
f.bundle.images.home.capturedAt = frame.capturedAt;
|
|
}
|
|
if (mutation === "image-bytes") await writeFile(join(f.sourceDirectory, f.bundle.images.home.path), "replacement frame");
|
|
await writeFile(join(f.sourceDirectory, "bundle.json"), JSON.stringify(f.bundle));
|
|
await assert.rejects(readCaptureBundle(f.sourceDirectory));
|
|
assert.equal(await writeCaptureArtifactCache(f.sourceDirectory, AGENT_RECIPE), false);
|
|
assert.equal(await restoreCaptureArtifactCache({ ...f.options, ...AGENT_RECIPE }), false);
|
|
});
|
|
}
|
|
});
|
|
|
|
test("partial agent captures retain receipts only for their successfully accepted frames", async t => {
|
|
const f = await fixture(t, { partial: true });
|
|
delete f.bundle.images.settings;
|
|
f.bundle.outcomes.settings = { status: "failed", attempts: 1, landing: "unverified", reason: "No accepted frame" };
|
|
f.bundle.captureContexts = { home: { description: "Home ready.", parameters: {}, requiredLabels: ["home"] } };
|
|
f.bundle.agentAcceptance = acceptedFrames(f.bundle);
|
|
await writeFile(join(f.sourceDirectory, "bundle.json"), JSON.stringify(f.bundle));
|
|
assert.equal(await writeCaptureArtifactCache(f.sourceDirectory, AGENT_RECIPE), true);
|
|
assert.equal(await restoreCaptureArtifactCache({ ...f.options, ...AGENT_RECIPE }), true);
|
|
const restored = await readCaptureBundle(f.options.directory);
|
|
assert.deepEqual(restored.selectedSurfaceIds, ["home"]);
|
|
assert.deepEqual(restored.agentAcceptance, f.bundle.agentAcceptance);
|
|
});
|
|
|
|
test("first run and expired or incomplete artifacts are cache misses", async t => {
|
|
for (const absent of [undefined, "missing", "cache-metadata.json", "screen-map.json", "execution.json"]) {
|
|
await t.test(String(absent), async t => {
|
|
const f = await fixture(t);
|
|
if (absent !== "cache-metadata.json") assert.equal(await writeCaptureArtifactCache(f.sourceDirectory, RECIPE), true);
|
|
if (["screen-map.json", "execution.json"].includes(absent)) await rm(join(f.sourceDirectory, absent));
|
|
const sourceDirectory = absent === undefined ? undefined : absent === "missing" ? join(f.root, "expired") : f.sourceDirectory;
|
|
assert.equal(await restoreCaptureArtifactCache({ ...f.options, sourceDirectory }), false);
|
|
await assert.rejects(readFile(join(f.options.directory, "screen-map.json")), { code: "ENOENT" });
|
|
});
|
|
}
|
|
});
|
|
|
|
test("changed commit, inventory, config, tools, profile, or runtime cannot reuse evidence", async t => {
|
|
const f = await fixture(t);
|
|
assert.equal(await writeCaptureArtifactCache(f.sourceDirectory, RECIPE), true);
|
|
for (const changed of [
|
|
{ commit: "b".repeat(40) }, { toolsCommit: "d".repeat(40) }, { config: "changed" },
|
|
{ inventory: RECIPE.inventory.slice(0, 1) },
|
|
{ profile: "other-profile" }, { runtimeProfile: { ...RUNTIME, osVersion: "26.1" } },
|
|
{ runtimeProfile: null },
|
|
]) assert.equal(await restoreCaptureArtifactCache({ ...f.options, ...changed }), false, JSON.stringify(changed));
|
|
});
|
|
|
|
test("tampered bytes, missing evidence files, and unlisted files are misses", async t => {
|
|
for (const mutation of ["screenshot", "accessibility", "missing-artifact", "extra-file", "bundle"]) {
|
|
await t.test(mutation, async t => {
|
|
const f = await fixture(t);
|
|
assert.equal(await writeCaptureArtifactCache(f.sourceDirectory, RECIPE), true);
|
|
if (mutation === "missing-artifact") await rm(join(f.sourceDirectory, f.map.artifacts[0].path));
|
|
else if (mutation === "extra-file") await writeFile(join(f.sourceDirectory, "unlisted.txt"), "extra");
|
|
else if (mutation === "bundle") await writeFile(join(f.sourceDirectory, "bundle.json"), JSON.stringify({ ...f.bundle, gitCommit: "b".repeat(40) }));
|
|
else await writeFile(join(f.sourceDirectory, f.map.artifacts.find(artifact => artifact.kind === mutation).path), "corrupt");
|
|
assert.equal(await restoreCaptureArtifactCache(f.options), false);
|
|
});
|
|
}
|
|
});
|
|
|
|
test("partial captures reuse only the screens with complete successful evidence", async t => {
|
|
const f = await fixture(t, { partial: true });
|
|
delete f.bundle.images.settings;
|
|
f.bundle.outcomes.settings = { status: "failed", attempts: 1, landing: "unverified", reason: "Interrupted before capture" };
|
|
await writeFile(join(f.sourceDirectory, "bundle.json"), JSON.stringify(f.bundle));
|
|
assert.equal(await writeCaptureArtifactCache(f.sourceDirectory, RECIPE), true);
|
|
assert.equal(await restoreCaptureArtifactCache(f.options), true);
|
|
const restored = await readCaptureBundle(f.options.directory);
|
|
assert.deepEqual(restored.selectedSurfaceIds, ["home"]);
|
|
assert.deepEqual(Object.keys(restored.images), ["home"]);
|
|
assert.deepEqual(Object.keys(restored.outcomes), ["home"]);
|
|
assert.equal(restored.outcomes.home.status, "captured");
|
|
assert.deepEqual(await readExportedMap(f.options.directory), f.map);
|
|
assert.equal(await restoreCaptureArtifactCache({ ...f.options, directory: join(f.root, "failed-only"), selectedIds: ["settings"] }), false);
|
|
});
|
|
|
|
test("a baseline whose dark session failed is cached, and a review captures the routes without a dark frame itself", async t => {
|
|
// brentvatne/euxy's main baseline run 39506374824: the light session captured every route and the dark session failed.
|
|
const failedDark = { status: "failed", attempts: 0, landing: "unverified", reason: "install: argent run list-devices failed: GET /tools failed: 404 Not Found",
|
|
error: { code: "CAPTURE_SESSION_FAILED", stage: "session", message: "install: argent run list-devices failed: GET /tools failed: 404 Not Found", nextAction: "Rerun the capture.", retryable: false } };
|
|
const f = await fixture(t, { partial: true, appearances: true });
|
|
const complete = structuredClone(f.bundle);
|
|
assert.equal(await writeCaptureArtifactCache(f.sourceDirectory, RECIPE), true);
|
|
assert.equal(await restoreCaptureArtifactCache(f.options), true, "a baseline with every appearance is reused whole");
|
|
assert.deepEqual(Object.keys((await readCaptureBundle(f.options.directory)).images).sort(), ["home", "home@dark", "settings", "settings@dark"]);
|
|
|
|
for (const id of ["home", "settings"]) { delete f.bundle.images[`${id}@dark`]; f.bundle.outcomes[`${id}@dark`] = failedDark; }
|
|
f.bundle.sessionFailures = [{ scheme: "dark", error: failedDark.error }];
|
|
await writeFile(join(f.sourceDirectory, "bundle.json"), JSON.stringify(f.bundle));
|
|
// No route has its dark frame: offering it would make a review wait for it and capture its sides in turn, only to
|
|
// capture every route of the base itself.
|
|
assert.equal(await writeCaptureArtifactCache(f.sourceDirectory, RECIPE), false, "a baseline with nothing to reuse is not offered");
|
|
assert.equal(await restoreCaptureArtifactCache({ ...f.options, directory: join(f.root, "no-dark") }), false);
|
|
assert.equal(await incompleteCaptureReason(f.sourceDirectory, COMMIT), "its dark session failed, so no route has every appearance");
|
|
assert.equal(await incompleteCaptureReason(f.sourceDirectory, "b".repeat(40)), undefined, "another commit's baseline is the usual miss");
|
|
|
|
const mixed = structuredClone(complete);
|
|
delete mixed.images["settings@dark"]; mixed.outcomes["settings@dark"] = failedDark; delete mixed.sessionFailures;
|
|
await writeFile(join(f.sourceDirectory, "bundle.json"), JSON.stringify(mixed));
|
|
assert.equal(await writeCaptureArtifactCache(f.sourceDirectory, RECIPE), true);
|
|
assert.equal(await restoreCaptureArtifactCache({ ...f.options, directory: join(f.root, "mixed") }), true);
|
|
const restored = await readCaptureBundle(join(f.root, "mixed"));
|
|
assert.deepEqual(restored.selectedSurfaceIds, ["home"], "settings has no dark frame, so the review's base captures it in both appearances");
|
|
assert.deepEqual(Object.keys(restored.images).sort(), ["home", "home@dark"]);
|
|
assert.deepEqual(Object.keys(restored.outcomes), ["home"]);
|
|
});
|
|
|
|
test("a light session's real export merged with a failed dark session is valid evidence, a valid map, and healthy but for dark", async t => {
|
|
// The light session of a side split over sessions: it renders light and dark, and took light alone.
|
|
const f = await fixture(t, { appearances: true, darkFrames: false });
|
|
const error = { code: "CAPTURE_SESSION_FAILED", stage: "session", message: "install: argent run list-devices failed: GET /tools failed: 404 Not Found",
|
|
nextAction: "Rerun the capture.", retryable: true };
|
|
// The light session lost /settings.
|
|
delete f.bundle.images.settings;
|
|
f.bundle.outcomes.settings = { status: "failed", attempts: 2, landing: "unverified", reason: "The route observer never answered." };
|
|
await writeFile(join(f.sourceDirectory, "bundle.json"), JSON.stringify(f.bundle));
|
|
await mergeCaptureExports(f.sourceDirectory, [{ scheme: "dark", error }]);
|
|
const map = await readExportedMap(f.sourceDirectory);
|
|
assert.equal(map.run.status, "partial");
|
|
assert.equal(map.run.stopReason, `The dark session failed (CAPTURE_SESSION_FAILED): ${error.message}.`);
|
|
const bundle = await readCaptureBundle(f.sourceDirectory);
|
|
assert.deepEqual(Object.keys(bundle.outcomes).sort(), ["home", "home@dark", "settings", "settings@dark"]);
|
|
// Each dark frame carries the dark session's reason, the one light lost too: the review shows it, not light's.
|
|
assert.equal(bundle.outcomes["settings@dark"].reason, error.message);
|
|
assert.equal(bundle.outcomes.settings.reason, "The route observer never answered.");
|
|
await validateEvidence(f.sourceDirectory, bundle);
|
|
const issues = partitionCaptureHealthIssues(captureHealthIssues(bundle, ["home", "settings"]));
|
|
assert.deepEqual(issues.integrity, []);
|
|
assert.deepEqual(issues.unavailable.map(issue => [issue.surfaceId, issue.code]), [["home@dark", "CAPTURE_SESSION_FAILED"], ["settings", "CAPTURE_FAILED"]]);
|
|
assert.equal(await writeCaptureArtifactCache(f.sourceDirectory, RECIPE), false, "valid, but nothing in it a review could reuse");
|
|
});
|
|
|
|
test("partial caches retain verified targets without restoring captured but unverified screenshots", async t => {
|
|
const f = await fixture(t, { partial: true });
|
|
f.bundle.outcomes.settings.landing = "unverified";
|
|
await writeFile(join(f.sourceDirectory, "bundle.json"), JSON.stringify(f.bundle));
|
|
assert.equal(await writeCaptureArtifactCache(f.sourceDirectory, RECIPE), true);
|
|
assert.equal(await restoreCaptureArtifactCache(f.options), true);
|
|
const restored = await readCaptureBundle(f.options.directory);
|
|
assert.deepEqual(restored.selectedSurfaceIds, ["home"]);
|
|
assert.deepEqual(Object.keys(restored.images), ["home"]);
|
|
assert.deepEqual(Object.keys(restored.outcomes), ["home"]);
|
|
assert.equal(restored.outcomes.home.landing, "verified");
|
|
assert.equal(await restoreCaptureArtifactCache({ ...f.options, directory: join(f.root, "unverified-only"), selectedIds: ["settings"] }), false);
|
|
});
|
|
|
|
test("legacy captured evidence without screenshot hash binding is not reused", async t => {
|
|
const f = await fixture(t);
|
|
delete f.bundle.outcomes.settings.screenshotSha256;
|
|
await writeFile(join(f.sourceDirectory, "bundle.json"), JSON.stringify(f.bundle));
|
|
assert.equal(await writeCaptureArtifactCache(f.sourceDirectory, RECIPE), true);
|
|
assert.equal(await restoreCaptureArtifactCache(f.options), true);
|
|
assert.deepEqual((await readCaptureBundle(f.options.directory)).selectedSurfaceIds, ["home"]);
|
|
assert.equal(await restoreCaptureArtifactCache({ ...f.options, directory: join(f.root, "unbound-only"), selectedIds: ["settings"] }), false);
|
|
});
|
|
|
|
test("a cache containing only unverified screenshots cannot be written or restored", async t => {
|
|
const f = await fixture(t);
|
|
for (const outcome of Object.values(f.bundle.outcomes)) outcome.landing = "unverified";
|
|
await writeFile(join(f.sourceDirectory, "bundle.json"), JSON.stringify(f.bundle));
|
|
assert.equal(await writeCaptureArtifactCache(f.sourceDirectory, RECIPE), false);
|
|
assert.equal(await restoreCaptureArtifactCache(f.options), false);
|
|
});
|
|
|
|
test("old metadata does not turn unverified captures into valid cache hits", async t => {
|
|
const f = await fixture(t);
|
|
assert.equal(await writeCaptureArtifactCache(f.sourceDirectory, RECIPE), true);
|
|
const metadataPath = join(f.sourceDirectory, "cache-metadata.json");
|
|
const metadata = JSON.parse(await readFile(metadataPath, "utf8"));
|
|
for (const outcome of Object.values(f.bundle.outcomes)) outcome.landing = "unverified";
|
|
const bytes = Buffer.from(JSON.stringify(f.bundle));
|
|
await writeFile(join(f.sourceDirectory, "bundle.json"), bytes);
|
|
// Simulate a correctly checksummed artifact written by the old status-only cache writer.
|
|
const { createHash } = await import("node:crypto");
|
|
const record = metadata.files.find(file => file.path === "bundle.json");
|
|
record.bytes = bytes.length;
|
|
record.sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
await writeFile(metadataPath, JSON.stringify(metadata));
|
|
assert.equal(await restoreCaptureArtifactCache(f.options), false);
|
|
await assert.rejects(readFile(join(f.options.directory, "bundle.json")), { code: "ENOENT" });
|
|
});
|
|
|
|
test("inconsistent or incomplete runtime evidence is never offered as a cache entry", async t => {
|
|
for (const condition of ["missingAccessibility", "missing-selected-image", "wrong-source-commit", "execution-mismatch", "missing-bundle-file"]) {
|
|
await t.test(condition, async t => {
|
|
const f = await fixture(t, { [condition]: true });
|
|
if (condition === "missing-selected-image") {
|
|
delete f.bundle.images.settings;
|
|
await writeFile(join(f.sourceDirectory, "bundle.json"), JSON.stringify(f.bundle));
|
|
} else if (condition === "wrong-source-commit") {
|
|
f.bundle.images.home.sourceCommit = "b".repeat(40);
|
|
await writeFile(join(f.sourceDirectory, "bundle.json"), JSON.stringify(f.bundle));
|
|
} else if (condition === "execution-mismatch") {
|
|
await writeFile(join(f.sourceDirectory, "execution.json"), JSON.stringify({ ...EXECUTION, osVersion: "26.1" }));
|
|
} else if (condition === "missing-bundle-file") {
|
|
f.bundle.images.home.path = "missing.png";
|
|
await writeFile(join(f.sourceDirectory, "bundle.json"), JSON.stringify(f.bundle));
|
|
}
|
|
assert.equal(await writeCaptureArtifactCache(f.sourceDirectory, RECIPE), false);
|
|
assert.equal(await restoreCaptureArtifactCache(f.options), false);
|
|
});
|
|
}
|
|
});
|
|
|
|
test("restore preserves an unrelated staging directory and an existing destination", async t => {
|
|
const f = await fixture(t);
|
|
assert.equal(await writeCaptureArtifactCache(f.sourceDirectory, RECIPE), true);
|
|
const unrelated = `${f.options.directory}.cache`;
|
|
await mkdir(unrelated);
|
|
await writeFile(join(unrelated, "owned-by-another-process"), "preserve");
|
|
assert.equal(await restoreCaptureArtifactCache(f.options), true);
|
|
assert.equal(await readFile(join(unrelated, "owned-by-another-process"), "utf8"), "preserve");
|
|
await writeFile(join(f.options.directory, "screen-map.json"), "existing destination");
|
|
assert.equal(await restoreCaptureArtifactCache(f.options), false);
|
|
assert.equal(await readFile(join(f.options.directory, "screen-map.json"), "utf8"), "existing destination");
|
|
});
|