feat: 支持 CaseRun 加载共享运行历史
This commit is contained in:
@@ -36,6 +36,27 @@ export type StartCaseRunInput = {
|
||||
runtimeApiUrl: string;
|
||||
};
|
||||
|
||||
export type ListCaseRunsInput = {
|
||||
limit: number;
|
||||
status?: CaseRunStatus;
|
||||
before?: { createdAt: string; runId: string };
|
||||
};
|
||||
|
||||
export type CaseRunListItem = {
|
||||
runId: string;
|
||||
caseId: string;
|
||||
workflowId: string;
|
||||
workflowRunId: string | null;
|
||||
status: CaseRunStatus;
|
||||
stage: string;
|
||||
terminal: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
artifactCount: number;
|
||||
evidenceStatus: string | null;
|
||||
artifactManifestSha256: string | null;
|
||||
};
|
||||
|
||||
export type ActivityResult = {
|
||||
runId: string;
|
||||
activityName: string;
|
||||
@@ -47,6 +68,7 @@ export interface HarnessRLRegistry {
|
||||
ensureSchema(): Promise<void>;
|
||||
createRun(input: StartCaseRunInput): Promise<CaseRunRecord>;
|
||||
getRun(runId: string): Promise<CaseRunRecord | null>;
|
||||
listRuns(input: ListCaseRunsInput): Promise<CaseRunRecord[]>;
|
||||
updateRun(runId: string, patch: Partial<Pick<CaseRunRecord, "workflowRunId" | "status" | "stage" | "result" | "error" | "blocker">>): Promise<CaseRunRecord>;
|
||||
appendEvent(runId: string, status: CaseRunStatus, stage: string, payload?: Record<string, unknown>): Promise<CaseRunEvent>;
|
||||
listEvents(runId: string): Promise<CaseRunEvent[]>;
|
||||
|
||||
@@ -43,6 +43,49 @@ test("API restart preserves PostgreSQL-shaped registry read model", async () =>
|
||||
assert.equal(record.workflowRunId, "temporal-run-1");
|
||||
});
|
||||
|
||||
test("run history lists shared registry results newest first with an opaque cursor", async () => {
|
||||
const fixture = await softwareSmokeFixture();
|
||||
const registry = new MemoryRegistry();
|
||||
const service = createHarnessRLService({ registry, temporal: new FakeTemporal(), cwd: fixture.root, caseRepo: fixture.root, stateRoot: fixture.stateRoot });
|
||||
const app = createHarnessRLHttpApp({ service });
|
||||
await service.submit({ caseId: fixture.caseId, runId: "cli-smoke-older", runtimeApiUrl: "https://public.example.test" });
|
||||
await service.submit({ caseId: fixture.caseId, runId: "cli-smoke-newer", runtimeApiUrl: "https://public.example.test" });
|
||||
const older = registry.runs.get("cli-smoke-older")!;
|
||||
const newer = registry.runs.get("cli-smoke-newer")!;
|
||||
older.createdAt = "2026-07-17T10:00:00.000Z";
|
||||
newer.createdAt = "2026-07-18T10:00:00.000Z";
|
||||
await registry.updateRun("cli-smoke-older", { status: "completed", stage: "completed" });
|
||||
await registry.updateRun("cli-smoke-newer", {
|
||||
status: "completed",
|
||||
stage: "completed",
|
||||
result: {
|
||||
artifactManifestSha256: "d".repeat(64),
|
||||
summary: { artifactCount: 1, artifacts: [{ path: "software-smoke.json" }] },
|
||||
evidence: { status: "recorded" }
|
||||
}
|
||||
});
|
||||
|
||||
const firstResponse = await app.fetch(new Request("http://harnessrl.test/v1/caserun/runs?status=completed&limit=1"));
|
||||
assert.equal(firstResponse.status, 200);
|
||||
const first = await firstResponse.json() as any;
|
||||
assert.equal(first.count, 1);
|
||||
assert.equal(first.runs[0].runId, "cli-smoke-newer");
|
||||
assert.equal(first.runs[0].artifactCount, 1);
|
||||
assert.equal(first.runs[0].evidenceStatus, "recorded");
|
||||
assert.equal(first.runs[0].artifactManifestSha256, "d".repeat(64));
|
||||
assert.equal("runDir" in first.runs[0], false);
|
||||
assert.equal(typeof first.nextCursor, "string");
|
||||
|
||||
const secondResponse = await app.fetch(new Request(`http://harnessrl.test/v1/caserun/runs?limit=1&cursor=${encodeURIComponent(first.nextCursor)}`));
|
||||
const second = await secondResponse.json() as any;
|
||||
assert.equal(second.runs[0].runId, "cli-smoke-older");
|
||||
assert.equal(second.nextCursor, null);
|
||||
|
||||
const invalidResponse = await app.fetch(new Request("http://harnessrl.test/v1/caserun/runs?status=done"));
|
||||
assert.equal(invalidResponse.status, 400);
|
||||
assert.equal((await invalidResponse.json() as any).error.code, "invalid_status");
|
||||
});
|
||||
|
||||
test("worker restart reuses idempotent prepare/build/collect activity results", async () => {
|
||||
const fixture = await softwareSmokeFixture();
|
||||
const registry = new MemoryRegistry();
|
||||
@@ -253,6 +296,14 @@ class MemoryRegistry implements HarnessRLRegistry {
|
||||
return structuredClone(record);
|
||||
}
|
||||
async getRun(runId: string) { const value = this.runs.get(runId); return value ? structuredClone(value) : null; }
|
||||
async listRuns(input: { limit: number; status?: CaseRunStatus; before?: { createdAt: string; runId: string } }) {
|
||||
return [...this.runs.values()]
|
||||
.filter((record) => !input.status || record.status === input.status)
|
||||
.filter((record) => !input.before || record.createdAt < input.before.createdAt || (record.createdAt === input.before.createdAt && record.runId < input.before.runId))
|
||||
.sort((left, right) => right.createdAt.localeCompare(left.createdAt) || right.runId.localeCompare(left.runId))
|
||||
.slice(0, input.limit)
|
||||
.map((record) => structuredClone(record));
|
||||
}
|
||||
async updateRun(runId: string, patch: any) {
|
||||
const current = this.runs.get(runId);
|
||||
if (!current) throw new Error("missing run");
|
||||
|
||||
@@ -17,6 +17,13 @@ export function createHarnessRLHttpApp(options: { service: ReturnType<typeof imp
|
||||
const record = await options.service.submit({ caseId: String(body.caseId ?? ""), runId: body.runId ? String(body.runId) : undefined, runtimeApiUrl: runtimeApiUrl(request) });
|
||||
return json(202, caseRunAccepted(record));
|
||||
}
|
||||
if (url.pathname === "/v1/caserun/runs" && request.method === "GET") {
|
||||
return json(200, await options.service.listRuns({
|
||||
status: url.searchParams.get("status") ?? undefined,
|
||||
limit: url.searchParams.get("limit") ?? undefined,
|
||||
cursor: url.searchParams.get("cursor") ?? undefined
|
||||
}));
|
||||
}
|
||||
const match = /^\/v1\/caserun\/runs\/([^/]+)(?:\/(events|aggregate|cancel))?$/u.exec(url.pathname);
|
||||
if (match) {
|
||||
const runId = decodeURIComponent(match[1]);
|
||||
|
||||
@@ -39,6 +39,7 @@ CREATE TABLE IF NOT EXISTS harnessrl_activity_results (
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_harnessrl_events_run_id ON harnessrl_run_events(run_id, id);
|
||||
CREATE INDEX IF NOT EXISTS idx_harnessrl_runs_created_at ON harnessrl_runs(created_at DESC, run_id DESC);
|
||||
|
||||
INSERT INTO harnessrl_schema_migrations (migration_id)
|
||||
VALUES ('0001_harnessrl_registry')
|
||||
|
||||
@@ -2,7 +2,7 @@ import pg from "pg";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { buildPostgresPoolConfig, postgresSslModeFromConnectionString } from "../db/runtime-store-core.ts";
|
||||
import type { ActivityResult, CaseRunEvent, CaseRunRecord, CaseRunStatus, HarnessRLRegistry, StartCaseRunInput } from "./contracts.ts";
|
||||
import type { ActivityResult, CaseRunEvent, CaseRunRecord, CaseRunStatus, HarnessRLRegistry, ListCaseRunsInput, StartCaseRunInput } from "./contracts.ts";
|
||||
import { terminalStatus } from "./contracts.ts";
|
||||
|
||||
const { Pool } = pg;
|
||||
@@ -59,6 +59,27 @@ export class PostgresHarnessRLRegistry implements HarnessRLRegistry {
|
||||
return result.rows[0] ? runRow(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
async listRuns(input: ListCaseRunsInput) {
|
||||
await this.ensureSchema();
|
||||
const values: unknown[] = [];
|
||||
const where: string[] = [];
|
||||
if (input.status) {
|
||||
values.push(input.status);
|
||||
where.push(`status=$${values.length}`);
|
||||
}
|
||||
if (input.before) {
|
||||
values.push(input.before.createdAt, input.before.runId);
|
||||
where.push(`(created_at,run_id)<($${values.length - 1}::timestamptz,$${values.length})`);
|
||||
}
|
||||
values.push(input.limit);
|
||||
const result = await this.pool.query(
|
||||
`SELECT * FROM harnessrl_runs${where.length ? ` WHERE ${where.join(" AND ")}` : ""}
|
||||
ORDER BY created_at DESC,run_id DESC LIMIT $${values.length}`,
|
||||
values
|
||||
);
|
||||
return result.rows.map(runRow);
|
||||
}
|
||||
|
||||
async updateRun(runId: string, patch: Partial<Pick<CaseRunRecord, "workflowRunId" | "status" | "stage" | "result" | "error" | "blocker">>) {
|
||||
await this.ensureSchema();
|
||||
const current = await this.getRun(runId);
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Buffer } from "node:buffer";
|
||||
import { mkdir, readFile, readdir, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { HarnessRLRegistry, HarnessRLTemporalGateway } from "./contracts.ts";
|
||||
import type { CaseRunListItem, CaseRunRecord, CaseRunStatus, HarnessRLRegistry, HarnessRLTemporalGateway } from "./contracts.ts";
|
||||
|
||||
const RUN_ID_PATTERN = /^[A-Za-z0-9_.:-]{3,180}$/u;
|
||||
const CASE_RUN_STATUSES = new Set<CaseRunStatus>(["queued", "running", "completed", "failed", "blocked", "cancel_requested", "canceled"]);
|
||||
const DEFAULT_RUN_LIST_LIMIT = 20;
|
||||
const MAX_RUN_LIST_LIMIT = 100;
|
||||
|
||||
export function createHarnessRLService(options: { registry: HarnessRLRegistry; temporal: HarnessRLTemporalGateway; cwd: string; caseRepo: string; stateRoot: string }) {
|
||||
const { registry, temporal } = options;
|
||||
@@ -20,6 +24,21 @@ export function createHarnessRLService(options: { registry: HarnessRLRegistry; t
|
||||
}
|
||||
return cases.sort((left, right) => left.caseId.localeCompare(right.caseId));
|
||||
},
|
||||
async listRuns(input: { status?: string; limit?: string; cursor?: string } = {}) {
|
||||
const status = parseStatus(input.status);
|
||||
const limit = parseLimit(input.limit);
|
||||
const records = await registry.listRuns({ status, limit: limit + 1, before: decodeCursor(input.cursor) });
|
||||
const hasMore = records.length > limit;
|
||||
const page = hasMore ? records.slice(0, limit) : records;
|
||||
const last = page.at(-1);
|
||||
return {
|
||||
ok: true,
|
||||
contractVersion: "hwlab-caserun-runs-v1",
|
||||
count: page.length,
|
||||
runs: page.map(runListItem),
|
||||
nextCursor: hasMore && last ? encodeCursor(last) : null
|
||||
};
|
||||
},
|
||||
async submit(input: { caseId: string; runId?: string; runtimeApiUrl: string }) {
|
||||
const caseId = opaqueId(input.caseId, "invalid_case_id");
|
||||
const definition = await loadCase(options.caseRepo, caseId).catch(() => null);
|
||||
@@ -64,6 +83,71 @@ export function createHarnessRLService(options: { registry: HarnessRLRegistry; t
|
||||
};
|
||||
}
|
||||
|
||||
function runListItem(record: CaseRunRecord): CaseRunListItem {
|
||||
const summary = objectValue(record.result?.summary);
|
||||
const evidence = objectValue(record.result?.evidence);
|
||||
const artifacts = Array.isArray(summary?.artifacts) ? summary.artifacts : Array.isArray(evidence?.artifacts) ? evidence.artifacts : [];
|
||||
return {
|
||||
runId: record.runId,
|
||||
caseId: record.caseId,
|
||||
workflowId: record.workflowId,
|
||||
workflowRunId: record.workflowRunId,
|
||||
status: record.status,
|
||||
stage: record.stage,
|
||||
terminal: record.terminal,
|
||||
createdAt: record.createdAt,
|
||||
updatedAt: record.updatedAt,
|
||||
artifactCount: finiteNumber(summary?.artifactCount) ?? artifacts.length,
|
||||
evidenceStatus: stringValue(evidence?.status),
|
||||
artifactManifestSha256: stringValue(record.result?.artifactManifestSha256)
|
||||
};
|
||||
}
|
||||
|
||||
function parseStatus(value?: string): CaseRunStatus | undefined {
|
||||
if (value === undefined || value === "") return undefined;
|
||||
if (!CASE_RUN_STATUSES.has(value as CaseRunStatus)) throw codedError("invalid_status", "status is invalid", { status: value });
|
||||
return value as CaseRunStatus;
|
||||
}
|
||||
|
||||
function parseLimit(value?: string): number {
|
||||
if (value === undefined || value === "") return DEFAULT_RUN_LIST_LIMIT;
|
||||
const limit = Number(value);
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_RUN_LIST_LIMIT) {
|
||||
throw codedError("invalid_limit", `limit must be an integer between 1 and ${MAX_RUN_LIST_LIMIT}`);
|
||||
}
|
||||
return limit;
|
||||
}
|
||||
|
||||
function encodeCursor(record: Pick<CaseRunRecord, "createdAt" | "runId">): string {
|
||||
return Buffer.from(JSON.stringify({ createdAt: record.createdAt, runId: record.runId }), "utf8").toString("base64url");
|
||||
}
|
||||
|
||||
function decodeCursor(value?: string): { createdAt: string; runId: string } | undefined {
|
||||
if (value === undefined || value === "") return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
|
||||
const createdAt = String(parsed?.createdAt ?? "");
|
||||
const runId = opaqueId(parsed?.runId, "invalid_cursor");
|
||||
if (!createdAt || Number.isNaN(Date.parse(createdAt))) throw new Error("createdAt is invalid");
|
||||
return { createdAt: new Date(createdAt).toISOString(), runId };
|
||||
} catch {
|
||||
throw codedError("invalid_cursor", "cursor is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
function objectValue(value: unknown): Record<string, any> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, any> : null;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | null {
|
||||
return typeof value === "string" && value ? value : null;
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown): number | null {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) && number >= 0 ? number : null;
|
||||
}
|
||||
|
||||
async function loadCase(caseRepo: string, caseId: string) {
|
||||
const caseDir = path.join(caseRepo, "cases", caseId);
|
||||
const definition = JSON.parse(await readFile(path.join(caseDir, "case.json"), "utf8"));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { listNativeCases, readNativeAggregate, readNativeEvents, readNativeRun, resetNativeFixtures, startNativeRun } from "./caserun-native-fixtures";
|
||||
import { listNativeCases, listNativeRuns, readNativeAggregate, readNativeEvents, readNativeRun, resetNativeFixtures, startNativeRun } from "./caserun-native-fixtures";
|
||||
|
||||
test("native CaseRun fixtures expose six deterministic API-owned states", () => {
|
||||
resetNativeFixtures();
|
||||
@@ -36,6 +36,17 @@ test("native CaseRun fixtures expose six deterministic API-owned states", () =>
|
||||
assert.equal(events?.mode, "native-test");
|
||||
});
|
||||
|
||||
test("native run history exposes a completed CLI-shaped result", () => {
|
||||
resetNativeFixtures();
|
||||
const history = listNativeRuns();
|
||||
assert.equal(history.mode, "native-test");
|
||||
assert.equal(history.runs[0]?.runId, "cli-software-smoke-passed");
|
||||
assert.equal(history.runs[0]?.caseId, "software-smoke");
|
||||
assert.equal(history.runs[0]?.status, "completed");
|
||||
assert.equal(history.runs[0]?.artifactCount, 1);
|
||||
assert.equal(readNativeAggregate("cli-software-smoke-passed")?.terminal, true);
|
||||
});
|
||||
|
||||
test("native blockers and cancellation come from the API fixture", () => {
|
||||
resetNativeFixtures();
|
||||
const blocked = startNativeRun("native-blocked")!;
|
||||
|
||||
@@ -14,6 +14,7 @@ interface NativeRunState {
|
||||
caseId: string;
|
||||
cursor: number;
|
||||
steps: NativeStep[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const MODE = "native-test";
|
||||
@@ -105,8 +106,8 @@ function runResponse(run: NativeRunState) {
|
||||
stage: step.stage,
|
||||
terminal: step.terminal,
|
||||
blocker: step.blocker ?? null,
|
||||
createdAt: timestamp(0),
|
||||
updatedAt: timestamp(run.cursor),
|
||||
createdAt: run.createdAt,
|
||||
updatedAt: new Date(Date.parse(run.createdAt) + run.cursor * 1000).toISOString(),
|
||||
facts: {
|
||||
jobId: `job-${run.runId}`,
|
||||
artifactCount: step.status === "completed" ? 1 : 0,
|
||||
@@ -145,11 +146,44 @@ export function startNativeRun(caseId: string) {
|
||||
if (!scenario) return null;
|
||||
runSequence += 1;
|
||||
const runId = `${caseId}-${runSequence}`;
|
||||
const run: NativeRunState = { runId, caseId, cursor: 0, steps: scenario.steps };
|
||||
const run: NativeRunState = { runId, caseId, cursor: 0, steps: scenario.steps, createdAt: timestamp(30 + runSequence) };
|
||||
runs.set(runId, run);
|
||||
return runResponse(run);
|
||||
}
|
||||
|
||||
export function listNativeRuns(query: { status?: string; limit?: number; cursor?: string } = {}) {
|
||||
const limit = query.limit ?? 30;
|
||||
const before = query.cursor ? decodeCursor(query.cursor) : null;
|
||||
const records = [...runs.values()]
|
||||
.map((run) => runResponse(run))
|
||||
.filter((run) => !query.status || run.status === query.status)
|
||||
.filter((run) => !before || run.createdAt < before.createdAt || (run.createdAt === before.createdAt && run.runId < before.runId))
|
||||
.sort((left, right) => right.createdAt.localeCompare(left.createdAt) || right.runId.localeCompare(left.runId));
|
||||
const page = records.slice(0, limit);
|
||||
const hasMore = records.length > limit;
|
||||
return {
|
||||
ok: true,
|
||||
mode: MODE,
|
||||
contractVersion: "hwlab-caserun-runs-v1",
|
||||
count: page.length,
|
||||
runs: page.map((run) => ({
|
||||
runId: run.runId,
|
||||
caseId: run.caseId,
|
||||
workflowId: run.workflowId,
|
||||
workflowRunId: run.workflowRunId,
|
||||
status: run.status,
|
||||
stage: run.stage,
|
||||
terminal: run.terminal,
|
||||
createdAt: run.createdAt,
|
||||
updatedAt: run.updatedAt,
|
||||
artifactCount: run.facts.artifactCount,
|
||||
evidenceStatus: run.facts.evidenceStatus,
|
||||
artifactManifestSha256: run.references.aggregate.sha256
|
||||
})),
|
||||
nextCursor: hasMore && page.length ? encodeCursor(page.at(-1)!) : null
|
||||
};
|
||||
}
|
||||
|
||||
export function readNativeRun(runId: string, advance = true) {
|
||||
const run = runs.get(runId);
|
||||
if (!run) return null;
|
||||
@@ -180,4 +214,23 @@ export function readNativeAggregate(runId: string) {
|
||||
export function resetNativeFixtures(): void {
|
||||
runSequence = 0;
|
||||
runs.clear();
|
||||
const steps = scenarios["native-completed"]!.steps;
|
||||
runs.set("cli-software-smoke-passed", {
|
||||
runId: "cli-software-smoke-passed",
|
||||
caseId: "software-smoke",
|
||||
cursor: steps.length - 1,
|
||||
steps,
|
||||
createdAt: timestamp(20)
|
||||
});
|
||||
}
|
||||
|
||||
function encodeCursor(run: { createdAt: string; runId: string }): string {
|
||||
return Buffer.from(JSON.stringify({ createdAt: run.createdAt, runId: run.runId }), "utf8").toString("base64url");
|
||||
}
|
||||
|
||||
function decodeCursor(cursor: string): { createdAt: string; runId: string } {
|
||||
const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
||||
return { createdAt: String(parsed.createdAt), runId: String(parsed.runId) };
|
||||
}
|
||||
|
||||
resetNativeFixtures();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { listNativeCases, readNativeAggregate, readNativeEvents, readNativeRun, startNativeRun } from "./caserun-native-fixtures";
|
||||
import { listNativeCases, listNativeRuns, readNativeAggregate, readNativeEvents, readNativeRun, startNativeRun } from "./caserun-native-fixtures";
|
||||
|
||||
const port = Number(requiredEnv("HWLAB_CASERUN_NATIVE_PORT"));
|
||||
const hostname = requiredEnv("HWLAB_CASERUN_NATIVE_HOST");
|
||||
@@ -36,6 +36,13 @@ const server = Bun.serve({
|
||||
}
|
||||
if (request.method === "GET" && url.pathname === "/auth/session") return json(authSession());
|
||||
if (request.method === "GET" && url.pathname === "/v1/caserun/cases") return json(listNativeCases());
|
||||
if (request.method === "GET" && url.pathname === "/v1/caserun/runs") {
|
||||
return json(listNativeRuns({
|
||||
status: url.searchParams.get("status") ?? undefined,
|
||||
limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
|
||||
cursor: url.searchParams.get("cursor") ?? undefined
|
||||
}));
|
||||
}
|
||||
if (request.method === "POST" && url.pathname === "/v1/caserun/runs") {
|
||||
const body = await request.json().catch(() => null) as { caseId?: string } | null;
|
||||
if (!body?.caseId) return json({ ok: false, mode: "native-test", error: { code: "case-id-required", message: "caseId is required" } }, 400);
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { fetchJson } from "./client";
|
||||
import type { ApiResult, CaseRunAggregateResponse, CaseRunCasesResponse, CaseRunEventsResponse, CaseRunRunResponse } from "@/types";
|
||||
import type { ApiResult, CaseRunAggregateResponse, CaseRunCasesResponse, CaseRunEventsResponse, CaseRunRunsResponse, CaseRunRunResponse } from "@/types";
|
||||
|
||||
export const caserunAPI = {
|
||||
cases: (): Promise<ApiResult<CaseRunCasesResponse>> => fetchJson("/v1/caserun/cases", { timeoutMs: 12000, timeoutName: "CaseRun cases" }),
|
||||
runs: (query: { status?: string; limit?: number; cursor?: string } = {}): Promise<ApiResult<CaseRunRunsResponse>> => {
|
||||
const params = new URLSearchParams();
|
||||
if (query.status) params.set("status", query.status);
|
||||
if (query.limit) params.set("limit", String(query.limit));
|
||||
if (query.cursor) params.set("cursor", query.cursor);
|
||||
const suffix = params.size ? `?${params.toString()}` : "";
|
||||
return fetchJson(`/v1/caserun/runs${suffix}`, { timeoutMs: 12000, timeoutName: "CaseRun runs" });
|
||||
},
|
||||
startRun: (caseId: string): Promise<ApiResult<CaseRunRunResponse>> => fetchJson("/v1/caserun/runs", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ caseId }),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
const { casesRequest, startRequest, runRequest, eventsRequest, aggregateRequest } = vi.hoisted(() => ({
|
||||
const { casesRequest, runsRequest, startRequest, runRequest, eventsRequest, aggregateRequest } = vi.hoisted(() => ({
|
||||
casesRequest: vi.fn(),
|
||||
runsRequest: vi.fn(),
|
||||
startRequest: vi.fn(),
|
||||
runRequest: vi.fn(),
|
||||
eventsRequest: vi.fn(),
|
||||
@@ -10,7 +11,7 @@ const { casesRequest, startRequest, runRequest, eventsRequest, aggregateRequest
|
||||
}));
|
||||
|
||||
vi.mock("@/api", () => ({
|
||||
caserunAPI: { cases: casesRequest, startRun: startRequest, run: runRequest, events: eventsRequest, aggregate: aggregateRequest }
|
||||
caserunAPI: { cases: casesRequest, runs: runsRequest, startRun: startRequest, run: runRequest, events: eventsRequest, aggregate: aggregateRequest }
|
||||
}));
|
||||
|
||||
import { useCaseRunStore } from "./caserun";
|
||||
@@ -18,10 +19,27 @@ import { useCaseRunStore } from "./caserun";
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
casesRequest.mockReset();
|
||||
runsRequest.mockReset();
|
||||
startRequest.mockReset();
|
||||
runRequest.mockReset();
|
||||
eventsRequest.mockReset();
|
||||
aggregateRequest.mockReset();
|
||||
runsRequest.mockResolvedValue({ ok: true, data: { runs: [], nextCursor: null } });
|
||||
});
|
||||
|
||||
test("run history keeps shared registry summaries and pagination separate from current run", async () => {
|
||||
runsRequest
|
||||
.mockResolvedValueOnce({ ok: true, data: { runs: [{ runId: "cli-run-2", caseId: "software-smoke", status: "completed", stage: "completed", terminal: true }], nextCursor: "next-page" } })
|
||||
.mockResolvedValueOnce({ ok: true, data: { runs: [{ runId: "cli-run-1", caseId: "software-smoke", status: "completed", stage: "completed", terminal: true }], nextCursor: null } });
|
||||
const store = useCaseRunStore();
|
||||
|
||||
await store.refreshRuns();
|
||||
await store.refreshRuns({ append: true });
|
||||
|
||||
expect(store.runs.map((run) => run.runId)).toEqual(["cli-run-2", "cli-run-1"]);
|
||||
expect(store.nextRunsCursor).toBeNull();
|
||||
expect(store.currentRun).toBeNull();
|
||||
expect(runsRequest).toHaveBeenLastCalledWith({ limit: 30, cursor: "next-page" });
|
||||
});
|
||||
|
||||
test("polling follows API terminal instead of status names", async () => {
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { computed, ref } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
import { caserunAPI } from "@/api";
|
||||
import type { CaseRunAggregateResponse, CaseRunCaseSummary, CaseRunEvent, CaseRunRunResponse } from "@/types";
|
||||
import type { CaseRunAggregateResponse, CaseRunCaseSummary, CaseRunEvent, CaseRunListItem, CaseRunRunResponse } from "@/types";
|
||||
|
||||
export const useCaseRunStore = defineStore("caserun", () => {
|
||||
const cases = ref<CaseRunCaseSummary[]>([]);
|
||||
const runs = ref<CaseRunListItem[]>([]);
|
||||
const nextRunsCursor = ref<string | null>(null);
|
||||
const currentRun = ref<CaseRunRunResponse | null>(null);
|
||||
const events = ref<CaseRunEvent[]>([]);
|
||||
const aggregate = ref<CaseRunAggregateResponse | null>(null);
|
||||
const loading = ref(false);
|
||||
const starting = ref(false);
|
||||
const runsLoading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const runsError = ref<string | null>(null);
|
||||
const selectedCaseId = computed(() => currentRun.value?.caseId ?? cases.value[0]?.caseId ?? "");
|
||||
const shouldPoll = computed(() => Boolean(currentRun.value?.runId && currentRun.value.terminal === false));
|
||||
|
||||
@@ -23,6 +27,22 @@ export const useCaseRunStore = defineStore("caserun", () => {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
async function refreshRuns(options: { append?: boolean } = {}): Promise<void> {
|
||||
if (runsLoading.value) return;
|
||||
runsLoading.value = true;
|
||||
runsError.value = null;
|
||||
const cursor = options.append ? nextRunsCursor.value ?? undefined : undefined;
|
||||
const result = await caserunAPI.runs({ limit: 30, cursor });
|
||||
if (result.ok && result.data) {
|
||||
const incoming = result.data.runs ?? [];
|
||||
runs.value = options.append ? [...runs.value, ...incoming.filter((item) => !runs.value.some((existing) => existing.runId === item.runId))] : incoming;
|
||||
nextRunsCursor.value = result.data.nextCursor ?? null;
|
||||
} else {
|
||||
runsError.value = result.error ?? "无法读取最近运行";
|
||||
}
|
||||
runsLoading.value = false;
|
||||
}
|
||||
|
||||
async function startRun(caseId: string): Promise<CaseRunRunResponse | null> {
|
||||
starting.value = true;
|
||||
error.value = null;
|
||||
@@ -36,18 +56,26 @@ export const useCaseRunStore = defineStore("caserun", () => {
|
||||
}
|
||||
currentRun.value = result.data;
|
||||
await refreshRun(result.data.runId ?? "");
|
||||
await refreshRuns();
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async function refreshRun(runId = currentRun.value?.runId ?? ""): Promise<void> {
|
||||
if (!runId) return;
|
||||
async function refreshRun(runId = currentRun.value?.runId ?? ""): Promise<boolean> {
|
||||
if (!runId) return false;
|
||||
const result = await caserunAPI.run(runId);
|
||||
if (!result.ok || !result.data) {
|
||||
error.value = result.error ?? "无法读取 CaseRun 运行";
|
||||
return;
|
||||
if (currentRun.value?.runId !== runId) {
|
||||
currentRun.value = null;
|
||||
events.value = [];
|
||||
aggregate.value = null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
error.value = null;
|
||||
currentRun.value = result.data;
|
||||
await Promise.all([refreshEvents(runId), refreshAggregate(runId)]);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function refreshEvents(runId = currentRun.value?.runId ?? ""): Promise<void> {
|
||||
@@ -62,5 +90,10 @@ export const useCaseRunStore = defineStore("caserun", () => {
|
||||
if (result.ok && result.data?.runId === runId) aggregate.value = result.data;
|
||||
}
|
||||
|
||||
return { cases, currentRun, events, aggregate, loading, starting, error, selectedCaseId, shouldPoll, refreshCases, startRun, refreshRun, refreshEvents, refreshAggregate };
|
||||
return {
|
||||
cases, runs, nextRunsCursor, currentRun, events, aggregate,
|
||||
loading, runsLoading, starting, error, runsError,
|
||||
selectedCaseId, shouldPoll,
|
||||
refreshCases, refreshRuns, startRun, refreshRun, refreshEvents, refreshAggregate
|
||||
};
|
||||
});
|
||||
|
||||
@@ -517,6 +517,8 @@ export interface HwlabNodeUpdateMetadata {
|
||||
}
|
||||
export interface CaseRunCaseSummary { caseId: string; title?: string; mode?: string; available?: boolean; hwpodSpec?: string; subject?: Record<string, unknown> | null; expected?: Record<string, unknown> | null; runtime?: Record<string, unknown> | null; [key: string]: unknown }
|
||||
export interface CaseRunCasesResponse { ok?: boolean; mode?: string; contractVersion?: string; count?: number; cases?: CaseRunCaseSummary[]; caseAuthority?: Record<string, unknown>; [key: string]: unknown }
|
||||
export interface CaseRunListItem { runId: string; caseId: string; workflowId?: string; workflowRunId?: string | null; status: string; stage: string; terminal: boolean; createdAt: string; updatedAt: string; artifactCount?: number; evidenceStatus?: string | null; artifactManifestSha256?: string | null; [key: string]: unknown }
|
||||
export interface CaseRunRunsResponse { ok?: boolean; mode?: string; contractVersion?: string; count?: number; runs?: CaseRunListItem[]; nextCursor?: string | null; [key: string]: unknown }
|
||||
export interface CaseRunEvent { id?: number; at?: string; createdAt?: string; status?: string; stage?: string; payload?: Record<string, unknown>; [key: string]: unknown }
|
||||
export interface CaseRunBlocker { code: string; summary: string; layer?: string | null; details?: Record<string, unknown> | null }
|
||||
export interface CaseRunReference { href?: string; ref?: string | null; sha256?: string | null; count?: number; relationship?: Record<string, unknown> | null; [key: string]: unknown }
|
||||
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
FileCheck2,
|
||||
FileCode2,
|
||||
GitBranch,
|
||||
History,
|
||||
ListChecks,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ShieldAlert,
|
||||
TerminalSquare,
|
||||
XCircle,
|
||||
@@ -23,12 +26,15 @@ import PageCommandBar from "@/components/layout/PageCommandBar.vue";
|
||||
import StatusStrip, { type ConsoleStatusItem } from "@/components/layout/StatusStrip.vue";
|
||||
import StatusBadge from "@/components/common/StatusBadge.vue";
|
||||
import { useCaseRunStore } from "@/stores/caserun";
|
||||
import type { CaseRunArtifact, CaseRunCaseSummary, CaseRunEvent } from "@/types";
|
||||
import type { CaseRunArtifact, CaseRunCaseSummary, CaseRunEvent, CaseRunListItem } from "@/types";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const caserun = useCaseRunStore();
|
||||
const selectedCaseId = ref("");
|
||||
type SidebarTab = "runs" | "cases";
|
||||
const sidebarTab = ref<SidebarTab>("runs");
|
||||
const runLookup = ref("");
|
||||
type EvidenceTab = "aggregate" | "artifacts" | "workflow" | "hwpod";
|
||||
const evidenceTabs: Array<{ id: EvidenceTab; label: string }> = [
|
||||
{ id: "aggregate", label: "结果" },
|
||||
@@ -76,12 +82,12 @@ const statusItems = computed<ConsoleStatusItem[]>(() => [
|
||||
|
||||
onMounted(async () => {
|
||||
selectedCaseId.value = typeof route.query.case === "string" ? route.query.case : "";
|
||||
if (selectedCaseId.value && !runId.value) sidebarTab.value = "cases";
|
||||
selectedTab.value = parseTab(route.query.tab);
|
||||
await caserun.refreshCases();
|
||||
await Promise.all([caserun.refreshCases(), caserun.refreshRuns()]);
|
||||
if (!selectedCaseId.value) selectedCaseId.value = caserun.cases[0]?.caseId ?? "";
|
||||
if (runId.value) {
|
||||
await caserun.refreshRun(runId.value);
|
||||
if (caserun.currentRun?.caseId) selectedCaseId.value = caserun.currentRun.caseId;
|
||||
await loadRun(runId.value);
|
||||
}
|
||||
pollTimer = setInterval(() => {
|
||||
if (caserun.shouldPoll) void caserun.refreshRun();
|
||||
@@ -93,7 +99,7 @@ onBeforeUnmount(() => {
|
||||
});
|
||||
|
||||
watch(() => route.params.runId, async (value) => {
|
||||
if (typeof value === "string" && value) await caserun.refreshRun(value);
|
||||
if (typeof value === "string" && value) await loadRun(value);
|
||||
});
|
||||
|
||||
watch(() => route.query.tab, (value) => { selectedTab.value = parseTab(value); });
|
||||
@@ -109,15 +115,34 @@ async function startRun(): Promise<void> {
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
await caserun.refreshCases();
|
||||
await Promise.all([caserun.refreshCases(), caserun.refreshRuns()]);
|
||||
if (runId.value) await caserun.refreshRun(runId.value);
|
||||
}
|
||||
|
||||
async function selectCase(caseId: string): Promise<void> {
|
||||
sidebarTab.value = "cases";
|
||||
selectedCaseId.value = caseId;
|
||||
await router.replace({ name: runId.value ? "CaseRunDetail" : "CaseRun", params: runId.value ? { runId: runId.value } : {}, query: { case: caseId, tab: selectedTab.value } });
|
||||
}
|
||||
|
||||
async function loadRun(value: string): Promise<void> {
|
||||
runLookup.value = value;
|
||||
const loaded = await caserun.refreshRun(value);
|
||||
if (loaded && caserun.currentRun?.caseId) selectedCaseId.value = caserun.currentRun.caseId;
|
||||
}
|
||||
|
||||
async function openRun(value = runLookup.value): Promise<void> {
|
||||
const target = value.trim();
|
||||
if (!target) return;
|
||||
sidebarTab.value = "runs";
|
||||
runLookup.value = target;
|
||||
if (target === runId.value) {
|
||||
await loadRun(target);
|
||||
return;
|
||||
}
|
||||
await router.push({ name: "CaseRunDetail", params: { runId: target } });
|
||||
}
|
||||
|
||||
async function selectTab(tab: EvidenceTab): Promise<void> {
|
||||
selectedTab.value = tab;
|
||||
await router.replace({ query: { ...route.query, tab } });
|
||||
@@ -164,6 +189,20 @@ function eventTime(value?: string): string {
|
||||
return Number.isNaN(date.getTime()) ? "--:--:--" : date.toLocaleTimeString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function historyTime(value?: string): string {
|
||||
if (!value) return "时间未知";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "时间未知";
|
||||
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
}
|
||||
|
||||
function historyStatusClass(run: CaseRunListItem): string {
|
||||
if (run.status === "completed") return "complete";
|
||||
if (run.status === "failed" || run.status === "blocked") return "failed";
|
||||
if (run.status === "canceled") return "canceled";
|
||||
return "running";
|
||||
}
|
||||
|
||||
function formatDuration(start?: string, end?: string): string {
|
||||
if (!start || !end) return "-";
|
||||
const elapsed = new Date(end).getTime() - new Date(start).getTime();
|
||||
@@ -200,27 +239,49 @@ function artifactSize(bytes?: number): string {
|
||||
<div class="caserun-workspace">
|
||||
<aside class="caserun-sidebar" aria-label="CaseRun 用例选择">
|
||||
<div class="caserun-sidebar-heading">
|
||||
<div><span class="caserun-kicker">用例目录</span><h2>可执行用例</h2></div>
|
||||
<span class="caserun-count">{{ caserun.cases.length }}</span>
|
||||
<div><span class="caserun-kicker">运行入口</span><h2>{{ sidebarTab === "runs" ? "最近运行" : "可执行用例" }}</h2></div>
|
||||
<span class="caserun-count">{{ sidebarTab === "runs" ? caserun.runs.length : caserun.cases.length }}</span>
|
||||
</div>
|
||||
<div class="caserun-case-list">
|
||||
<button v-for="item in caserun.cases" :key="item.caseId" class="caserun-case-row" :class="{ selected: item.caseId === selectedCaseId }" type="button" @click="selectCase(item.caseId)">
|
||||
<span class="case-row-icon"><Cpu :size="16" aria-hidden="true" /></span>
|
||||
<span class="case-row-copy"><strong>{{ item.title || item.caseId }}</strong><small>{{ item.caseId }}</small><small>{{ item.hwpodSpec || "未声明 HWPOD 规格" }}</small></span>
|
||||
<Check v-if="item.available !== false" class="case-row-check" :size="15" aria-hidden="true" />
|
||||
</button>
|
||||
<div v-if="!caserun.cases.length && !caserun.loading" class="caserun-empty">当前入口没有可执行用例。</div>
|
||||
<div class="caserun-sidebar-tabs" role="tablist" aria-label="CaseRun 运行入口">
|
||||
<button type="button" role="tab" :aria-selected="sidebarTab === 'runs'" @click="sidebarTab = 'runs'"><History :size="14" aria-hidden="true" />最近运行</button>
|
||||
<button type="button" role="tab" :aria-selected="sidebarTab === 'cases'" @click="sidebarTab = 'cases'"><ListChecks :size="14" aria-hidden="true" />可执行用例</button>
|
||||
</div>
|
||||
<div v-if="selectedCase" class="caserun-context">
|
||||
<span class="caserun-kicker">已选上下文</span>
|
||||
<strong>{{ selectedCase.title || selectedCase.caseId }}</strong>
|
||||
<dl>
|
||||
<div><dt>用例 ID</dt><dd>{{ selectedCase.caseId }}</dd></div>
|
||||
<div><dt>运行模式</dt><dd>{{ modeLabel(selectedCase.mode || "live") }}</dd></div>
|
||||
<div><dt>HWPOD</dt><dd>{{ selectedCase.hwpodSpec || "-" }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
<button id="caserun-start" class="caserun-start" type="button" :disabled="!selectedCaseId || caserun.starting" @click="startRun"><TerminalSquare :size="17" aria-hidden="true" />{{ caserun.starting ? "提交中" : "启动 CaseRun" }}</button>
|
||||
<template v-if="sidebarTab === 'runs'">
|
||||
<form class="caserun-run-lookup" aria-label="按 runId 加载运行" @submit.prevent="openRun()">
|
||||
<Search :size="15" aria-hidden="true" />
|
||||
<input id="caserun-run-lookup" v-model="runLookup" type="search" autocomplete="off" placeholder="输入 CLI 输出的 runId" aria-label="输入 CLI 输出的 runId" />
|
||||
<button type="submit" :disabled="!runLookup.trim()" title="加载 runId" aria-label="加载 runId">加载</button>
|
||||
</form>
|
||||
<div class="caserun-run-list">
|
||||
<button v-for="item in caserun.runs" :key="item.runId" class="caserun-run-row" :class="{ selected: item.runId === runId }" type="button" @click="openRun(item.runId)">
|
||||
<span class="run-row-top"><strong>{{ item.caseId }}</strong><span class="run-row-status" :class="historyStatusClass(item)">{{ statusLabel(item.status) }}</span></span>
|
||||
<code>{{ item.runId }}</code>
|
||||
<span class="run-row-meta"><span>{{ stageLabel(item.stage) }}</span><time>{{ historyTime(item.createdAt) }}</time><span>{{ item.artifactCount ?? 0 }} 项产物</span></span>
|
||||
</button>
|
||||
<div v-if="!caserun.runs.length && !caserun.runsLoading" class="caserun-empty">{{ caserun.runsError || "共享 registry 中暂无运行。" }}</div>
|
||||
<button v-if="caserun.nextRunsCursor" class="caserun-load-more" type="button" :disabled="caserun.runsLoading" @click="caserun.refreshRuns({ append: true })">{{ caserun.runsLoading ? "读取中" : "加载更早运行" }}</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="caserun-case-list">
|
||||
<button v-for="item in caserun.cases" :key="item.caseId" class="caserun-case-row" :class="{ selected: item.caseId === selectedCaseId }" type="button" @click="selectCase(item.caseId)">
|
||||
<span class="case-row-icon"><Cpu :size="16" aria-hidden="true" /></span>
|
||||
<span class="case-row-copy"><strong>{{ item.title || item.caseId }}</strong><small>{{ item.caseId }}</small><small>{{ item.hwpodSpec || "未声明 HWPOD 规格" }}</small></span>
|
||||
<Check v-if="item.available !== false" class="case-row-check" :size="15" aria-hidden="true" />
|
||||
</button>
|
||||
<div v-if="!caserun.cases.length && !caserun.loading" class="caserun-empty">当前入口没有可执行用例。</div>
|
||||
</div>
|
||||
<div v-if="selectedCase" class="caserun-context">
|
||||
<span class="caserun-kicker">已选上下文</span>
|
||||
<strong>{{ selectedCase.title || selectedCase.caseId }}</strong>
|
||||
<dl>
|
||||
<div><dt>用例 ID</dt><dd>{{ selectedCase.caseId }}</dd></div>
|
||||
<div><dt>运行模式</dt><dd>{{ modeLabel(selectedCase.mode || "live") }}</dd></div>
|
||||
<div><dt>HWPOD</dt><dd>{{ selectedCase.hwpodSpec || "-" }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
<button id="caserun-start" class="caserun-start" type="button" :disabled="!selectedCaseId || caserun.starting" @click="startRun"><TerminalSquare :size="17" aria-hidden="true" />{{ caserun.starting ? "提交中" : "启动 CaseRun" }}</button>
|
||||
</template>
|
||||
</aside>
|
||||
|
||||
<main class="caserun-main">
|
||||
@@ -329,6 +390,27 @@ function artifactSize(bytes?: number): string {
|
||||
.caserun-sidebar-heading h2, .inspector-heading h2, .run-heading h2, .section-heading h3 { margin: 3px 0 0; font-size: 15px; font-weight: 780; }
|
||||
.caserun-kicker { color: var(--console-cyan-700); font-family: var(--console-font-mono); font-size: 10px; font-weight: 780; letter-spacing: .08em; }
|
||||
.caserun-count { min-width: 24px; padding: 3px 7px; border: 1px solid var(--console-border); border-radius: 99px; color: var(--console-graphite-600); font-family: var(--console-font-mono); font-size: 11px; text-align: center; }
|
||||
.caserun-sidebar-tabs { display: grid; grid-template-columns: repeat(2, 1fr); gap: 3px; margin: 8px; border: 1px solid var(--console-border); border-radius: var(--console-radius-sm); background: var(--console-surface-raised); padding: 3px; }
|
||||
.caserun-sidebar-tabs button { display: inline-flex; min-width: 0; align-items: center; justify-content: center; gap: 6px; border: 0; border-radius: 4px; background: transparent; padding: 7px 5px; color: var(--console-graphite-600); font-size: 11px; cursor: pointer; }
|
||||
.caserun-sidebar-tabs button[aria-selected="true"] { background: var(--console-cyan-100); color: var(--console-cyan-700); font-weight: 760; }
|
||||
.caserun-run-lookup { display: grid; grid-template-columns: 16px minmax(0, 1fr) auto; gap: 7px; align-items: center; margin: 0 8px 7px; border: 1px solid var(--console-border); border-radius: var(--console-radius-sm); background: var(--console-surface-raised); padding: 4px 4px 4px 9px; color: var(--console-graphite-500); }
|
||||
.caserun-run-lookup:focus-within { border-color: var(--console-cyan-500); box-shadow: 0 0 0 2px var(--console-cyan-100); }
|
||||
.caserun-run-lookup input { width: 100%; min-width: 0; border: 0; outline: 0; background: transparent; color: var(--console-graphite-950); font-family: var(--console-font-mono); font-size: 10px; }
|
||||
.caserun-run-lookup button { border: 0; border-radius: 4px; background: var(--console-cyan-700); padding: 6px 8px; color: white; font-size: 10px; cursor: pointer; }
|
||||
.caserun-run-lookup button:disabled { cursor: not-allowed; opacity: .45; }
|
||||
.caserun-run-list { min-height: 0; flex: 1 1 auto; overflow: auto; overscroll-behavior: contain; padding: 0 8px 8px; }
|
||||
.caserun-run-row { display: grid; width: 100%; gap: 5px; border: 1px solid transparent; border-bottom-color: var(--console-border); background: transparent; padding: 10px 7px; color: inherit; text-align: left; cursor: pointer; }
|
||||
.caserun-run-row:hover, .caserun-run-row.selected { border-color: var(--console-cyan-200); border-radius: var(--console-radius-sm); background: var(--console-cyan-100); }
|
||||
.run-row-top, .run-row-meta { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 7px; }
|
||||
.run-row-top strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.run-row-status { flex: 0 0 auto; border-radius: 99px; padding: 2px 6px; background: var(--console-graphite-100); color: var(--console-graphite-600); font-size: 9px; }
|
||||
.run-row-status.complete { background: var(--console-green-100); color: var(--console-green-700); }
|
||||
.run-row-status.failed { background: var(--console-red-100); color: var(--console-red-700); }
|
||||
.run-row-status.running { background: var(--console-cyan-100); color: var(--console-cyan-700); }
|
||||
.caserun-run-row code { overflow: hidden; color: var(--console-graphite-600); font-family: var(--console-font-mono); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.run-row-meta { justify-content: flex-start; color: var(--console-graphite-500); font-family: var(--console-font-mono); font-size: 9px; }
|
||||
.run-row-meta time { margin-left: auto; }
|
||||
.caserun-load-more { width: 100%; border: 0; background: transparent; padding: 10px; color: var(--console-cyan-700); font-size: 10px; cursor: pointer; }
|
||||
.caserun-case-list { min-height: 0; flex: 1 1 auto; overflow: auto; overscroll-behavior: contain; padding: 8px; }
|
||||
.caserun-case-row { display: grid; width: 100%; grid-template-columns: 28px minmax(0, 1fr) 16px; gap: 8px; align-items: start; border: 1px solid transparent; border-radius: var(--console-radius-sm); background: transparent; padding: 10px 8px; color: inherit; text-align: left; cursor: pointer; }
|
||||
.caserun-case-row:hover, .caserun-case-row.selected { border-color: var(--console-cyan-200); background: var(--console-cyan-100); }
|
||||
|
||||
Reference in New Issue
Block a user