fix: harden routed lifecycle guards
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user