qa-lab: add evidence artifact gallery (#94283)

* qa-lab: add evidence artifact gallery

* qa-lab: harden evidence gallery artifacts

* qa-lab: share evidence gallery view types

* qa-lab: disable evidence preview caching

* refactor(qa-lab): resolve evidence artifacts once and trim gallery render/test duplication

* fix(qa-lab): build evidence model before sending /api/evidence success headers

---------

Co-authored-by: Dallin Romney <dallinromney@gmail.com>
This commit is contained in:
Colin Johnson 2026-06-18 15:17:46 -04:00 committed by GitHub
parent ea4ddb2eb5
commit c677424edb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 3079 additions and 12 deletions

View file

@ -0,0 +1,88 @@
export type QaEvidenceGalleryStatus = "pass" | "fail" | "blocked" | "skipped";
export type QaEvidenceCoverageView = {
id: string;
role: string;
};
export type QaEvidenceProducerContextFile = {
href: string;
path: string;
preview: string | null;
};
export type QaEvidenceMatrixCellView = {
artifactKinds: string[];
artifactPaths: string[];
coverageIds: string[];
runner: {
availability: string | null;
command: string | null;
lane: string | null;
workflow: string | null;
} | null;
stage: string;
status: string;
surface: string;
testId: string | null;
title: string | null;
};
export type QaEvidenceArtifactView = {
exists: boolean;
error: string | null;
href: string | null;
kind: string;
mediaKind: "image" | "video" | "json" | "text" | "file";
path: string;
preview: string | null;
source: string;
};
export type QaEvidenceGalleryEntryView = {
artifacts: QaEvidenceArtifactView[];
coverage: QaEvidenceCoverageView[];
failureReason: string | null;
id: string;
kind: string;
sourcePath: string | null;
status: QaEvidenceGalleryStatus;
title: string;
};
export type QaEvidenceProducerContext = {
commands: QaEvidenceProducerContextFile | null;
kind: "ux-matrix";
manifest:
| (QaEvidenceProducerContextFile & {
path: string;
runStatus: string | null;
runId: string | null;
})
| null;
matrix: {
cells: QaEvidenceMatrixCellView[];
counts: Record<string, number>;
path: string;
stages: string[];
surfaces: string[];
} | null;
preflight: {
adbDevices: QaEvidenceProducerContextFile | null;
memory: QaEvidenceProducerContextFile | null;
};
releaseLedger: (QaEvidenceProducerContextFile & { counts: Record<string, number> }) | null;
rootPath: string;
scorecard: QaEvidenceProducerContextFile | null;
};
export type QaEvidenceGalleryModel = {
counts: Record<QaEvidenceGalleryStatus, number>;
entries: QaEvidenceGalleryEntryView[];
evidenceMode: string;
evidencePath: string;
generatedAt: string;
profile: string | null;
producerContext: QaEvidenceProducerContext | null;
schemaVersion: number;
};

View file

@ -0,0 +1,521 @@
// Qa Lab tests cover generic QA evidence gallery behavior.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
buildQaEvidenceGalleryModel,
resolveQaEvidenceArtifactFile,
resolveQaEvidenceFile,
} from "./evidence-gallery.js";
import {
QA_EVIDENCE_FILENAME,
buildVitestEvidenceSummary,
type QaEvidenceSummaryJson,
} from "./evidence-summary.js";
async function createTempRepo() {
return fs.mkdtemp(path.join(os.tmpdir(), "qa-evidence-gallery-"));
}
async function writeJson(filePath: string, value: unknown) {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
function vitestArtifactEvidence(params: {
id: string;
title: string;
artifact: { kind: string; path: string };
}) {
return {
kind: "openclaw.qa.evidence-summary",
schemaVersion: 2,
generatedAt: "2026-06-17T12:00:00.000Z",
evidenceMode: "full",
entries: [
{
test: { kind: "vitest-test", id: params.id, title: params.title },
coverage: [{ id: "qa.artifact", role: "primary" }],
execution: {
runner: "vitest",
environment: { ref: "gallery-test", os: "darwin", nodeVersion: "v24.0.0" },
provider: {
id: "mock-openai",
live: false,
model: { name: "mock-openai/gpt-5.5", ref: "mock-openai/gpt-5.5" },
},
packageSource: { kind: "source-checkout" },
artifacts: [{ ...params.artifact, source: "vitest" }],
},
result: { status: "pass" },
},
],
};
}
describe("evidence gallery", () => {
it("builds a generic gallery model for non-UX QA Lab evidence", async () => {
const repoRoot = await createTempRepo();
const outputDir = path.join(repoRoot, ".artifacts", "qa-e2e", "vitest");
await fs.mkdir(path.join(outputDir, "runner"), { recursive: true });
await fs.writeFile(path.join(outputDir, "runner", "result.json"), '{"ok":true}\n', "utf8");
await fs.writeFile(path.join(outputDir, "runner", "output.log"), "vitest pass\n", "utf8");
const evidence: QaEvidenceSummaryJson = buildVitestEvidenceSummary({
artifactPaths: [
{ kind: "runner-result", path: "runner/result.json" },
{ kind: "log", path: "runner/output.log" },
],
env: {
OPENCLAW_QA_REF: "gallery-test",
} as NodeJS.ProcessEnv,
generatedAt: "2026-06-17T12:00:00.000Z",
primaryModel: "mock-openai/gpt-5.5",
providerMode: "mock-openai",
targets: [
{
id: "qa-lab.generic-vitest",
title: "Generic Vitest evidence",
sourcePath: "extensions/qa-lab/src/generic.test.ts",
primaryCoverageIds: ["qa.generic"],
},
{
id: "qa-lab.no-artifacts",
title: "Generic entry without artifacts",
sourcePath: "extensions/qa-lab/src/no-artifacts.test.ts",
primaryCoverageIds: ["qa.empty"],
},
],
results: [
{
id: "qa-lab.generic-vitest",
status: "pass",
durationMs: 42,
},
{
id: "qa-lab.no-artifacts",
status: "skipped",
durationMs: 1,
},
],
});
evidence.entries[1] = {
...evidence.entries[1],
execution: {
...evidence.entries[1].execution!,
artifacts: [],
},
};
const evidencePath = path.join(outputDir, QA_EVIDENCE_FILENAME);
await writeJson(evidencePath, evidence);
const model = await buildQaEvidenceGalleryModel({
evidencePath: outputDir,
repoRoot,
});
expect(model.counts).toMatchObject({ pass: 1, skipped: 1, fail: 0, blocked: 0 });
expect(model.evidencePath).toBe(".artifacts/qa-e2e/vitest/qa-evidence.json");
expect(model.producerContext).toBeNull();
expect(model.entries).toHaveLength(2);
expect(model.entries[0]).toMatchObject({
id: "qa-lab.generic-vitest",
kind: "vitest-test",
artifacts: [
expect.objectContaining({
exists: true,
kind: "runner-result",
href: "/api/evidence/artifact?evidencePath=.artifacts%2Fqa-e2e%2Fvitest%2Fqa-evidence.json&artifactPath=runner%2Fresult.json",
mediaKind: "json",
preview: '{\n "ok": true\n}',
}),
expect.objectContaining({
exists: true,
kind: "log",
mediaKind: "text",
preview: "vitest pass\n",
}),
],
});
expect(model.entries[1]).toMatchObject({
id: "qa-lab.no-artifacts",
artifacts: [],
});
});
it("detects UX Matrix producer context from suite-level evidence artifacts", async () => {
const repoRoot = await createTempRepo();
const suiteDir = path.join(repoRoot, ".artifacts", "qa-e2e", "suite");
const runDir = path.join(suiteDir, "script", "ux-matrix-evidence-dashboard", "run-1");
await fs.mkdir(path.join(runDir, "surfaces", "web-ui", "stages", "first-run"), {
recursive: true,
});
await fs.mkdir(path.join(runDir, "surfaces", "cli", "stages", "error-state"), {
recursive: true,
});
await fs.writeFile(
path.join(runDir, "surfaces", "web-ui", "stages", "first-run", "screenshot.png"),
"png",
);
await fs.writeFile(
path.join(runDir, "surfaces", "cli", "stages", "error-state", "logs.txt"),
"cli blocked\n",
"utf8",
);
await writeJson(path.join(runDir, "manifest.json"), {
run: {
runId: "run-1",
status: "pass",
},
});
await writeJson(path.join(runDir, "matrix.json"), {
counts: {
pass: 1,
blocked: 1,
"proof-gap": 1,
},
stages: [
{ id: "first-run", label: "First run" },
{ id: "error-state", label: "Error state" },
],
surfaces: [
{ id: "web-ui", label: "Web UI" },
{ id: "cli", label: "CLI" },
],
cells: [
null,
{
coverageIds: ["ui.control"],
runner: {
availability: "local",
command: "pnpm openclaw qa suite --scenario ux-matrix-evidence-dashboard",
lane: "web-ui-playwright",
workflow: ".github/workflows/ux-matrix-qa.yml#ux-matrix-local",
},
stage: "first-run",
status: "pass",
surface: "web-ui",
},
{
coverageIds: ["cli-entrypoint"],
runner: {
availability: "local",
command: "pnpm openclaw qa suite --scenario ux-matrix-evidence-dashboard",
lane: "cli-status",
workflow: ".github/workflows/ux-matrix-qa.yml#ux-matrix-local",
},
stage: "first-run",
status: "proof-gap",
surface: "cli",
},
{ stage: "error-state", status: "blocked", surface: "cli" },
],
});
await writeJson(path.join(runDir, "release-ledger.json"), {
counts: {
pass: 1,
blocked: 1,
"proof-gap": 1,
},
});
await fs.writeFile(path.join(runDir, "scorecard.md"), "# UX Matrix\n\n- pass: 1\n", "utf8");
await fs.writeFile(path.join(runDir, "commands.txt"), "node ux matrix\n", "utf8");
await fs.mkdir(path.join(runDir, "preflight"), { recursive: true });
await fs.writeFile(path.join(runDir, "preflight", "memory.txt"), "memory ok\n", "utf8");
await fs.writeFile(
path.join(runDir, "preflight", "adb-devices.txt"),
"List of devices\n",
"utf8",
);
await writeJson(path.join(suiteDir, QA_EVIDENCE_FILENAME), {
kind: "openclaw.qa.evidence-summary",
schemaVersion: 2,
generatedAt: "2026-06-17T12:00:00.000Z",
evidenceMode: "full",
entries: [
{
test: {
kind: "ux-matrix-cell",
id: "ux-matrix.web-ui.first-run",
title: "UX Matrix: web-ui / first-run",
source: { path: "scripts/ux-matrix/dashboard.ts" },
},
coverage: [{ id: "ui.control", role: "primary" }],
execution: {
runner: "ux-matrix-dashboard",
environment: {
ref: "gallery-test",
os: "darwin",
nodeVersion: "v24.0.0",
},
provider: {
id: "ux-matrix",
live: false,
model: { name: null, ref: null },
fixture: "mocked-control-ui-and-isolated-cli",
},
packageSource: { kind: "source-checkout", sha: "abc123" },
artifacts: [
{
kind: "screenshot",
path: ".artifacts/qa-e2e/suite/script/ux-matrix-evidence-dashboard/run-1/surfaces/web-ui/stages/first-run/screenshot.png",
source: "ux-matrix:web-ui:first-run",
},
],
},
result: { status: "pass", timing: { wallMs: 1 } },
},
{
test: {
kind: "ux-matrix-cell",
id: "qa-lab.wrapper-cli-error",
title: "UX Matrix: cli / error-state",
source: { path: "scripts/ux-matrix/dashboard.ts" },
},
coverage: [{ id: "status-snapshots", role: "primary" }],
execution: {
runner: "ux-matrix-dashboard",
environment: {
ref: "gallery-test",
os: "darwin",
nodeVersion: "v24.0.0",
},
provider: {
id: "ux-matrix",
live: false,
model: { name: null, ref: null },
fixture: "mocked-control-ui-and-isolated-cli",
},
packageSource: { kind: "source-checkout", sha: "abc123" },
artifacts: [
{
kind: "log",
path: ".artifacts/qa-e2e/suite/script/ux-matrix-evidence-dashboard/run-1/surfaces/cli/stages/error-state/logs.txt",
source: "ux-matrix:cli:error-state",
},
],
},
result: {
status: "blocked",
failure: {
class: "blocked",
reason: "CLI error-state proof captured a blocked result.",
},
timing: { wallMs: 2 },
},
},
],
});
const model = await buildQaEvidenceGalleryModel({
evidencePath: suiteDir,
repoRoot,
});
expect(model.producerContext).toMatchObject({
kind: "ux-matrix",
manifest: {
runId: "run-1",
runStatus: "pass",
},
matrix: {
counts: {
pass: 1,
blocked: 1,
"proof-gap": 1,
},
stages: ["first-run", "error-state"],
surfaces: ["web-ui", "cli"],
},
releaseLedger: {
counts: {
pass: 1,
blocked: 1,
"proof-gap": 1,
},
},
});
expect(model.producerContext?.matrix?.cells).toEqual([
{
artifactKinds: ["screenshot"],
artifactPaths: [
".artifacts/qa-e2e/suite/script/ux-matrix-evidence-dashboard/run-1/surfaces/web-ui/stages/first-run/screenshot.png",
],
coverageIds: ["ui.control"],
runner: {
availability: "local",
command: "pnpm openclaw qa suite --scenario ux-matrix-evidence-dashboard",
lane: "web-ui-playwright",
workflow: ".github/workflows/ux-matrix-qa.yml#ux-matrix-local",
},
stage: "first-run",
status: "pass",
surface: "web-ui",
testId: "ux-matrix.web-ui.first-run",
title: "UX Matrix: web-ui / first-run",
},
{
artifactKinds: [],
artifactPaths: [],
coverageIds: ["cli-entrypoint"],
runner: {
availability: "local",
command: "pnpm openclaw qa suite --scenario ux-matrix-evidence-dashboard",
lane: "cli-status",
workflow: ".github/workflows/ux-matrix-qa.yml#ux-matrix-local",
},
stage: "first-run",
status: "proof-gap",
surface: "cli",
testId: null,
title: null,
},
{
artifactKinds: ["log"],
artifactPaths: [
".artifacts/qa-e2e/suite/script/ux-matrix-evidence-dashboard/run-1/surfaces/cli/stages/error-state/logs.txt",
],
coverageIds: [],
runner: null,
stage: "error-state",
status: "blocked",
surface: "cli",
testId: "qa-lab.wrapper-cli-error",
title: "UX Matrix: cli / error-state",
},
]);
expect(model.producerContext?.scorecard?.preview).toContain("# UX Matrix");
expect(model.producerContext?.scorecard?.href).toContain("/api/evidence/artifact?");
expect(model.producerContext?.scorecard?.href).not.toContain(repoRoot);
expect(model.producerContext?.commands?.preview).toBe("node ux matrix\n");
expect(model.producerContext?.commands?.path).toContain("commands.txt");
expect(model.producerContext?.manifest?.preview).toContain('"runId": "run-1"');
expect(model.producerContext?.releaseLedger?.preview).toContain('"proof-gap": 1');
expect(model.producerContext?.preflight.memory?.path).toContain("preflight/memory.txt");
expect(model.producerContext?.preflight.memory?.preview).toBe("memory ok\n");
expect(model.producerContext?.preflight.adbDevices?.path).toContain(
"preflight/adb-devices.txt",
);
expect(model.producerContext?.preflight.adbDevices?.preview).toBe("List of devices\n");
expect(model.evidencePath).toBe(".artifacts/qa-e2e/suite/qa-evidence.json");
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "qa-evidence-outside-"));
const outsideCommands = path.join(outsideDir, "commands.txt");
await fs.writeFile(outsideCommands, "outside secret\n", "utf8");
await fs.unlink(path.join(runDir, "commands.txt"));
await fs.symlink(outsideCommands, path.join(runDir, "commands.txt"));
const symlinkModel = await buildQaEvidenceGalleryModel({
evidencePath: suiteDir,
repoRoot,
});
expect(symlinkModel.producerContext?.commands).toBeNull();
expect(JSON.stringify(symlinkModel)).not.toContain("outside secret");
await expect(
resolveQaEvidenceArtifactFile({
artifactPath:
".artifacts/qa-e2e/suite/script/ux-matrix-evidence-dashboard/run-1/scorecard.md",
evidencePath: suiteDir,
repoRoot,
}),
).resolves.toBe(await fs.realpath(path.join(runDir, "scorecard.md")));
});
it("resolves evidence and declared artifacts inside the repo root only", async () => {
const repoRoot = await createTempRepo();
const outputDir = path.join(repoRoot, ".artifacts", "qa-e2e", "suite");
const evidencePath = path.join(outputDir, QA_EVIDENCE_FILENAME);
await fs.writeFile(path.join(repoRoot, "package.json"), '{"private":true}\n', "utf8");
await fs.mkdir(outputDir, { recursive: true });
await fs.writeFile(path.join(outputDir, "artifact.log"), "ok\n", "utf8");
await writeJson(
evidencePath,
vitestArtifactEvidence({
id: "qa-lab.declared-artifact",
title: "Declared artifact",
artifact: { kind: "log", path: "artifact.log" },
}),
);
await expect(resolveQaEvidenceFile({ inputPath: outputDir, repoRoot })).resolves.toBe(
await fs.realpath(evidencePath),
);
await expect(
resolveQaEvidenceArtifactFile({
artifactPath: "artifact.log",
evidencePath,
repoRoot,
}),
).resolves.toBe(await fs.realpath(path.join(outputDir, "artifact.log")));
await fs.mkdir(path.join(repoRoot, "runner"), { recursive: true });
await fs.mkdir(path.join(outputDir, "runner"), { recursive: true });
await fs.writeFile(path.join(repoRoot, "runner", "result.json"), '{"from":"repo"}\n', "utf8");
await fs.writeFile(
path.join(outputDir, "runner", "result.json"),
'{"from":"evidence"}\n',
"utf8",
);
const collisionEvidence = vitestArtifactEvidence({
id: "qa-lab.colliding-artifact",
title: "Colliding artifact",
artifact: { kind: "runner-result", path: "runner/result.json" },
});
await writeJson(evidencePath, collisionEvidence);
await expect(
resolveQaEvidenceArtifactFile({
artifactPath: "runner/result.json",
evidencePath,
repoRoot,
}),
).resolves.toBe(await fs.realpath(path.join(outputDir, "runner", "result.json")));
await fs.rm(path.join(outputDir, "runner", "result.json"));
const missingBundleModel = await buildQaEvidenceGalleryModel({ evidencePath, repoRoot });
expect(missingBundleModel.entries[0].artifacts[0]).toMatchObject({
exists: false,
error: "Evidence artifact not found.",
preview: null,
});
expect(JSON.stringify(missingBundleModel)).not.toContain('"from":"repo"');
await expect(
resolveQaEvidenceArtifactFile({
artifactPath: "package.json",
evidencePath,
repoRoot,
}),
).rejects.toThrow("Evidence artifact not found.");
await fs.writeFile(path.join(outputDir, "undeclared.log"), "undeclared\n", "utf8");
await expect(
resolveQaEvidenceArtifactFile({
artifactPath: "undeclared.log",
evidencePath,
repoRoot,
}),
).rejects.toThrow("Evidence artifact is not declared by this evidence summary.");
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "qa-evidence-outside-artifact-"));
const outsideArtifact = path.join(outsideDir, "artifact.log");
await fs.writeFile(outsideArtifact, "outside secret\n", "utf8");
await fs.symlink(outsideArtifact, path.join(outputDir, "escape.log"));
await writeJson(evidencePath, {
...collisionEvidence,
entries: [
{
...collisionEvidence.entries[0],
execution: {
...collisionEvidence.entries[0].execution,
artifacts: [{ kind: "log", path: "escape.log", source: "vitest" }],
},
},
],
});
await expect(
resolveQaEvidenceArtifactFile({
artifactPath: "escape.log",
evidencePath,
repoRoot,
}),
).rejects.toThrow("Evidence artifact not found.");
await expect(
resolveQaEvidenceFile({ inputPath: "/tmp/not-openclaw-evidence.json", repoRoot }),
).rejects.toThrow("Evidence path not found.");
});
});

View file

@ -0,0 +1,711 @@
// Qa Lab plugin module implements generic QA evidence gallery data.
import fs from "node:fs/promises";
import path from "node:path";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type {
QaEvidenceArtifactView,
QaEvidenceGalleryEntryView,
QaEvidenceGalleryModel,
QaEvidenceMatrixCellView,
QaEvidenceProducerContext,
QaEvidenceProducerContextFile,
} from "../shared/evidence-gallery-types.js";
import { toRepoRelativePath } from "./cli-paths.js";
import {
QA_EVIDENCE_FILENAME,
validateQaEvidenceSummaryJson,
type QaEvidenceStatus,
type QaEvidenceSummaryEntry,
} from "./evidence-summary.js";
export type {
QaEvidenceArtifactView,
QaEvidenceGalleryEntryView,
QaEvidenceGalleryModel,
QaEvidenceMatrixCellView,
QaEvidenceProducerContext,
QaEvidenceProducerContextFile,
} from "../shared/evidence-gallery-types.js";
const TEXT_PREVIEW_BYTES = 12 * 1024;
const ARTIFACT_VIEW_CONCURRENCY = 8;
const UX_MATRIX_PRODUCER_FILES = [
{ key: "commands", path: "commands.txt", previewKind: "text" },
{ key: "manifest", path: "manifest.json", previewKind: "json" },
{ key: "matrix", path: "matrix.json", previewKind: "json" },
{ key: "releaseLedger", path: "release-ledger.json", previewKind: "json" },
{ key: "scorecard", path: "scorecard.md", previewKind: "text" },
{ key: "memory", path: path.join("preflight", "memory.txt"), previewKind: "text" },
{ key: "adbDevices", path: path.join("preflight", "adb-devices.txt"), previewKind: "text" },
] as const;
type QaEvidenceArtifact = NonNullable<QaEvidenceSummaryEntry["execution"]>["artifacts"][number];
export class QaEvidenceGalleryError extends Error {
readonly statusCode: number;
constructor(message: string, statusCode: number) {
super(message);
this.name = "QaEvidenceGalleryError";
this.statusCode = statusCode;
}
}
function evidenceError(message: string, statusCode: number): QaEvidenceGalleryError {
return new QaEvidenceGalleryError(message, statusCode);
}
function isInside(root: string, candidate: string) {
const relative = path.relative(root, candidate);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
async function realpathIfExists(filePath: string): Promise<string | null> {
return fs.realpath(filePath).catch(() => null);
}
async function resolveContainedFileIfExists(
filePath: string,
allowedRoots: readonly string[],
): Promise<string | null> {
const realFile = await realpathIfExists(filePath);
if (!realFile) {
return null;
}
if (!allowedRoots.some((root) => isInside(root, realFile))) {
return null;
}
const stats = await fs.stat(realFile).catch(() => null);
return stats?.isFile() ? realFile : null;
}
export async function resolveQaEvidenceFile(params: {
inputPath: string;
repoRoot: string;
}): Promise<string> {
const repoRoot = await fs.realpath(path.resolve(params.repoRoot));
const raw = params.inputPath.trim();
if (!raw) {
throw evidenceError("Evidence path is required.", 400);
}
const candidate = path.resolve(repoRoot, raw);
const realCandidate = await realpathIfExists(candidate);
if (!realCandidate) {
throw evidenceError("Evidence path not found.", 404);
}
if (!isInside(repoRoot, realCandidate)) {
throw evidenceError("Evidence path must stay inside the repo root.", 403);
}
const stats = await fs.stat(realCandidate);
const evidencePath = stats.isDirectory()
? path.join(realCandidate, QA_EVIDENCE_FILENAME)
: realCandidate;
const realEvidencePath = await realpathIfExists(evidencePath);
if (!realEvidencePath) {
throw evidenceError("qa-evidence.json not found.", 404);
}
if (!isInside(repoRoot, realEvidencePath)) {
throw evidenceError("qa-evidence.json must stay inside the repo root.", 403);
}
return realEvidencePath;
}
export async function resolveQaEvidenceArtifactFile(params: {
artifactPath: string;
evidencePath: string;
repoRoot: string;
}): Promise<string> {
const repoRoot = await fs.realpath(path.resolve(params.repoRoot));
const evidencePath = await resolveQaEvidenceFile({ inputPath: params.evidencePath, repoRoot });
if (!params.artifactPath.trim()) {
throw evidenceError("Artifact path is required.", 400);
}
const summary = validateQaEvidenceSummaryJson(
JSON.parse(await fs.readFile(evidencePath, "utf8")) as unknown,
);
const artifactFile = await resolveArtifactFileWithinRoots({
artifactPath: params.artifactPath,
evidenceDir: path.dirname(evidencePath),
repoRoot,
});
if (!artifactFile) {
throw evidenceError("Evidence artifact not found.", 404);
}
const allowedArtifactFiles = await collectDeclaredQaEvidenceArtifactFiles({
evidencePath,
repoRoot,
summaryEntries: summary.entries,
});
if (allowedArtifactFiles.has(artifactFile)) {
return artifactFile;
}
throw evidenceError("Evidence artifact is not declared by this evidence summary.", 403);
}
function isExplicitRepoRootArtifactPath(raw: string): boolean {
const normalized = raw.split(/[\\/]+/u).join("/");
return normalized.startsWith(".artifacts/");
}
// Resolve an artifact path against pre-resolved roots without re-reading the evidence file.
// Returns null when the path is missing or escapes both roots; callers map that to an error.
async function resolveArtifactFileWithinRoots(params: {
artifactPath: string;
evidenceDir: string;
repoRoot: string;
}): Promise<string | null> {
const raw = params.artifactPath.trim();
if (!raw) {
return null;
}
const candidates = path.isAbsolute(raw) ? [raw] : [path.resolve(params.evidenceDir, raw)];
if (!path.isAbsolute(raw) && isExplicitRepoRootArtifactPath(raw)) {
candidates.push(path.resolve(params.repoRoot, raw));
}
for (const candidate of candidates) {
const realCandidate = await realpathIfExists(candidate);
if (!realCandidate) {
continue;
}
if (!isInside(params.repoRoot, realCandidate) && !isInside(params.evidenceDir, realCandidate)) {
continue;
}
const stats = await fs.stat(realCandidate).catch(() => null);
if (stats?.isFile()) {
return realCandidate;
}
}
return null;
}
async function collectDeclaredQaEvidenceArtifactFiles(params: {
evidencePath: string;
repoRoot: string;
summaryEntries: readonly QaEvidenceSummaryEntry[];
}): Promise<Set<string>> {
const repoRoot = await fs.realpath(path.resolve(params.repoRoot));
const evidenceDir = path.dirname(params.evidencePath);
const allowed = new Set<string>();
for (const entry of params.summaryEntries) {
for (const artifact of entry.execution?.artifacts ?? []) {
const artifactPath = await resolveArtifactFileWithinRoots({
artifactPath: artifact.path,
evidenceDir,
repoRoot,
});
if (artifactPath) {
allowed.add(artifactPath);
}
}
}
const producerRoot = await findUxMatrixProducerRoot({
evidencePath: params.evidencePath,
repoRoot: params.repoRoot,
summaryEntries: params.summaryEntries,
});
if (producerRoot) {
const producerFiles = [
...UX_MATRIX_PRODUCER_FILES.map((file) => file.path),
QA_EVIDENCE_FILENAME,
];
for (const producerFile of producerFiles) {
const realProducerFile = await realpathIfExists(path.join(producerRoot, producerFile));
if (realProducerFile) {
allowed.add(realProducerFile);
}
}
}
return allowed;
}
function classifyArtifact(kind: string, filePath: string): QaEvidenceArtifactView["mediaKind"] {
const normalizedKind = kind.toLowerCase();
const ext = path.extname(filePath).toLowerCase();
if (
normalizedKind.includes("screenshot") ||
normalizedKind.includes("gif") ||
[".png", ".jpg", ".jpeg", ".gif", ".webp"].includes(ext)
) {
return "image";
}
if (normalizedKind.includes("video") || [".webm", ".mp4", ".mov"].includes(ext)) {
return "video";
}
if (
normalizedKind.includes("validation") ||
normalizedKind.includes("json") ||
ext === ".json" ||
ext === ".jsonl"
) {
return "json";
}
if (
normalizedKind.includes("log") ||
normalizedKind.includes("report") ||
[".log", ".md", ".txt"].includes(ext)
) {
return "text";
}
return "file";
}
async function readPreview(filePath: string, mediaKind: QaEvidenceArtifactView["mediaKind"]) {
if (mediaKind !== "json" && mediaKind !== "text") {
return null;
}
const handle = await fs.open(filePath, "r");
try {
const buffer = Buffer.alloc(TEXT_PREVIEW_BYTES);
const { bytesRead } = await handle.read(buffer, 0, TEXT_PREVIEW_BYTES, 0);
const text = buffer.subarray(0, bytesRead).toString("utf8");
if (mediaKind !== "json") {
return text;
}
try {
return JSON.stringify(JSON.parse(text), null, 2);
} catch {
return text;
}
} finally {
await handle.close();
}
}
async function readJsonIfExists(
filePath: string,
allowedRoots: readonly string[],
): Promise<Record<string, unknown> | null> {
const realFile = await resolveContainedFileIfExists(filePath, allowedRoots);
if (!realFile) {
return null;
}
try {
const value = JSON.parse(await fs.readFile(realFile, "utf8")) as unknown;
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
} catch {
return null;
}
}
function artifactHref(evidencePath: string, artifactPath: string) {
const params = new URLSearchParams({
evidencePath,
artifactPath,
});
return `/api/evidence/artifact?${params.toString()}`;
}
async function buildProducerContextFile(params: {
allowedRoots: readonly string[];
artifactPath: string;
filePath: string;
hrefEvidencePath: string;
previewKind: "json" | "text";
repoRoot: string;
}): Promise<QaEvidenceProducerContextFile | null> {
const realFile = await resolveContainedFileIfExists(params.filePath, params.allowedRoots);
if (!realFile) {
return null;
}
const repoPath = toRepoRelativePath(params.repoRoot, params.filePath);
return {
href: artifactHref(params.hrefEvidencePath, params.artifactPath),
path: repoPath,
preview: await readPreview(realFile, params.previewKind).catch(() => null),
};
}
async function buildArtifactView(params: {
allowedArtifactFiles: ReadonlySet<string>;
artifact: QaEvidenceArtifact;
evidenceDir: string;
hrefEvidencePath: string;
repoRoot: string;
}): Promise<QaEvidenceArtifactView> {
const mediaKind = classifyArtifact(params.artifact.kind, params.artifact.path);
const realFile = await resolveArtifactFileWithinRoots({
artifactPath: params.artifact.path,
evidenceDir: params.evidenceDir,
repoRoot: params.repoRoot,
}).catch(() => null);
if (!realFile || !params.allowedArtifactFiles.has(realFile)) {
return {
exists: false,
error: realFile
? "Evidence artifact is not declared by this evidence summary."
: "Evidence artifact not found.",
href: null,
kind: params.artifact.kind,
mediaKind,
path: params.artifact.path,
preview: null,
source: params.artifact.source,
};
}
return {
exists: true,
error: null,
href: artifactHref(params.hrefEvidencePath, params.artifact.path),
kind: params.artifact.kind,
mediaKind,
path: params.artifact.path,
preview: await readPreview(realFile, mediaKind).catch(
(error: unknown) => `Preview unavailable: ${formatErrorMessage(error)}`,
),
source: params.artifact.source,
};
}
function readString(value: unknown): string | null {
return typeof value === "string" ? value : null;
}
function readRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function readCountRecord(value: unknown): Record<string, number> {
const record = readRecord(value);
if (!record) {
return {};
}
return Object.fromEntries(
Object.entries(record).filter(
(entry): entry is [string, number] => typeof entry[1] === "number",
),
);
}
function readOrderedStringArray(values: Iterable<unknown>) {
return Array.from(
new Set(Array.from(values).filter((value): value is string => typeof value === "string")),
);
}
function readStringArray(values: Iterable<unknown>) {
return readOrderedStringArray(values).toSorted();
}
function readMatrixDimensionIds(value: unknown, fallback: readonly string[]): string[] {
if (!Array.isArray(value)) {
return readOrderedStringArray(fallback);
}
const ids = readOrderedStringArray(
value.map((entry) => {
if (typeof entry === "string") {
return entry;
}
return readString(readRecord(entry)?.id);
}),
);
for (const fallbackId of fallback) {
if (!ids.includes(fallbackId)) {
ids.push(fallbackId);
}
}
return ids;
}
function uxMatrixEntryKey(
entry: QaEvidenceSummaryEntry,
): { stage: string; surface: string } | null {
const idMatch = /^ux-matrix\.([a-z0-9-]+)\.([a-z0-9-]+)$/u.exec(entry.test.id);
if (idMatch) {
return { surface: idMatch[1], stage: idMatch[2] };
}
for (const artifact of entry.execution?.artifacts ?? []) {
const sourceMatch = /^ux-matrix:([a-z0-9-]+):([a-z0-9-]+)$/u.exec(artifact.source);
if (sourceMatch) {
return { surface: sourceMatch[1], stage: sourceMatch[2] };
}
}
return null;
}
function buildUxMatrixEvidenceEntryIndex(entries: readonly QaEvidenceSummaryEntry[]) {
const indexed = new Map<string, QaEvidenceSummaryEntry>();
for (const entry of entries) {
const key = uxMatrixEntryKey(entry);
if (key) {
indexed.set(`${key.surface}:${key.stage}`, entry);
}
}
return indexed;
}
function readMatrixCells(params: {
matrix: Record<string, unknown> | null;
summaryEntries: readonly QaEvidenceSummaryEntry[];
}): QaEvidenceMatrixCellView[] {
const rawCells = Array.isArray(params.matrix?.cells)
? params.matrix.cells
.map(readRecord)
.filter((cell): cell is Record<string, unknown> => Boolean(cell))
: [];
const entriesByCell = buildUxMatrixEvidenceEntryIndex(params.summaryEntries);
return rawCells.flatMap((cell): QaEvidenceMatrixCellView[] => {
const surface = readString(cell.surface);
const stage = readString(cell.stage);
const status = readString(cell.status) ?? "proof-gap";
if (!surface || !stage) {
return [];
}
const entry =
status === "proof-gap" ? null : (entriesByCell.get(`${surface}:${stage}`) ?? null);
const artifacts = entry?.execution?.artifacts ?? [];
const runner = readRecord(cell.runner);
return [
{
artifactKinds: readStringArray(artifacts.map((artifact) => artifact.kind)),
artifactPaths: artifacts.map((artifact) => artifact.path),
coverageIds: readStringArray(Array.isArray(cell.coverageIds) ? cell.coverageIds : []),
runner: runner
? {
availability: readString(runner.availability),
command: readString(runner.command),
lane: readString(runner.lane),
workflow: readString(runner.workflow),
}
: null,
stage,
status,
surface,
testId: entry?.test.id ?? null,
title: entry?.test.title ?? null,
},
];
});
}
async function candidateProducerRoots(params: {
evidencePath: string;
repoRoot: string;
summaryEntries: readonly QaEvidenceSummaryEntry[];
}) {
const repoRoot = await fs.realpath(path.resolve(params.repoRoot));
const evidenceDir = path.dirname(params.evidencePath);
const roots = new Set<string>([evidenceDir]);
for (const entry of params.summaryEntries) {
for (const artifact of entry.execution?.artifacts ?? []) {
const artifactPath = await resolveArtifactFileWithinRoots({
artifactPath: artifact.path,
evidenceDir,
repoRoot,
});
if (!artifactPath) {
continue;
}
let current = path.dirname(artifactPath);
while (isInside(repoRoot, current)) {
roots.add(current);
const parent = path.dirname(current);
if (parent === current) {
break;
}
current = parent;
}
}
}
return Array.from(roots);
}
async function findUxMatrixProducerRoot(params: {
evidencePath: string;
repoRoot: string;
summaryEntries: readonly QaEvidenceSummaryEntry[];
}) {
for (const candidate of await candidateProducerRoots(params)) {
const [manifest, matrix] = await Promise.all([
realpathIfExists(path.join(candidate, "manifest.json")),
realpathIfExists(path.join(candidate, "matrix.json")),
]);
if (manifest && matrix) {
return candidate;
}
}
return null;
}
async function buildProducerContext(params: {
evidencePath: string;
hrefEvidencePath: string;
repoRoot: string;
summaryEntries: readonly QaEvidenceSummaryEntry[];
}): Promise<QaEvidenceProducerContext | null> {
const rootPath = await findUxMatrixProducerRoot(params);
if (!rootPath) {
return null;
}
const repoRoot = await fs.realpath(path.resolve(params.repoRoot));
const evidenceDir = path.dirname(
await resolveQaEvidenceFile({ inputPath: params.evidencePath, repoRoot }),
);
const allowedRoots = [repoRoot, evidenceDir];
const producerPaths = Object.fromEntries(
UX_MATRIX_PRODUCER_FILES.map((file) => [file.key, path.join(rootPath, file.path)]),
) as Record<(typeof UX_MATRIX_PRODUCER_FILES)[number]["key"], string>;
const manifestPath = producerPaths.manifest;
const matrixPath = producerPaths.matrix;
const releaseLedgerPath = producerPaths.releaseLedger;
const manifest = await readJsonIfExists(manifestPath, allowedRoots);
const matrix = await readJsonIfExists(matrixPath, allowedRoots);
const releaseLedger = await readJsonIfExists(releaseLedgerPath, allowedRoots);
const producerFiles = Object.fromEntries(
await Promise.all(
UX_MATRIX_PRODUCER_FILES.map(async (file) => [
file.key,
await buildProducerContextFile({
allowedRoots,
artifactPath: toRepoRelativePath(repoRoot, producerPaths[file.key]),
filePath: producerPaths[file.key],
hrefEvidencePath: params.hrefEvidencePath,
previewKind: file.previewKind,
repoRoot,
}),
]),
),
) as Record<
(typeof UX_MATRIX_PRODUCER_FILES)[number]["key"],
QaEvidenceProducerContextFile | null
>;
const matrixCells = readMatrixCells({
matrix,
summaryEntries: params.summaryEntries,
});
return {
commands: producerFiles.commands,
kind: "ux-matrix",
manifest:
manifest && producerFiles.manifest
? {
...producerFiles.manifest,
runId: readString(readRecord(manifest.run)?.runId),
runStatus: readString(readRecord(manifest.run)?.status),
}
: null,
matrix: matrix
? {
cells: matrixCells,
counts: readCountRecord(matrix.counts),
path: toRepoRelativePath(repoRoot, matrixPath),
stages: readMatrixDimensionIds(
matrix.stages,
matrixCells.map((cell) => cell.stage),
),
surfaces: readMatrixDimensionIds(
matrix.surfaces,
matrixCells.map((cell) => cell.surface),
),
}
: null,
preflight: {
adbDevices: producerFiles.adbDevices,
memory: producerFiles.memory,
},
releaseLedger:
releaseLedger && producerFiles.releaseLedger
? {
...producerFiles.releaseLedger,
counts: readCountRecord(releaseLedger.counts),
}
: null,
rootPath: toRepoRelativePath(repoRoot, rootPath),
scorecard: producerFiles.scorecard,
};
}
function createConcurrencyLimit(limit: number) {
let active = 0;
const queue: Array<() => void> = [];
return async function runLimited<T>(task: () => Promise<T>): Promise<T> {
if (active >= limit) {
await new Promise<void>((resolve) => {
queue.push(resolve);
});
}
active += 1;
try {
return await task();
} finally {
active -= 1;
queue.shift()?.();
}
};
}
export async function buildQaEvidenceGalleryModel(params: {
evidencePath: string;
repoRoot: string;
}): Promise<QaEvidenceGalleryModel> {
const repoRoot = await fs.realpath(path.resolve(params.repoRoot));
const evidencePath = await resolveQaEvidenceFile({
inputPath: params.evidencePath,
repoRoot,
});
const hrefEvidencePath = toRepoRelativePath(repoRoot, evidencePath);
const summary = validateQaEvidenceSummaryJson(
JSON.parse(await fs.readFile(evidencePath, "utf8")) as unknown,
);
const counts: Record<QaEvidenceStatus, number> = {
pass: 0,
fail: 0,
blocked: 0,
skipped: 0,
};
// Resolve the declared-artifact allowlist once; buildArtifactView then only checks membership
// instead of re-reading the evidence file and re-collecting the allowlist per artifact.
const evidenceDir = path.dirname(evidencePath);
const allowedArtifactFiles = await collectDeclaredQaEvidenceArtifactFiles({
evidencePath,
repoRoot,
summaryEntries: summary.entries,
});
const limitArtifactView = createConcurrencyLimit(ARTIFACT_VIEW_CONCURRENCY);
const entries = await Promise.all(
summary.entries.map(async (entry): Promise<QaEvidenceGalleryEntryView> => {
counts[entry.result.status] += 1;
return {
artifacts: await Promise.all(
(entry.execution?.artifacts ?? []).map((artifact) =>
limitArtifactView(() =>
buildArtifactView({
allowedArtifactFiles,
artifact,
evidenceDir,
hrefEvidencePath,
repoRoot,
}),
),
),
),
coverage: entry.coverage,
failureReason: entry.result.failure?.reason ?? null,
id: entry.test.id,
kind: entry.test.kind,
sourcePath: entry.test.source?.path ?? null,
status: entry.result.status,
title: entry.test.title,
};
}),
);
return {
counts,
entries,
evidenceMode: summary.evidenceMode,
evidencePath: hrefEvidencePath,
generatedAt: summary.generatedAt,
profile: summary.profile ?? null,
producerContext: await buildProducerContext({
evidencePath,
hrefEvidencePath,
repoRoot,
summaryEntries: summary.entries,
}),
schemaVersion: summary.schemaVersion,
};
}

View file

@ -354,11 +354,9 @@ describe("qa-lab server", () => {
reject(new Error("gateway stop failed"));
return;
}
abortSignal.addEventListener(
"abort",
() => reject(new Error("gateway stop failed")),
{ once: true },
);
abortSignal.addEventListener("abort", () => reject(new Error("gateway stop failed")), {
once: true,
});
}),
);
@ -450,6 +448,119 @@ describe("qa-lab server", () => {
await expectFileMissing(outputPath);
});
it("serves evidence artifact HEAD metadata and streams GET bodies", async () => {
const repoRoot = await createQaLabRepoRootFixture();
const evidenceDir = path.join(repoRoot, ".artifacts", "qa-e2e", "server");
await mkdir(evidenceDir, { recursive: true });
await writeFile(path.join(evidenceDir, "artifact.log"), "streamed body\n", "utf8");
await writeFile(
path.join(evidenceDir, "qa-evidence.json"),
`${JSON.stringify(
{
kind: "openclaw.qa.evidence-summary",
schemaVersion: 2,
generatedAt: "2026-06-17T12:00:00.000Z",
evidenceMode: "full",
entries: [
{
test: {
kind: "vitest-test",
id: "qa-lab.server-artifact",
title: "Server artifact",
},
coverage: [{ id: "qa.artifact", role: "primary" }],
execution: {
runner: "vitest",
environment: {
ref: "server-test",
os: "darwin",
nodeVersion: "v24.0.0",
},
provider: {
id: "mock-openai",
live: false,
model: { name: "mock-openai/gpt-5.5", ref: "mock-openai/gpt-5.5" },
},
packageSource: { kind: "source-checkout" },
artifacts: [{ kind: "log", path: "artifact.log", source: "vitest" }],
},
result: { status: "pass" },
},
],
},
null,
2,
)}\n`,
"utf8",
);
const lab = await startQaLabServerForTest({
host: "127.0.0.1",
port: 0,
repoRoot,
});
cleanups.push(async () => {
await lab.stop();
});
const evidenceUrl = new URL("/api/evidence", lab.baseUrl);
evidenceUrl.searchParams.set("path", ".artifacts/qa-e2e/server/qa-evidence.json");
const evidenceResponse = await fetchWithRetry(evidenceUrl.toString());
expect(evidenceResponse.status).toBe(200);
expect(evidenceResponse.headers.get("cache-control")).toBe("no-store");
expect((await evidenceResponse.json()) as unknown).toMatchObject({
evidence: {
counts: {
pass: 1,
},
entries: [{ id: "qa-lab.server-artifact" }],
},
});
// A missing evidence path must return a controlled JSON error, not a reset connection
// (the model must build before any success header is written).
const missingEvidenceUrl = new URL("/api/evidence", lab.baseUrl);
missingEvidenceUrl.searchParams.set("path", ".artifacts/qa-e2e/server/does-not-exist.json");
const missingEvidenceResponse = await fetchWithRetry(missingEvidenceUrl.toString());
expect(missingEvidenceResponse.status).toBe(404);
expect(await missingEvidenceResponse.text()).not.toBe("");
const artifactUrl = new URL("/api/evidence/artifact", lab.baseUrl);
artifactUrl.searchParams.set("evidencePath", ".artifacts/qa-e2e/server/qa-evidence.json");
artifactUrl.searchParams.set("artifactPath", "artifact.log");
const headResponse = await fetchWithRetry(artifactUrl.toString(), { method: "HEAD" });
expect(headResponse.status).toBe(200);
expect(headResponse.headers.get("content-type")).toBe("text/plain; charset=utf-8");
expect(headResponse.headers.get("content-length")).toBe("14");
expect(headResponse.headers.get("cache-control")).toBe("no-store");
expect(headResponse.headers.get("x-content-type-options")).toBe("nosniff");
expect(await headResponse.text()).toBe("");
const getResponse = await fetchWithRetry(artifactUrl.toString());
expect(getResponse.status).toBe(200);
expect(getResponse.headers.get("content-length")).toBe("14");
expect(getResponse.headers.get("cache-control")).toBe("no-store");
expect(getResponse.headers.get("x-content-type-options")).toBe("nosniff");
expect(await getResponse.text()).toBe("streamed body\n");
await writeFile(path.join(evidenceDir, "undeclared.log"), "hidden\n", "utf8");
const undeclaredUrl = new URL(artifactUrl);
undeclaredUrl.searchParams.set("artifactPath", "undeclared.log");
const undeclaredResponse = await fetchWithRetry(undeclaredUrl.toString());
expect(undeclaredResponse.status).toBe(403);
const outsideDir = await mkdtemp(path.join(os.tmpdir(), "qa-lab-outside-artifact-"));
cleanups.push(async () => {
await rm(outsideDir, { recursive: true, force: true });
});
const outsideArtifact = path.join(outsideDir, "outside.log");
await writeFile(outsideArtifact, "outside\n", "utf8");
const outsideUrl = new URL(artifactUrl);
outsideUrl.searchParams.set("artifactPath", outsideArtifact);
const outsideResponse = await fetchWithRetry(outsideUrl.toString());
expect(outsideResponse.status).toBe(404);
});
it("returns controlled errors for oversized JSON body reads", async () => {
const req = {
headers: { "content-length": String(1024 * 1024 + 1) },

View file

@ -16,6 +16,11 @@ import {
writeQaRequestBodyLimitError,
} from "./bus-server.js";
import { createQaBusState, type QaBusState } from "./bus-state.js";
import {
QaEvidenceGalleryError,
buildQaEvidenceGalleryModel,
resolveQaEvidenceArtifactFile,
} from "./evidence-gallery.js";
import { createQaRunnerRuntime } from "./harness-runtime.js";
import {
isCaptureQueryPreset,
@ -69,6 +74,10 @@ export function writeQaLabServerError(res: Parameters<typeof writeError>[0], err
if (writeQaRequestBodyLimitError(res, error)) {
return;
}
if (error instanceof QaEvidenceGalleryError) {
writeError(res, error.statusCode, error.message);
return;
}
writeError(res, 500, error);
}
@ -172,6 +181,38 @@ function normalizeQaLabCleanupError(error: unknown): Error {
return error instanceof Error ? error : new Error(formatErrorMessage(error));
}
function detectQaEvidenceArtifactContentType(filePath: string): string {
const lower = filePath.toLowerCase();
if (lower.endsWith(".png")) {
return "image/png";
}
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) {
return "image/jpeg";
}
if (lower.endsWith(".gif")) {
return "image/gif";
}
if (lower.endsWith(".webp")) {
return "image/webp";
}
if (lower.endsWith(".webm")) {
return "video/webm";
}
if (lower.endsWith(".mp4")) {
return "video/mp4";
}
if (lower.endsWith(".mov")) {
return "video/quicktime";
}
if (lower.endsWith(".json") || lower.endsWith(".jsonl")) {
return "application/json; charset=utf-8";
}
if (lower.endsWith(".md") || lower.endsWith(".txt") || lower.endsWith(".log")) {
return "text/plain; charset=utf-8";
}
return "application/octet-stream";
}
async function startQaGatewayLoop(params: { state: QaBusState; baseUrl: string }) {
const runtime = createQaRunnerRuntime();
setQaChannelRuntime(runtime);
@ -384,6 +425,59 @@ export async function startQaLabServer(
writeJson(res, 200, { run: latestScenarioRun });
return;
}
if (req.method === "GET" && url.pathname === "/api/evidence") {
const evidencePath =
url.searchParams.get("path")?.trim() || runnerSnapshot.artifacts?.evidencePath;
if (!evidencePath) {
res.writeHead(200, {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store",
});
res.end(JSON.stringify({ evidence: null }));
return;
}
// Build the model before sending any headers so a thrown QaEvidenceGalleryError
// still routes through writeQaLabServerError (writing headers first would make the
// error response throw ERR_HTTP_HEADERS_SENT and reset the connection).
const evidence = await buildQaEvidenceGalleryModel({ evidencePath, repoRoot });
res.writeHead(200, {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store",
});
res.end(JSON.stringify({ evidence }));
return;
}
if (
(req.method === "GET" || req.method === "HEAD") &&
url.pathname === "/api/evidence/artifact"
) {
const evidencePath = url.searchParams.get("evidencePath")?.trim();
const artifactPath = url.searchParams.get("artifactPath")?.trim();
if (!evidencePath || !artifactPath) {
writeError(res, 400, "Missing evidencePath or artifactPath");
return;
}
const artifactFile = await resolveQaEvidenceArtifactFile({
artifactPath,
evidencePath,
repoRoot,
});
const artifactStats = await fs.promises.stat(artifactFile);
res.writeHead(200, {
"content-type": detectQaEvidenceArtifactContentType(artifactFile),
"content-length": artifactStats.size,
"cache-control": "no-store",
"x-content-type-options": "nosniff",
});
if (req.method === "HEAD") {
res.end();
return;
}
fs.createReadStream(artifactFile)
.on("error", (error) => res.destroy(normalizeQaLabCleanupError(error)))
.pipe(res);
return;
}
if (req.method === "GET" && url.pathname === "/api/capture/sessions") {
writeJson(res, 200, {
sessions: captureStore.listSessions(50),

View file

@ -4,6 +4,7 @@ import { normalizeCaptureSavedView, normalizeCaptureSavedViews } from "./capture
import { formatErrorMessage } from "./errors.js";
import {
type Bootstrap,
type EvidenceEnvelope,
type OutcomesEnvelope,
type ReportEnvelope,
type RunnerSelection,
@ -160,6 +161,11 @@ function isEditableElement(target: EventTarget | null): boolean {
}
export async function createQaLabApp(root: HTMLDivElement) {
const initialUrl = new URL(window.location.href);
const initialEvidencePath =
initialUrl.searchParams.get("evidencePath")?.trim() ||
initialUrl.searchParams.get("path")?.trim() ||
"";
const state: UiState = {
theme: detectTheme(),
bootstrap: null,
@ -203,6 +209,13 @@ export async function createQaLabApp(root: HTMLDivElement) {
captureErrorsOnly: false,
captureCoverage: null,
captureStartupStatus: null,
evidence: null,
evidenceArtifactFilter: "all",
evidenceError: null,
evidenceLoading: false,
evidencePathDraft: initialEvidencePath,
evidenceSearchText: "",
evidenceStatusFilter: "all",
captureControlsExpanded: false,
captureSummaryExpanded: false,
captureSavedViews: loadCaptureSavedViews(),
@ -213,10 +226,11 @@ export async function createQaLabApp(root: HTMLDivElement) {
capturePinnedLaneIds: [],
selectedCaptureSessionIds: [],
selectedCaptureEventKey: null,
selectedEvidenceEntryId: null,
selectedConversationId: null,
selectedThreadId: null,
selectedScenarioId: null,
activeTab: "chat",
activeTab: initialUrl.pathname === "/evidence" || initialEvidencePath ? "evidence" : "chat",
runnerDraft: null,
runnerDraftDirty: false,
composer: {
@ -316,6 +330,17 @@ export async function createQaLabApp(root: HTMLDivElement) {
ccli: state.captureCollapsedLaneIds.join(","),
ccpi: state.capturePinnedLaneIds.join(","),
er: state.error,
el: state.evidenceLoading,
ee: state.evidenceError,
ep: state.evidence?.evidencePath ?? null,
eg: state.evidence?.generatedAt ?? null,
ecnt: state.evidence?.entries.length ?? 0,
eac: state.evidence?.entries.reduce((sum, entry) => sum + entry.artifacts.length, 0) ?? 0,
epr: state.evidence?.producerContext?.rootPath ?? null,
esf: state.evidenceStatusFilter,
eaf: state.evidenceArtifactFilter,
esq: state.evidenceSearchText,
ese: state.selectedEvidenceEntryId,
});
}
@ -338,6 +363,9 @@ export async function createQaLabApp(root: HTMLDivElement) {
state.snapshot = snapshot;
state.latestReport = report.report ?? bootstrap.latestReport;
state.scenarioRun = outcomes.run;
if (!state.evidencePathDraft.trim() && bootstrap.runner.artifacts?.evidencePath) {
state.evidencePathDraft = bootstrap.runner.artifacts.evidencePath;
}
if (!state.runnerDraft || !state.runnerDraftDirty) {
state.runnerDraft = {
...bootstrap.runner.selection,
@ -617,6 +645,37 @@ export async function createQaLabApp(root: HTMLDivElement) {
}
}
async function loadEvidence(pathOverride?: string) {
const evidencePath = (pathOverride ?? state.evidencePathDraft).trim();
if (!evidencePath) {
state.evidenceError = "Evidence path is required.";
render();
return;
}
state.evidenceLoading = true;
state.evidenceError = null;
render();
try {
const payload = await getJson<EvidenceEnvelope>(
`/api/evidence?path=${encodeURIComponent(evidencePath)}`,
);
state.evidence = payload.evidence;
state.evidencePathDraft = payload.evidence?.evidencePath ?? evidencePath;
state.selectedEvidenceEntryId = payload.evidence?.entries[0]?.id ?? null;
const url = new URL(window.location.href);
url.pathname = "/evidence";
url.searchParams.set("path", state.evidencePathDraft);
window.history.replaceState(null, "", `${url.pathname}${url.search}`);
} catch (error) {
state.evidence = null;
state.selectedEvidenceEntryId = null;
state.evidenceError = formatErrorMessage(error);
} finally {
state.evidenceLoading = false;
render();
}
}
async function sendKickoff() {
state.busy = true;
state.error = null;
@ -832,6 +891,19 @@ export async function createQaLabApp(root: HTMLDivElement) {
root
.querySelector<HTMLElement>("[data-action='download-report']")
?.addEventListener("click", downloadReport);
root
.querySelector<HTMLElement>("[data-action='load-evidence']")
?.addEventListener("click", () => void loadEvidence());
root
.querySelector<HTMLElement>("[data-action='open-run-evidence']")
?.addEventListener("click", () => {
const evidencePath = state.bootstrap?.runner.artifacts?.evidencePath;
if (!evidencePath) {
return;
}
state.activeTab = "evidence";
void loadEvidence(evidencePath);
});
/* Scenario All/None */
root
@ -899,6 +971,53 @@ export async function createQaLabApp(root: HTMLDivElement) {
}));
});
root.querySelector<HTMLInputElement>("#evidence-path")?.addEventListener("input", (e) => {
state.evidencePathDraft = (e.currentTarget as HTMLInputElement).value;
});
root.querySelector<HTMLInputElement>("#evidence-path")?.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
void loadEvidence();
}
});
root
.querySelector<HTMLSelectElement>("#evidence-status-filter")
?.addEventListener("change", (e) => {
const value = (e.currentTarget as HTMLSelectElement).value;
state.evidenceStatusFilter =
value === "pass" || value === "fail" || value === "blocked" || value === "skipped"
? value
: "all";
state.selectedEvidenceEntryId = null;
render();
});
root
.querySelector<HTMLSelectElement>("#evidence-artifact-filter")
?.addEventListener("change", (e) => {
const value = (e.currentTarget as HTMLSelectElement).value;
state.evidenceArtifactFilter =
value === "image" ||
value === "video" ||
value === "json" ||
value === "text" ||
value === "file"
? value
: "all";
state.selectedEvidenceEntryId = null;
render();
});
root.querySelector<HTMLInputElement>("#evidence-search")?.addEventListener("input", (e) => {
state.evidenceSearchText = (e.currentTarget as HTMLInputElement).value;
state.selectedEvidenceEntryId = null;
render();
});
root.querySelectorAll<HTMLElement>("[data-evidence-entry-id]").forEach((node) => {
node.addEventListener("click", () => {
state.selectedEvidenceEntryId = node.dataset.evidenceEntryId ?? null;
render();
});
});
root.querySelector<HTMLSelectElement>("#capture-session")?.addEventListener("change", (e) => {
state.selectedCaptureSessionIds = readMultiSelect(e.currentTarget as HTMLSelectElement);
state.selectedCaptureEventKey = null;
@ -1643,6 +1762,9 @@ export async function createQaLabApp(root: HTMLDivElement) {
render();
await refresh();
if (initialEvidencePath) {
await loadEvidence(initialEvidencePath);
}
void pollUiVersion();
setInterval(() => void refresh(), 1_000);
setInterval(() => void pollUiVersion(), 1_000);

View file

@ -371,6 +371,27 @@ select {
border-right-color: transparent;
}
.app-shell--evidence-focus .sidebar {
width: 0;
min-width: 0;
border-right-color: transparent;
}
.app-shell--evidence-focus .main-content {
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--bg-app) 88%, var(--bg-surface)) 0,
var(--bg-app) 240px
),
var(--bg-app);
}
.app-shell--evidence-focus .tab-content {
overflow-x: hidden;
overflow-y: auto;
}
.sidebar-panel-tabs {
display: flex;
gap: 6px;
@ -1522,6 +1543,650 @@ select {
border: 1px solid var(--border);
}
/* --- Evidence view --- */
.evidence-view {
display: flex;
flex-direction: column;
height: 100%;
overflow-x: hidden;
overflow-y: auto;
padding: 12px 16px 16px;
gap: 10px;
max-width: 1680px;
width: 100%;
margin: 0 auto;
}
.app-shell--evidence-focus .evidence-view {
height: auto;
min-height: 100%;
overflow: visible;
}
.evidence-toolbar {
flex-shrink: 0;
display: grid;
grid-template-columns: minmax(220px, 0.65fr) minmax(0, 1.35fr) minmax(380px, 0.9fr);
gap: 12px;
align-items: end;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: color-mix(in srgb, var(--bg-surface) 94%, transparent);
box-shadow: var(--shadow);
}
.evidence-toolbar-intro {
min-width: 0;
}
.evidence-toolbar-intro p {
margin: 0;
color: var(--text-secondary);
font-size: 13px;
line-height: 1.4;
}
.evidence-toolbar-main,
.evidence-filters {
display: grid;
gap: 10px;
align-items: end;
}
.evidence-toolbar-main {
grid-template-columns: minmax(0, 1fr) auto;
}
.evidence-filters {
grid-template-columns: minmax(112px, 0.8fr) minmax(112px, 0.8fr) minmax(0, 1.4fr);
}
.evidence-filters label {
display: grid;
gap: 4px;
color: var(--text-tertiary);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.evidence-summary-row {
flex-shrink: 0;
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 6px;
}
.evidence-metric {
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 7px;
background: color-mix(in srgb, var(--bg-surface) 92%, transparent);
}
.evidence-metric span {
display: block;
color: var(--text-tertiary);
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.evidence-metric strong {
display: block;
margin-top: 1px;
font-size: 18px;
line-height: 1.1;
}
.evidence-metric-pass strong {
color: var(--success);
}
.evidence-metric-fail strong {
color: var(--danger);
}
.evidence-metric-blocked strong {
color: var(--warning);
}
.evidence-metric-skipped strong {
color: var(--text-secondary);
}
.evidence-producer-panel {
flex-shrink: 0;
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: color-mix(in srgb, var(--bg-surface) 94%, transparent);
box-shadow: var(--shadow);
}
.evidence-producer-head {
display: flex;
justify-content: space-between;
gap: 18px;
align-items: flex-start;
}
.evidence-producer-head h2 {
margin-top: 2px;
color: var(--text);
font-size: 17px;
line-height: 1.2;
}
.evidence-producer-head p {
margin-top: 3px;
max-width: 720px;
color: var(--text-secondary);
font-size: 12px;
}
.evidence-producer-run {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
align-items: center;
color: var(--text-tertiary);
font-size: 12px;
}
.evidence-producer-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 6px;
}
.evidence-producer-metric {
min-width: 0;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 7px;
background: color-mix(in srgb, var(--bg-surface) 72%, var(--bg-inset));
}
.evidence-producer-metric span {
display: block;
overflow: hidden;
color: var(--text-tertiary);
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: 0.06em;
}
.evidence-producer-metric strong {
display: block;
margin-top: 1px;
color: var(--text);
font-size: 17px;
line-height: 1.1;
}
.evidence-producer-status-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.evidence-producer-status-row > div {
min-width: 0;
display: grid;
gap: 6px;
}
.evidence-producer-drilldowns {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
align-items: start;
}
.evidence-producer-drilldown {
min-width: 0;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 8px;
background: color-mix(in srgb, var(--bg-surface) 78%, var(--bg-inset));
}
.evidence-producer-drilldown summary {
display: grid;
grid-template-columns: minmax(100px, auto) minmax(0, 1fr);
gap: 8px;
align-items: center;
min-height: 34px;
padding: 8px 10px;
cursor: pointer;
color: var(--text);
font-size: 12px;
font-weight: 800;
}
.evidence-producer-drilldown summary::marker {
color: var(--text-tertiary);
}
.evidence-producer-drilldown summary .capture-mono {
overflow: hidden;
color: var(--text-tertiary);
font-size: 11px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.evidence-producer-drilldown-body {
display: grid;
gap: 8px;
padding: 0 10px 10px;
}
.evidence-producer-preview {
max-height: 220px;
overflow: auto;
margin: 0;
border-radius: 6px;
font-size: 11px;
}
.evidence-producer-links {
display: flex;
flex-wrap: wrap;
gap: 6px;
min-width: 0;
}
.evidence-matrix-mini {
overflow: visible;
border: 1px solid var(--border);
border-radius: 8px;
background: color-mix(in srgb, var(--bg-surface) 68%, var(--bg-inset));
}
.evidence-matrix-mini table {
width: 100%;
min-width: 980px;
border-collapse: collapse;
table-layout: fixed;
}
.evidence-matrix-mini th,
.evidence-matrix-mini td {
padding: 4px;
border-bottom: 1px solid var(--border);
border-left: 1px solid var(--border);
text-align: center;
vertical-align: middle;
}
.evidence-matrix-mini th:first-child,
.evidence-matrix-mini td:first-child {
border-left: 0;
}
.evidence-matrix-mini thead th,
.evidence-matrix-mini tbody th {
color: var(--text-secondary);
background: color-mix(in srgb, var(--bg-surface) 84%, var(--bg-inset));
font-size: 10px;
font-weight: 800;
line-height: 1.15;
text-transform: uppercase;
}
.evidence-matrix-mini thead th {
position: sticky;
top: 0;
z-index: 1;
}
.evidence-matrix-mini tbody th {
position: sticky;
left: 0;
z-index: 1;
width: 116px;
text-align: left;
}
.evidence-matrix-cell {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 22px;
padding: 2px 4px;
border: 1px solid var(--border);
border-radius: 5px;
color: var(--text-secondary);
background: var(--bg-surface);
font-size: 10px;
font-weight: 800;
line-height: 1.1;
text-transform: uppercase;
}
button.evidence-matrix-cell {
white-space: normal;
}
.evidence-matrix-cell-action:hover {
border-color: var(--accent);
background: var(--accent-soft);
}
.evidence-matrix-cell-pass,
.evidence-matrix-cell-fixed-in-pr {
color: var(--success-text);
border-color: color-mix(in srgb, var(--success) 42%, var(--border));
background: var(--success-soft);
}
.evidence-matrix-cell-fail,
.evidence-matrix-cell-automation-issue {
color: var(--danger-text);
border-color: color-mix(in srgb, var(--danger) 44%, var(--border));
background: var(--danger-soft);
}
.evidence-matrix-cell-blocked,
.evidence-matrix-cell-environment-issue {
color: var(--warning-text);
border-color: color-mix(in srgb, var(--warning) 44%, var(--border));
background: var(--warning-soft);
}
.evidence-matrix-cell-proof-gap {
color: var(--text-tertiary);
border-style: dashed;
background: transparent;
}
.evidence-matrix-cell-not-applicable,
.evidence-matrix-cell-missing {
color: var(--text-tertiary);
background: color-mix(in srgb, var(--bg-surface) 58%, var(--bg-inset));
}
.evidence-meta-line {
flex-shrink: 0;
display: flex;
gap: 10px;
align-items: center;
min-width: 0;
color: var(--text-tertiary);
font-size: 12px;
}
.evidence-meta-line span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.evidence-workspace {
flex: 0 0 min(640px, calc(100vh - 220px));
min-height: 420px;
display: grid;
grid-template-columns: minmax(320px, 0.34fr) minmax(0, 1fr);
gap: 10px;
overflow: hidden;
}
.evidence-list,
.evidence-inspector {
min-height: 0;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-surface);
overflow: hidden;
box-shadow: var(--shadow);
}
.evidence-list {
display: flex;
flex-direction: column;
}
.evidence-list-header {
flex-shrink: 0;
display: flex;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
color: var(--text-tertiary);
font-size: 12px;
font-weight: 600;
}
.evidence-list-scroll,
.evidence-inspector {
overflow: auto;
}
.evidence-list-scroll {
display: grid;
gap: 6px;
padding: 8px;
}
.evidence-entry-card {
display: grid;
gap: 8px;
width: 100%;
padding: 10px;
text-align: left;
background: color-mix(in srgb, var(--bg-surface) 70%, var(--bg-inset));
border-color: var(--border);
border-radius: 8px;
}
.evidence-entry-card:hover,
.evidence-entry-card.selected {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent-soft) 72%, var(--bg-surface));
}
.evidence-entry-card-top {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 9px;
align-items: start;
}
.evidence-entry-title {
color: var(--text);
font-size: 13px;
font-weight: 700;
line-height: 1.3;
white-space: normal;
}
.evidence-entry-meta,
.evidence-artifact-source,
.evidence-source-path {
color: var(--text-tertiary);
font-size: 12px;
overflow-wrap: anywhere;
}
.evidence-entry-artifacts {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.evidence-artifact-badge {
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 2px 6px;
border-radius: 999px;
border: 1px solid var(--border);
color: var(--text-secondary);
background: var(--bg-surface);
font-size: 10px;
font-weight: 700;
}
.evidence-artifact-badge-missing {
color: var(--danger);
border-color: var(--danger);
}
.evidence-detail {
min-height: 100%;
display: grid;
align-content: start;
gap: 12px;
padding: 16px;
}
.evidence-detail-header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: start;
}
.evidence-detail-header h2 {
margin-top: 4px;
font-size: 22px;
line-height: 1.18;
letter-spacing: -0.02em;
}
.evidence-detail-section {
display: grid;
gap: 8px;
}
.evidence-artifact-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 10px;
}
.evidence-artifact-card {
min-width: 0;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 10px;
background: color-mix(in srgb, var(--bg-surface) 72%, var(--bg-inset));
}
.evidence-artifact-card header {
display: flex;
justify-content: space-between;
gap: 10px;
padding: 10px;
border-bottom: 1px solid var(--border);
}
.evidence-artifact-title {
font-size: 13px;
font-weight: 800;
}
.evidence-artifact-card img,
.evidence-artifact-card video {
display: block;
width: 100%;
max-height: 430px;
aspect-ratio: 16 / 10;
object-fit: contain;
background: var(--bg-app);
}
.evidence-artifact-deferred {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px;
color: var(--text-secondary);
font-size: 12px;
}
.evidence-artifact-preview-shell {
border-top: 1px solid var(--border);
}
.evidence-artifact-preview-shell summary {
cursor: pointer;
padding: 10px 12px;
color: var(--text-secondary);
font-size: 12px;
font-weight: 700;
}
.evidence-artifact-preview {
max-height: 340px;
overflow: auto;
border-radius: 0;
border: 0;
}
.evidence-artifact-card footer {
padding: 8px 10px;
border-top: 1px solid var(--border);
color: var(--text-tertiary);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.evidence-empty {
flex: 1;
display: grid;
place-content: center;
justify-items: center;
gap: 8px;
color: var(--text-secondary);
text-align: center;
}
.evidence-empty h2 {
color: var(--text);
font-size: 20px;
}
.evidence-empty p {
max-width: 620px;
}
@media (max-width: 980px) {
.evidence-summary-row,
.evidence-filters,
.evidence-toolbar,
.evidence-producer-grid,
.evidence-producer-status-row,
.evidence-producer-drilldowns,
.evidence-workspace {
grid-template-columns: 1fr;
}
.evidence-workspace {
overflow: auto;
}
}
/* --- Events view --- */
.events-view {
display: flex;

View file

@ -0,0 +1,258 @@
// Qa Lab UI render tests cover evidence gallery affordances.
import { describe, expect, it } from "vitest";
import { renderQaLabUi, type UiState } from "./ui-render.js";
function evidenceState(overrides: Partial<UiState> = {}): UiState {
return {
activeTab: "evidence",
bootstrap: null,
busy: false,
captureCollapsedLaneIds: [],
captureControlsExpanded: true,
captureCoverage: null,
captureDetailPlacement: "right",
captureDetailSplitDragging: false,
captureDetailSplitPct: 35,
captureDetailView: "overview",
captureErrorsOnly: false,
captureEvents: [],
captureFlowDetailLayout: null,
captureGroupMode: "none",
captureHeaderMode: "key",
captureHostFilter: [],
captureKindFilter: [],
capturePayloadDetailLayout: null,
capturePayloadEventFilter: "",
capturePayloadEventSort: "stream",
capturePayloadExtent: "preview",
capturePinnedLaneIds: [],
capturePreferredDetailView: null,
captureProviderFilter: [],
captureQueryPreset: "none",
captureQueryRows: [],
captureSavedViews: [],
captureSearchText: "",
captureSelectedSessionsExpanded: true,
captureSessions: [],
captureStartupStatus: null,
captureSummaryExpanded: true,
captureTimelineBrushAnchorPct: null,
captureTimelineBrushCurrentPct: null,
captureTimelineFocusSelectedFlow: false,
captureTimelineFocusedLaneMode: "all",
captureTimelineFocusedLaneThreshold: "any",
captureTimelineLaneMode: "domain",
captureTimelineLaneSearch: "",
captureTimelineLaneSort: "most-events",
captureTimelinePreviousLaneSort: null,
captureTimelineSparklineMode: "session-relative",
captureTimelineWindowEndPct: null,
captureTimelineWindowStartPct: null,
captureTimelineZoom: 100,
captureViewMode: "list",
composer: {
conversationId: "",
conversationKind: "direct",
senderId: "",
senderName: "",
text: "",
},
error: null,
evidence: null,
evidenceArtifactFilter: "all",
evidenceError: null,
evidenceLoading: false,
evidencePathDraft: "",
evidenceSearchText: "",
evidenceStatusFilter: "all",
latestReport: null,
runnerDraft: null,
runnerDraftDirty: false,
scenarioRun: null,
selectedCaptureEventKey: null,
selectedCaptureSessionIds: [],
selectedConversationId: null,
selectedEvidenceEntryId: null,
selectedScenarioId: null,
selectedThreadId: null,
sidebarCollapsed: false,
sidebarPanel: "scenarios",
snapshot: null,
theme: "light",
...overrides,
};
}
describe("QA Lab UI evidence render", () => {
it("maps blocked and skipped evidence statuses to styled tones", () => {
const html = renderQaLabUi(
evidenceState({
evidence: {
counts: { blocked: 1, fail: 0, pass: 0, skipped: 1 },
entries: [
{
artifacts: [],
coverage: [{ id: "qa.blocked", role: "primary" }],
failureReason: "Environment unavailable",
id: "qa-lab.blocked",
kind: "script-test",
sourcePath: "scripts/blocked.ts",
status: "blocked",
title: "Blocked evidence",
},
{
artifacts: [],
coverage: [{ id: "qa.skipped", role: "primary" }],
failureReason: null,
id: "qa-lab.skipped",
kind: "vitest-test",
sourcePath: "extensions/qa-lab/src/skipped.test.ts",
status: "skipped",
title: "Skipped evidence",
},
],
evidenceMode: "full",
evidencePath: ".artifacts/qa-e2e/suite/qa-evidence.json",
generatedAt: "2026-06-17T12:00:00.000Z",
producerContext: null,
profile: null,
schemaVersion: 2,
},
selectedEvidenceEntryId: "qa-lab.blocked",
}),
);
expect(html).toContain("badge-pending");
expect(html).toContain("badge-skip");
expect(html).toContain("scenario-item-dot-pending");
expect(html).toContain("scenario-item-dot-skip");
expect(html).not.toContain("badge-blocked");
expect(html).not.toContain("badge-skipped");
expect(html).not.toContain("scenario-item-dot-blocked");
});
it("links executed UX Matrix cells to evidence entries and leaves proof gaps unlinked", () => {
const html = renderQaLabUi(
evidenceState({
evidence: {
counts: { blocked: 0, fail: 0, pass: 1, skipped: 0 },
entries: [
{
artifacts: [
{
error: null,
exists: true,
href: "/api/evidence/artifact?artifactPath=screenshot.png",
kind: "screenshot",
mediaKind: "image",
path: "screenshot.png",
preview: null,
source: "ux-matrix:web-ui:first-run",
},
{
error: null,
exists: true,
href: "/api/evidence/artifact?artifactPath=recording.gif",
kind: "motion-preview-gif",
mediaKind: "image",
path: "recording.gif",
preview: null,
source: "ux-matrix:web-ui:first-run",
},
{
error: null,
exists: true,
href: "/api/evidence/artifact?artifactPath=recording.webm",
kind: "video",
mediaKind: "video",
path: "recording.webm",
preview: null,
source: "ux-matrix:web-ui:first-run",
},
],
coverage: [{ id: "ui.control", role: "primary" }],
failureReason: null,
id: "ux-matrix.web-ui.first-run",
kind: "ux-matrix-cell",
sourcePath: "scripts/ux-matrix/dashboard.ts",
status: "pass",
title: "UX Matrix: web-ui / first-run",
},
],
evidenceMode: "full",
evidencePath: ".artifacts/qa-e2e/suite/qa-evidence.json",
generatedAt: "2026-06-17T12:00:00.000Z",
producerContext: {
commands: null,
kind: "ux-matrix",
manifest: {
href: "/api/evidence/artifact?artifactPath=manifest.json",
path: "manifest.json",
preview: null,
runId: "run-1",
runStatus: "pass",
},
matrix: {
cells: [
{
artifactKinds: ["screenshot"],
artifactPaths: ["screenshot.png"],
coverageIds: ["ui.control"],
runner: {
availability: "local",
command: "pnpm openclaw qa suite --scenario ux-matrix-evidence-dashboard",
lane: "web-ui-playwright",
workflow: ".github/workflows/ux-matrix-qa.yml#ux-matrix-local",
},
stage: "first-run",
status: "pass",
surface: "web-ui",
testId: "ux-matrix.web-ui.first-run",
title: "UX Matrix: web-ui / first-run",
},
{
artifactKinds: [],
artifactPaths: [],
coverageIds: ["cli-entrypoint"],
runner: {
availability: "local",
command: "pnpm openclaw qa suite --scenario ux-matrix-evidence-dashboard",
lane: "cli-status",
workflow: ".github/workflows/ux-matrix-qa.yml#ux-matrix-local",
},
stage: "first-run",
status: "proof-gap",
surface: "cli",
testId: null,
title: null,
},
],
counts: { pass: 1, "proof-gap": 1 },
path: "matrix.json",
stages: ["first-run"],
surfaces: ["cli", "web-ui"],
},
preflight: { adbDevices: null, memory: null },
releaseLedger: null,
rootPath: ".artifacts/qa-e2e/suite/script/ux-matrix-evidence-dashboard/run-1",
scorecard: null,
},
profile: null,
schemaVersion: 2,
},
selectedEvidenceEntryId: "ux-matrix.web-ui.first-run",
}),
);
expect(html).toContain('data-evidence-entry-id="ux-matrix.web-ui.first-run"');
expect(html).toContain("evidence-matrix-cell-proof-gap");
expect(html).toContain("not executed in this run");
expect(html).toContain("Coverage: cli-entrypoint");
expect(html).toContain("Runner: cli-status");
expect(html).toContain("Open media artifact");
expect(html).toContain("Open video artifact");
expect(html).not.toContain('src="/api/evidence/artifact?artifactPath=recording.gif"');
expect(html).not.toContain("<video controls");
expect(html).not.toContain('data-evidence-entry-id="null"');
});
});

View file

@ -1,3 +1,12 @@
import type {
QaEvidenceArtifactView,
QaEvidenceGalleryEntryView,
QaEvidenceGalleryModel,
QaEvidenceMatrixCellView,
QaEvidenceProducerContext,
QaEvidenceProducerContextFile,
} from "../../shared/evidence-gallery-types.js";
/* ===== Shared types (unchanged from the bus protocol) ===== */
type Conversation = {
@ -138,6 +147,7 @@ type RunnerSnapshot = {
startedAt?: string;
finishedAt?: string;
artifacts: null | {
evidencePath: string;
outputDir: string;
reportPath: string;
summaryPath: string;
@ -250,6 +260,18 @@ export type CaptureStartupStatusEnvelope = {
status: CaptureStartupStatus;
};
type EvidenceStatus = QaEvidenceGalleryEntryView["status"];
type EvidenceArtifactView = QaEvidenceArtifactView;
type EvidenceEntryView = QaEvidenceGalleryEntryView;
type EvidenceProducerContextFile = QaEvidenceProducerContextFile;
type EvidenceMatrixCell = QaEvidenceMatrixCellView;
type EvidenceProducerContext = QaEvidenceProducerContext;
type EvidenceGalleryModel = QaEvidenceGalleryModel;
export type EvidenceEnvelope = {
evidence: EvidenceGalleryModel | null;
};
export type CaptureSavedView = {
id: string;
name: string;
@ -271,7 +293,7 @@ export type CaptureSavedView = {
payloadExtent: "preview" | "full";
};
export type TabId = "chat" | "results" | "report" | "events" | "capture";
export type TabId = "chat" | "results" | "report" | "events" | "capture" | "evidence";
export type UiState = {
theme: "light" | "dark";
@ -321,6 +343,13 @@ export type UiState = {
captureErrorsOnly: boolean;
captureCoverage: CaptureCoverageSummary | null;
captureStartupStatus: CaptureStartupStatus | null;
evidence: EvidenceGalleryModel | null;
evidenceArtifactFilter: "all" | EvidenceArtifactView["mediaKind"];
evidenceError: string | null;
evidenceLoading: boolean;
evidencePathDraft: string;
evidenceSearchText: string;
evidenceStatusFilter: "all" | EvidenceStatus;
captureControlsExpanded: boolean;
captureSummaryExpanded: boolean;
captureSavedViews: CaptureSavedView[];
@ -331,6 +360,7 @@ export type UiState = {
capturePinnedLaneIds: string[];
selectedCaptureSessionIds: string[];
selectedCaptureEventKey: string | null;
selectedEvidenceEntryId: string | null;
selectedConversationId: string | null;
selectedThreadId: string | null;
selectedScenarioId: string | null;
@ -963,8 +993,24 @@ function statusDotClass(status: ScenarioOutcome["status"] | "pending"): string {
return `scenario-item-dot scenario-item-dot-${status}`;
}
function statusTone(status: string): string {
if (status === "failed") {
return "fail";
}
if (status === "completed") {
return "pass";
}
if (status === "skipped") {
return "skip";
}
if (status === "blocked") {
return "pending";
}
return status;
}
function badgeHtml(status: string): string {
const tone = status === "failed" ? "fail" : status === "completed" ? "pass" : status;
const tone = statusTone(status);
return `<span class="badge badge-${esc(tone)}">${esc(status)}</span>`;
}
@ -1168,6 +1214,7 @@ function renderTabBar(state: UiState): string {
const tabs: Array<{ id: TabId; label: string }> = [
{ id: "chat", label: "Chat" },
{ id: "results", label: "Results" },
{ id: "evidence", label: "Evidence Archive" },
{ id: "report", label: "Report" },
{ id: "events", label: "Events" },
{ id: "capture", label: "Capture" },
@ -1439,6 +1486,7 @@ function renderResultsView(state: UiState): string {
function renderInspector(state: UiState, scenario: SeedScenario): string {
const outcome = findScenarioOutcome(state, scenario);
const evidencePath = state.bootstrap?.runner.artifacts?.evidencePath ?? null;
return `
<div class="inspector-layout">
@ -1448,6 +1496,11 @@ function renderInspector(state: UiState, scenario: SeedScenario): string {
<div class="inspector-title">${esc(scenario.title)}</div>
${badgeHtml(outcome?.status ?? "pending")}
</div>
${
evidencePath
? `<button class="btn-sm" data-action="open-run-evidence" title="${esc(evidencePath)}">Open evidence</button>`
: ""
}
</div>
<div class="inspector-objective">${esc(scenario.objective)}</div>
<div class="inspector-meta">
@ -1528,6 +1581,441 @@ function renderReportView(state: UiState): string {
</div>`;
}
/* ===== Render: Evidence tab ===== */
function evidenceEntryMatches(state: UiState, entry: EvidenceEntryView): boolean {
if (state.evidenceStatusFilter !== "all" && entry.status !== state.evidenceStatusFilter) {
return false;
}
if (
state.evidenceArtifactFilter !== "all" &&
!entry.artifacts.some((artifact) => artifact.mediaKind === state.evidenceArtifactFilter)
) {
return false;
}
const query = state.evidenceSearchText.trim().toLowerCase();
if (!query) {
return true;
}
const haystack = [
entry.id,
entry.title,
entry.kind,
entry.sourcePath ?? "",
...entry.coverage.map((coverage) => `${coverage.id} ${coverage.role}`),
...entry.artifacts.map((artifact) => `${artifact.kind} ${artifact.source} ${artifact.path}`),
]
.join("\n")
.toLowerCase();
return haystack.includes(query);
}
function renderEvidenceMetric(label: string, value: string | number, tone?: string): string {
return `<div class="evidence-metric${tone ? ` evidence-metric-${esc(tone)}` : ""}">
<span>${esc(label)}</span>
<strong>${esc(String(value))}</strong>
</div>`;
}
function renderEvidenceCoverage(entry: EvidenceEntryView): string {
if (entry.coverage.length === 0) {
return '<span class="text-dimmed text-sm">No coverage IDs</span>';
}
return entry.coverage
.map(
(coverage) =>
`<span class="capture-chip">${esc(coverage.id)} <em>${esc(coverage.role)}</em></span>`,
)
.join("");
}
function renderEvidenceArtifactBadge(artifact: EvidenceArtifactView): string {
const missing = artifact.exists ? "" : " evidence-artifact-badge-missing";
return `<span class="evidence-artifact-badge${missing}" title="${esc(artifact.path)}">${esc(artifact.kind)}</span>`;
}
function renderEvidenceEntryButton(entry: EvidenceEntryView, selected: boolean): string {
const artifactSummary =
entry.artifacts.length > 0
? entry.artifacts.map(renderEvidenceArtifactBadge).join("")
: '<span class="text-dimmed text-sm">No artifacts</span>';
return `<button class="evidence-entry-card${selected ? " selected" : ""}" data-evidence-entry-id="${esc(entry.id)}" type="button">
<div class="evidence-entry-card-top">
<span class="result-card-dot scenario-item-dot-${statusTone(entry.status)}"></span>
<div>
<div class="evidence-entry-title">${esc(entry.title)}</div>
<div class="evidence-entry-meta">${esc(entry.kind)} · ${esc(entry.id)}</div>
</div>
${badgeHtml(entry.status)}
</div>
<div class="evidence-entry-artifacts">${artifactSummary}</div>
</button>`;
}
function renderEvidenceArtifactBody(artifact: EvidenceArtifactView): string {
if (!artifact.exists || !artifact.href) {
return `<div class="empty-state">Artifact unavailable: ${esc(artifact.error ?? "missing")}</div>`;
}
const isInlineScreenshot =
artifact.mediaKind === "image" && artifact.kind.toLowerCase().includes("screenshot");
if (isInlineScreenshot) {
return `<a href="${esc(artifact.href)}" target="_blank" rel="noopener noreferrer"><img src="${esc(artifact.href)}" alt="${esc(artifact.kind)} artifact" loading="lazy" /></a>`;
}
if (artifact.mediaKind === "image" || artifact.mediaKind === "video") {
const noun = artifact.mediaKind === "video" ? "Video" : "Media";
return `<div class="evidence-artifact-deferred">
<span>${noun} preview is deferred to keep the evidence view responsive.</span>
<a class="btn-sm" href="${esc(artifact.href)}" target="_blank" rel="noopener noreferrer">Open ${noun.toLowerCase()} artifact</a>
</div>`;
}
if (artifact.preview !== null) {
return `<details class="evidence-artifact-preview-shell">
<summary>Preview ${esc(artifact.kind)}</summary>
<pre class="report-pre evidence-artifact-preview">${esc(artifact.preview)}</pre>
</details>`;
}
return `<a class="btn-sm" href="${esc(artifact.href)}" target="_blank" rel="noopener noreferrer">Open artifact</a>`;
}
function renderEvidenceArtifactCard(artifact: EvidenceArtifactView): string {
return `<article class="evidence-artifact-card evidence-artifact-card-${artifact.mediaKind}">
<header>
<div>
<div class="evidence-artifact-title">${esc(artifact.kind)}</div>
<div class="evidence-artifact-source">${esc(artifact.source)}</div>
</div>
${artifact.href ? `<a class="btn-sm btn-ghost" href="${esc(artifact.href)}" target="_blank" rel="noopener noreferrer">Open</a>` : ""}
</header>
${renderEvidenceArtifactBody(artifact)}
<footer title="${esc(artifact.path)}">${esc(artifact.path)}</footer>
</article>`;
}
function renderEvidenceDetail(entry: EvidenceEntryView | null): string {
if (!entry) {
return '<div class="inspector-empty">Select an evidence entry</div>';
}
return `<div class="evidence-detail">
<header class="evidence-detail-header">
<div>
<div class="inspector-section-title">${esc(entry.kind)}</div>
<h2>${esc(entry.title)}</h2>
<div class="evidence-entry-meta">${esc(entry.id)}</div>
</div>
${badgeHtml(entry.status)}
</header>
${entry.failureReason ? `<div class="capture-error">${esc(entry.failureReason)}</div>` : ""}
<section class="evidence-detail-section">
<div class="inspector-section-title">Coverage</div>
<div class="capture-chip-row">${renderEvidenceCoverage(entry)}</div>
</section>
${
entry.sourcePath
? `<section class="evidence-detail-section">
<div class="inspector-section-title">Source</div>
<div class="capture-mono evidence-source-path">${esc(entry.sourcePath)}</div>
</section>`
: ""
}
<section class="evidence-detail-section evidence-detail-section-artifacts">
<div class="inspector-section-title">Artifacts</div>
${
entry.artifacts.length > 0
? `<div class="evidence-artifact-grid">${entry.artifacts.map(renderEvidenceArtifactCard).join("")}</div>`
: '<div class="empty-state">No execution artifacts recorded for this entry.</div>'
}
</section>
</div>`;
}
function renderProducerContextMetric(label: string, value: string | number): string {
return `<div class="evidence-producer-metric">
<span>${esc(label)}</span>
<strong>${esc(String(value))}</strong>
</div>`;
}
function renderProducerCountChips(counts: Record<string, number>): string {
const entries = Object.entries(counts).toSorted(
(left, right) => right[1] - left[1] || left[0].localeCompare(right[0]),
);
if (entries.length === 0) {
return '<span class="text-dimmed text-sm">No counts recorded</span>';
}
return entries
.map(
([status, count]) =>
`<span class="capture-chip">${esc(status)} <em>${esc(String(count))}</em></span>`,
)
.join("");
}
function renderProducerContextFile(params: {
file: EvidenceProducerContextFile | null;
open?: boolean;
title: string;
}): string {
if (!params.file) {
return "";
}
return `<details class="evidence-producer-drilldown" ${params.open ? "open" : ""}>
<summary>
<span>${esc(params.title)}</span>
<span class="capture-mono">${esc(params.file.path)}</span>
</summary>
<div class="evidence-producer-drilldown-body">
${
params.file.preview !== null
? `<pre class="report-pre evidence-producer-preview">${esc(params.file.preview)}</pre>`
: '<div class="empty-state">Preview unavailable for this artifact.</div>'
}
<a class="btn-sm btn-ghost" href="${esc(params.file.href)}" target="_blank" rel="noopener noreferrer">Open artifact</a>
</div>
</details>`;
}
function formatMatrixLabel(id: string): string {
return id
.split("-")
.map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part))
.join(" ");
}
function matrixCellClass(status: string): string {
return status.replace(/[^a-z0-9-]/gi, "-").toLowerCase();
}
function renderEvidenceMatrixCell(
cell: EvidenceMatrixCell | undefined,
surface: string,
stage: string,
): string {
if (!cell) {
return '<span class="evidence-matrix-cell evidence-matrix-cell-missing" title="No matrix cell was recorded for this surface and stage.">-</span>';
}
const isProofGap = cell.status === "proof-gap";
const artifactText =
cell.artifactPaths.length > 0 ? ` Artifacts: ${cell.artifactPaths.join(", ")}` : "";
const proofText =
cell.artifactKinds.length > 0
? ` Proof: ${cell.artifactKinds.join(" + ")} (${cell.artifactPaths.length})`
: "";
const coverageText =
cell.coverageIds.length > 0 ? ` Coverage: ${cell.coverageIds.join(", ")}` : "";
const runnerText = cell.runner?.lane
? ` Runner: ${cell.runner.lane}${cell.runner.workflow ? ` via ${cell.runner.workflow}` : ""}${cell.runner.command ? `; ${cell.runner.command}` : ""}`
: "";
const title = `${surface} / ${stage}: ${cell.status}${isProofGap ? " (not executed in this run)" : ""}.${coverageText}${runnerText ? ` ${runnerText}` : ""}${proofText}${artifactText}`;
const className = `evidence-matrix-cell evidence-matrix-cell-${matrixCellClass(cell.status)}${cell.testId ? " evidence-matrix-cell-action" : ""}`;
const label = isProofGap ? "gap" : cell.status;
if (!cell.testId) {
return `<span class="${className}" title="${esc(title)}">${esc(label)}</span>`;
}
return `<button class="${className}" data-evidence-entry-id="${esc(cell.testId)}" type="button" title="${esc(cell.title ?? title)}">${esc(label)}</button>`;
}
function renderEvidenceMatrixMiniGrid(matrix: EvidenceProducerContext["matrix"]): string {
if (!matrix || matrix.cells.length === 0) {
return "";
}
const cellsByKey = new Map(
matrix.cells.map((cell) => [`${cell.surface}:${cell.stage}`, cell] as const),
);
return `<div class="evidence-matrix-mini" aria-label="UX Matrix surface by stage evidence grid">
<table>
<thead>
<tr>
<th scope="col">Surface</th>
${matrix.stages.map((stage) => `<th scope="col" title="${esc(stage)}">${esc(formatMatrixLabel(stage))}</th>`).join("")}
</tr>
</thead>
<tbody>
${matrix.surfaces
.map(
(surface) => `<tr>
<th scope="row" title="${esc(surface)}">${esc(formatMatrixLabel(surface))}</th>
${matrix.stages
.map(
(stage) =>
`<td>${renderEvidenceMatrixCell(cellsByKey.get(`${surface}:${stage}`), surface, stage)}</td>`,
)
.join("")}
</tr>`,
)
.join("")}
</tbody>
</table>
</div>`;
}
function renderEvidenceProducerContext(producer: EvidenceProducerContext | null): string {
if (!producer) {
return "";
}
const matrix = producer.matrix;
const counts = matrix?.counts ?? {};
const proofGaps = counts["proof-gap"] ?? 0;
const issueCount =
(counts.fail ?? 0) +
(counts.blocked ?? 0) +
(counts["automation-issue"] ?? 0) +
(counts["environment-issue"] ?? 0);
return `<section class="evidence-producer-panel">
<div class="evidence-producer-head">
<div>
<div class="inspector-section-title">Producer context</div>
<h2>UX journey matrix</h2>
<p>Matrix context from the evidence producer. Proof gaps mean this run did not execute those cells.</p>
</div>
<div class="evidence-producer-run">
${producer.manifest?.runStatus ? badgeHtml(producer.manifest.runStatus) : ""}
${producer.manifest?.runId ? `<span class="capture-mono">${esc(producer.manifest.runId)}</span>` : ""}
</div>
</div>
<div class="evidence-producer-grid">
${renderProducerContextMetric("Cells", matrix?.cells.length ?? 0)}
${renderProducerContextMetric("Pass", counts.pass ?? 0)}
${renderProducerContextMetric("Proof gaps", proofGaps)}
${renderProducerContextMetric("Issues", issueCount)}
${renderProducerContextMetric("Surfaces", matrix?.surfaces.length ?? 0)}
${renderProducerContextMetric("Stages", matrix?.stages.length ?? 0)}
</div>
<div class="evidence-producer-status-row">
<div>
<div class="inspector-section-title">Matrix counts</div>
<div class="capture-chip-row">${renderProducerCountChips(matrix?.counts ?? {})}</div>
</div>
${
producer.releaseLedger
? `<div>
<div class="inspector-section-title">Release ledger counts</div>
<div class="capture-chip-row">${renderProducerCountChips(producer.releaseLedger.counts)}</div>
</div>`
: ""
}
</div>
<div class="evidence-producer-drilldowns">
${renderProducerContextFile({ title: "Scorecard", file: producer.scorecard, open: true })}
${renderProducerContextFile({ title: "Commands", file: producer.commands })}
${renderProducerContextFile({ title: "Preflight memory", file: producer.preflight.memory })}
${renderProducerContextFile({
title: "Preflight adb devices",
file: producer.preflight.adbDevices,
})}
${renderProducerContextFile({ title: "Manifest", file: producer.manifest })}
${renderProducerContextFile({ title: "Release ledger", file: producer.releaseLedger })}
</div>
${
matrix
? `<div class="evidence-producer-links">
<span class="ref-tag">${esc(matrix.path)}</span>
</div>`
: ""
}
${renderEvidenceMatrixMiniGrid(matrix)}
</section>`;
}
function renderEvidenceView(state: UiState): string {
const evidence = state.evidence;
const entries = evidence?.entries.filter((entry) => evidenceEntryMatches(state, entry)) ?? [];
const selected =
entries.find((entry) => entry.id === state.selectedEvidenceEntryId) ??
evidence?.entries.find((entry) => entry.id === state.selectedEvidenceEntryId) ??
entries[0] ??
null;
const artifactCount =
evidence?.entries.reduce((sum, entry) => sum + entry.artifacts.length, 0) ?? 0;
const missingCount =
evidence?.entries.reduce(
(sum, entry) => sum + entry.artifacts.filter((artifact) => !artifact.exists).length,
0,
) ?? 0;
return `<div class="evidence-view">
<div class="evidence-toolbar">
<div class="evidence-toolbar-intro">
<div class="inspector-section-title">Evidence Archive</div>
<p>Saved QA evidence bundles, proof artifacts, logs, and producer context.</p>
</div>
<div class="evidence-toolbar-main">
<label class="capture-search-field">Evidence path
<input id="evidence-path" value="${esc(state.evidencePathDraft)}" placeholder=".artifacts/qa-e2e/suite-.../qa-evidence.json" />
</label>
<button class="btn-primary" data-action="load-evidence"${state.evidenceLoading ? " disabled" : ""}>Load</button>
</div>
<div class="evidence-filters">
<label>Status
<select id="evidence-status-filter">
${(["all", "fail", "blocked", "pass", "skipped"] as const)
.map(
(status) =>
`<option value="${status}"${state.evidenceStatusFilter === status ? " selected" : ""}>${status}</option>`,
)
.join("")}
</select>
</label>
<label>Artifact
<select id="evidence-artifact-filter">
${(["all", "image", "video", "json", "text", "file"] as const)
.map(
(kind) =>
`<option value="${kind}"${state.evidenceArtifactFilter === kind ? " selected" : ""}>${kind}</option>`,
)
.join("")}
</select>
</label>
<label class="capture-search-field">Search
<input id="evidence-search" value="${esc(state.evidenceSearchText)}" placeholder="coverage, title, artifact..." />
</label>
</div>
</div>
${state.evidenceError ? `<div class="error-banner">${esc(state.evidenceError)}</div>` : ""}
${
evidence
? `<div class="evidence-summary-row">
${renderEvidenceMetric("Pass", evidence.counts.pass, "pass")}
${renderEvidenceMetric("Fail", evidence.counts.fail, "fail")}
${renderEvidenceMetric("Blocked", evidence.counts.blocked, "blocked")}
${renderEvidenceMetric("Skipped", evidence.counts.skipped, "skipped")}
${renderEvidenceMetric("Artifacts", artifactCount)}
${renderEvidenceMetric("Missing", missingCount, missingCount > 0 ? "fail" : undefined)}
</div>
${renderEvidenceProducerContext(evidence.producerContext)}
<div class="evidence-meta-line">
<span class="capture-mono">${esc(evidence.evidencePath)}</span>
<span>schema v${evidence.schemaVersion}</span>
<span>${esc(evidence.evidenceMode)}</span>
${evidence.profile ? `<span>profile ${esc(evidence.profile)}</span>` : ""}
<span>${esc(formatIso(evidence.generatedAt))}</span>
</div>
<div class="evidence-workspace">
<aside class="evidence-list">
<div class="evidence-list-header">
<span>${entries.length} visible</span>
<span>${evidence.entries.length} total</span>
</div>
<div class="evidence-list-scroll">
${
entries.length > 0
? entries
.map((entry) => renderEvidenceEntryButton(entry, entry.id === selected?.id))
.join("")
: '<div class="empty-state">No evidence entries match these filters.</div>'
}
</div>
</aside>
<section class="evidence-inspector">
${renderEvidenceDetail(selected)}
</section>
</div>`
: `<div class="evidence-empty">
<h2>No evidence loaded</h2>
<p>Load a QA Lab <code>qa-evidence.json</code> file or a suite artifact directory to inspect entries, coverage IDs, screenshots, video, logs, and machine-validation artifacts.</p>
</div>`
}
</div>`;
}
/* ===== Render: Events tab ===== */
function renderEventsView(state: UiState): string {
@ -3530,6 +4018,8 @@ function renderActiveTab(state: UiState): string {
return renderChatView(state);
case "results":
return renderResultsView(state);
case "evidence":
return renderEvidenceView(state);
case "report":
return renderReportView(state);
case "events":
@ -3544,8 +4034,15 @@ function renderActiveTab(state: UiState): string {
/* ===== Main render ===== */
export function renderQaLabUi(state: UiState): string {
const shellClasses = [
"app-shell",
state.sidebarCollapsed ? "app-shell--sidebar-collapsed" : "",
state.activeTab === "evidence" ? "app-shell--evidence-focus" : "",
]
.filter(Boolean)
.join(" ");
return `
<div class="app-shell${state.sidebarCollapsed ? " app-shell--sidebar-collapsed" : ""}" data-theme="${state.theme}">
<div class="${shellClasses}" data-theme="${state.theme}">
${renderHeader(state)}
<div class="layout">
${renderSidebar(state)}

View file

@ -48,9 +48,9 @@ const allowedRawFetchCallsites = new Set([
bundledPluginCallsite("qa-lab", "src/gateway-child.ts", 489),
bundledPluginCallsite("qa-lab", "src/suite.ts", 330),
bundledPluginCallsite("qa-lab", "src/suite.ts", 341),
bundledPluginCallsite("qa-lab", "web/src/app.ts", 22),
bundledPluginCallsite("qa-lab", "web/src/app.ts", 30),
bundledPluginCallsite("qa-lab", "web/src/app.ts", 38),
bundledPluginCallsite("qa-lab", "web/src/app.ts", 23),
bundledPluginCallsite("qa-lab", "web/src/app.ts", 31),
bundledPluginCallsite("qa-lab", "web/src/app.ts", 39),
bundledPluginCallsite("qqbot", "src/engine/api/api-client.ts", 124),
bundledPluginCallsite("qqbot", "src/engine/api/media-chunked.ts", 554),
bundledPluginCallsite("qqbot", "src/engine/api/token.ts", 211),