fix: harden workflow console contracts

This commit is contained in:
lda
2026-08-09 03:26:26 +07:00 Verified
parent 255a26c210
commit eccc982598
39 changed files with 751 additions and 155 deletions
@@ -0,0 +1,55 @@
import { render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { cleanup } from "@testing-library/react";
import { RecordColumns } from "./RecordColumns.js";
afterEach(() => cleanup());
describe("RecordColumns", () => {
it("uses presentation wrappers for listbox options", () => {
render(
<RecordColumns
artifacts={[{
key: "report@1",
artifactId: "report",
version: 1,
kind: "workflow",
displayName: "Report",
description: null,
outcomes: [],
requiredSources: [],
diagnosticCount: 0,
}]}
deployments={[{
id: "report.default",
artifactId: "report",
artifactVersion: 1,
bindingCount: 0,
driftPolicy: "strict",
}]}
runs={[{
runId: "run_123",
deploymentId: "report.default",
artifactId: "report",
artifactVersion: 1,
status: "succeeded",
resumeReadiness: "not_applicable",
diagnosticCount: 0,
}]}
selectedArtifactId={null}
selectedDeploymentId={null}
selectedRunId={null}
primaryKind="artifact"
onSelectArtifact={() => undefined}
onSelectDeployment={() => undefined}
onSelectRun={() => undefined}
/>,
);
for (const listbox of screen.getAllByRole("listbox")) {
expect(Array.from(listbox.children)).toHaveLength(1);
expect(listbox.children[0]).toHaveAttribute("role", "presentation");
}
expect(screen.getAllByRole("option")).toHaveLength(3);
});
});
@@ -64,7 +64,7 @@ export const RecordColumns = ({
) : (
<ul role="listbox" aria-label="Artifacts">
{artifacts.map((artifact) => (
<li key={artifact.key}>
<li key={artifact.key} role="presentation">
<button
role="option"
aria-selected={selectedArtifactId === artifact.key}
@@ -92,7 +92,7 @@ export const RecordColumns = ({
) : (
<ul role="listbox" aria-label="Deployments">
{deployments.map((deployment) => (
<li key={deployment.id}>
<li key={deployment.id} role="presentation">
<button
role="option"
aria-selected={selectedDeploymentId === deployment.id}
@@ -115,7 +115,7 @@ export const RecordColumns = ({
) : (
<ul role="listbox" aria-label="Runs">
{runs.map((run) => (
<li key={run.runId}>
<li key={run.runId} role="presentation">
<button
role="option"
aria-selected={selectedRunId === run.runId}
@@ -289,6 +289,56 @@ describe("useLifecycleExplorer", () => {
expect(result.current.state.artifactDetail?.version).toBe(2);
});
it.each(["report", "report@abc", "@2", "report@-1", "[email protected]"])(
"does not inspect a malformed artifact key %s",
(artifactKey) => {
const clients = makeClients();
const { result } = renderHook(() => useLifecycleExplorer(clients));
act(() => result.current.selectArtifact(artifactKey));
expect(clients.artifacts.inspect).not.toHaveBeenCalled();
},
);
it("keeps a pending run inspection when a trace page is requested", async () => {
const detail = deferred<RunDetail>();
const clients = makeClients({
runs: {
inspect: vi.fn().mockReturnValue(detail.promise),
trace: vi.fn().mockResolvedValue({
frames: [],
traceStart: 50,
traceLimit: 50,
traceTruncated: false,
}),
},
});
const { result } = renderHook(() => useLifecycleExplorer(clients));
act(() => result.current.selectRun("run_123"));
act(() => result.current.loadTrace(50, 50));
detail.resolve({ ...runDetail, traceCount: 1 });
await act(async () => await detail.promise);
expect(result.current.state.runDetail).toEqual({ ...runDetail, traceCount: 1 });
expect(clients.runs.trace).toHaveBeenCalledTimes(1);
expect(result.current.state.trace?.traceStart).toBe(50);
});
it("refreshes the currently selected run detail", async () => {
const inspect = vi.fn().mockResolvedValue(runDetail);
const clients = makeClients({ runs: { inspect } });
const { result } = renderHook(() => useLifecycleExplorer(clients));
act(() => result.current.selectRun("run_123"));
await waitFor(() => expect(result.current.state.runDetail).toEqual(runDetail));
act(() => result.current.refresh());
await waitFor(() => expect(inspect).toHaveBeenCalledTimes(2));
await waitFor(() => expect(result.current.state.runDetail).toEqual(runDetail));
});
it("rejects a late artifact detail after a null selection", async () => {
const detail = deferred<ArtifactDetail>();
const clients = makeClients({
@@ -33,12 +33,14 @@ export const useLifecycleExplorer = (
const deploymentDetailGenerationRef = useRef(0);
const runListGenerationRef = useRef(0);
const runDetailGenerationRef = useRef(0);
const runTraceGenerationRef = useRef(0);
const committedClientsRef = useRef<LifecycleClients | null | undefined>(undefined);
const invalidateDetailReads = useCallback((): void => {
artifactDetailGenerationRef.current++;
deploymentDetailGenerationRef.current++;
runDetailGenerationRef.current++;
runTraceGenerationRef.current++;
}, []);
const executeRead = useCallback(
@@ -141,8 +143,10 @@ export const useLifecycleExplorer = (
if (!artifactKey || !clients) return;
const generation = artifactDetailGenerationRef.current;
const separator = artifactKey.lastIndexOf("@");
if (separator <= 0 || separator === artifactKey.length - 1) return;
const artifactId = artifactKey.slice(0, separator);
const version = Number(artifactKey.slice(separator + 1));
if (!Number.isSafeInteger(version) || version < 0) return;
void executeRead(
() => clients.artifacts.inspect(artifactId, version),
generation,
@@ -186,6 +190,7 @@ export const useLifecycleExplorer = (
dispatch({ type: "selectRun", runId });
if (!runId || !clients) return;
const generation = runDetailGenerationRef.current;
const traceGeneration = runTraceGenerationRef.current;
const targetGeneration = generationRef.current;
void executeRead(
() => clients.runs.inspect(runId),
@@ -194,11 +199,16 @@ export const useLifecycleExplorer = (
targetGeneration,
(value) => {
dispatch({ type: "setRunDetail", detail: value });
if (value.traceCount > 0) {
// Do not let the automatic first page overwrite a trace page the
// operator requested while run inspection was still pending.
if (
value.traceCount > 0 &&
traceGeneration === runTraceGenerationRef.current
) {
void executeRead(
() => clients.runs.trace(runId, 0, 50),
generation,
runDetailGenerationRef,
traceGeneration,
runTraceGenerationRef,
targetGeneration,
(trace) => dispatch({ type: "setTrace", trace }),
);
@@ -222,7 +232,23 @@ export const useLifecycleExplorer = (
runListGenerationRef.current,
generationRef.current,
);
}, [clients, invalidateDetailReads, startCollectionReads]);
// Refresh owns both collection and selected-detail freshness. Invalidating
// a detail without replacing it would leave the URL pointing at a record
// whose completed response is silently discarded.
if (state.selectedArtifactId) selectArtifact(state.selectedArtifactId);
else if (state.selectedDeploymentId) selectDeployment(state.selectedDeploymentId);
else if (state.selectedRunId) selectRun(state.selectedRunId);
}, [
clients,
invalidateDetailReads,
selectArtifact,
selectDeployment,
selectRun,
startCollectionReads,
state.selectedArtifactId,
state.selectedDeploymentId,
state.selectedRunId,
]);
const loadMoreArtifacts = useCallback((): void => {
const current = state.artifactList;
@@ -255,13 +281,13 @@ export const useLifecycleExplorer = (
const loadTrace = useCallback(
(start: number, limit: number): void => {
if (!state.selectedRunId || !clients) return;
const generation = ++runDetailGenerationRef.current;
const generation = ++runTraceGenerationRef.current;
const targetGeneration = generationRef.current;
const runId = state.selectedRunId;
void executeRead(
() => clients.runs.trace(runId, start, limit),
generation,
runDetailGenerationRef,
runTraceGenerationRef,
targetGeneration,
(value) => dispatch({ type: "setTrace", trace: value }),
);