fix: harden routed lifecycle guards

This commit is contained in:
lda
2026-08-04 23:41:36 +07:00 Verified
parent f14d9f1278
commit d6f8b44001
8 changed files with 507 additions and 127 deletions
@@ -0,0 +1,102 @@
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import type { LifecycleClients } from "../domain/lifecycle-clients.js";
import type { ArtifactDetail, ArtifactList, DeploymentList, RunList } from "../../lifecycle/models.js";
import { LifecycleRoute } from "./LifecycleRoute.js";
const { mockCreateLifecycleClients } = vi.hoisted(() => ({
mockCreateLifecycleClients: vi.fn(),
}));
vi.mock("../context.js", () => ({
useConsoleWorkspace: () => ({
readExecutor: { run: vi.fn() },
}),
}));
vi.mock("../domain/lifecycle-clients.js", () => ({
createLifecycleClients: mockCreateLifecycleClients,
}));
const deferred = <T,>() => {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
};
const artifactList: ArtifactList = { items: [], total: 0, nextCursor: null };
const deploymentList: DeploymentList = { items: [] };
const runList: RunList = { items: [], total: 0, nextCursor: null };
const artifactDetail = {
artifactId: "report",
version: 2,
title: "Report",
kind: "workflow",
description: null,
outcomes: [],
plan: { nodes: [], edges: [] },
requiredCapabilities: [],
workflowDependencies: {},
createdFromCatalogVersion: null,
} satisfies ArtifactDetail;
const renderRoute = () =>
render(
<MemoryRouter initialEntries={["/console/artifacts/report/2"]}>
<Routes>
<Route path="/console/artifacts/:artifactId/:version" element={<LifecycleRoute kind="artifact" />} />
</Routes>
</MemoryRouter>,
);
beforeEach(() => mockCreateLifecycleClients.mockReset());
afterEach(() => cleanup());
describe("LifecycleRoute direct collection loading", () => {
it("settles all collection loads after a direct detail route selects first", async () => {
const artifacts = deferred<ArtifactList>();
const deployments = deferred<DeploymentList>();
const runs = deferred<RunList>();
const clients: LifecycleClients = {
artifacts: {
list: vi.fn().mockReturnValue(artifacts.promise),
inspect: vi.fn().mockResolvedValue(artifactDetail),
},
deployments: {
list: vi.fn().mockReturnValue(deployments.promise),
inspect: vi.fn(),
validate: vi.fn(),
},
runs: {
list: vi.fn().mockReturnValue(runs.promise),
inspect: vi.fn(),
trace: vi.fn(),
},
};
mockCreateLifecycleClients.mockReturnValue(clients);
renderRoute();
await waitFor(() => {
expect(clients.artifacts.list).toHaveBeenCalledWith({ limit: 50 });
expect(clients.deployments.list).toHaveBeenCalledWith();
expect(clients.runs.list).toHaveBeenCalledWith({ limit: 50 });
expect(clients.artifacts.inspect).toHaveBeenCalledWith("report", 2);
});
expect(screen.getAllByRole("status")).toHaveLength(3);
await act(async () => {
artifacts.resolve(artifactList);
deployments.resolve(deploymentList);
runs.resolve(runList);
await Promise.all([artifacts.promise, deployments.promise, runs.promise]);
});
await waitFor(() => {
expect(screen.queryAllByRole("status")).toHaveLength(0);
expect(screen.getByText("Report")).toBeVisible();
});
});
});
@@ -117,6 +117,31 @@ describe("LifecycleRoute", () => {
expect(currentController.selectArtifact).not.toHaveBeenCalled();
});
it("suppresses stale detail synchronously until it matches the URL identity", () => {
const currentController = controller({
...emptyState(),
selectedArtifactId: "old@1",
artifactDetail: {
artifactId: "old",
version: 1,
title: "Old artifact",
kind: "workflow",
description: null,
outcomes: [],
plan: { nodes: [], edges: [] },
requiredCapabilities: [],
workflowDependencies: {},
createdFromCatalogVersion: null,
},
});
mockUseLifecycleExplorer.mockReturnValue(currentController);
renderRoute("/console/artifacts/report/2");
expect(screen.queryByText("Old artifact")).toBeNull();
expect(currentController.selectArtifact).toHaveBeenCalledWith("report@2");
});
it("navigates to the canonical detail URL before selection follows it", async () => {
const currentController = controller({
...emptyState(),
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { LifecycleExplorer } from "../../lifecycle/LifecycleExplorer.js";
import { useLifecycleExplorer } from "../../lifecycle/useLifecycleExplorer.js";
import type { LifecycleState } from "../../lifecycle/state.js";
import { createLifecycleClients } from "../domain/lifecycle-clients.js";
import { useConsoleWorkspace } from "../context.js";
@@ -31,6 +32,46 @@ const decodeRouteParam = (value: string): string => {
}
};
const visibleStateForRoute = (
state: LifecycleState,
kind: LifecycleRouteKind,
artifactIdentity: string | null,
deploymentIdentity: string | null,
runIdentity: string | null,
): LifecycleState => {
const selectedArtifactId = kind === "artifact" ? artifactIdentity : state.selectedArtifactId;
const selectedDeploymentId = kind === "deployment" ? deploymentIdentity : state.selectedDeploymentId;
const selectedRunId = kind === "run" ? runIdentity : state.selectedRunId;
const artifactDetail =
state.artifactDetail &&
selectedArtifactId === `${state.artifactDetail.artifactId}@${state.artifactDetail.version}`
? state.artifactDetail
: null;
const deploymentDetail =
state.deploymentDetail && state.deploymentDetail.id === selectedDeploymentId
? state.deploymentDetail
: null;
const deploymentValidation =
state.deploymentValidation &&
state.deploymentValidation.deploymentId === selectedDeploymentId
? state.deploymentValidation
: null;
const runDetail =
state.runDetail && state.runDetail.runId === selectedRunId ? state.runDetail : null;
return {
...state,
selectedArtifactId,
selectedDeploymentId,
selectedRunId,
artifactDetail,
deploymentDetail,
deploymentValidation,
runDetail,
trace: runDetail ? state.trace : null,
};
};
export const LifecycleRoute = ({ kind }: LifecycleRouteProps) => {
const { readExecutor } = useConsoleWorkspace();
const navigate = useNavigate();
@@ -62,6 +103,16 @@ export const LifecycleRoute = ({ kind }: LifecycleRouteProps) => {
: null;
const runIdentity =
kind === "run" && params.runId ? decodeRouteParam(params.runId) : null;
const visibleState = useMemo(
() => visibleStateForRoute(
state,
kind,
artifactIdentity,
deploymentIdentity,
runIdentity,
),
[artifactIdentity, deploymentIdentity, kind, runIdentity, state],
);
useEffect(() => {
if (kind === "artifact") {
@@ -136,6 +187,7 @@ export const LifecycleRoute = ({ kind }: LifecycleRouteProps) => {
<LifecycleExplorer
controller={{
...controller,
state: visibleState,
selectArtifact: onSelectArtifact,
selectDeployment: onSelectDeployment,
selectRun: onSelectRun,