fix: harden workflow console contracts
This commit is contained in:
@@ -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 }),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user