feat: add demo timeline replay controls

This commit is contained in:
lda
2026-07-03 03:08:21 +07:00 Verified
parent 7dd1dc86d8
commit 2ade72f1db
22 changed files with 3817 additions and 596 deletions
+33 -20
View File
@@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { App } from "./App.js";
import { callOperation, connectToServer } from "../connection/api.js";
import type { ConnectResponse, RpcResponse } from "../connection/contracts.js";
import type { RpcResponse } from "../connection/contracts.js";
vi.mock("../connection/api.js", () => ({
connectToServer: vi.fn(),
@@ -13,12 +13,12 @@ vi.mock("../connection/api.js", () => ({
const mockedConnectToServer = vi.mocked(connectToServer);
const mockedCallOperation = vi.mocked(callOperation);
const successfulConnection = (target: string): ConnectResponse => ({
ok: true,
const successfulConnection = (target: string) => ({
ok: true as const,
connection: {
status: "connected",
status: "connected" as const,
target,
serverStatus: "ok",
serverStatus: "ok" as const,
storeRoot: "/tmp/store",
durationMs: 11,
},
@@ -74,9 +74,9 @@ afterEach(() => {
cleanup();
});
const lifecycleOk: RpcResponse = {
ok: true,
operation: "workflow.artifacts.list",
const lifecycleOk = {
ok: true as const,
operation: "workflow.artifacts.list" as const,
label: "List artifacts",
interpreted: { items: [], total: 0, nextCursor: null },
exchange: { request: {}, response: {} },
@@ -102,15 +102,6 @@ describe("App", () => {
it("ignores stale source inventory responses after reconnect", async () => {
const firstSources = deferred<RpcResponse>();
const secondSources = deferred<RpcResponse>();
const lifecycleOk: RpcResponse = {
ok: true,
operation: "workflow.artifacts.list",
label: "List artifacts",
interpreted: { items: [], total: 0, nextCursor: null },
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf artifact list",
durationMs: 5,
};
let latestSourcesDeferred = firstSources;
mockedConnectToServer
.mockResolvedValueOnce(successfulConnection("http://first.example/rpc"))
@@ -143,8 +134,8 @@ describe("App", () => {
mockedCallOperation.mockImplementation((op: string) => {
if (op === "workflow.sources.list") {
return Promise.resolve({
ok: true,
operation: "workflow.sources.list",
ok: true as const,
operation: "workflow.sources.list" as const,
label: "List sources",
interpreted: { sources: [], total: 0, nextCursor: null },
exchange: { request: {}, response: {} },
@@ -154,7 +145,7 @@ describe("App", () => {
}
if (op === "workflow.deployments.inspect") {
return Promise.resolve({
ok: false,
ok: false as const,
error: { code: "rpc_remote_error", message: "not found" },
exchange: { request: {}, response: {} },
});
@@ -170,4 +161,26 @@ describe("App", () => {
});
expect(screen.getByLabelText("lda report workflow demo")).toBeInTheDocument();
});
it("always renders demo panel even without connection", async () => {
mockedConnectToServer.mockRejectedValue(new Error("connection refused"));
render(<App />);
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
await waitFor(() => {
expect(screen.getByLabelText("lda report workflow demo")).toBeInTheDocument();
});
expect(screen.getByRole("button", { name: /start presentation/i })).toBeDisabled();
});
it("shows replay mode button even without connection", async () => {
render(<App />);
expect(screen.getByRole("button", { name: "Replay" })).toBeVisible();
expect(screen.getByRole("button", { name: /start presentation/i })).toBeDisabled();
await userEvent.click(screen.getByRole("button", { name: "Replay" }));
expect(screen.getByRole("button", { name: /start presentation/i })).toBeEnabled();
});
});
+3 -3
View File
@@ -11,7 +11,7 @@ import { SourceInventory } from "../components/SourceInventory.js";
import { LifecycleExplorer } from "../lifecycle/LifecycleExplorer.js";
import { useLifecycleExplorer } from "../lifecycle/useLifecycleExplorer.js";
import { LdaReportDemoPanel } from "../demo/LdaReportDemoPanel.js";
import { useLdaReportDemo } from "../demo/useLdaReportDemo.js";
import { useDemoTimeline } from "../demo/useDemoTimeline.js";
const parseSources = (
data: unknown,
@@ -56,7 +56,7 @@ export const App = () => {
);
const lifecycleController = useLifecycleExplorer(connectedTarget, recordEvidence);
const demoController = useLdaReportDemo(connectedTarget, recordEvidence);
const demoController = useDemoTimeline(connectedTarget, recordEvidence);
const loadSources = useCallback(
async (target: string) => {
@@ -163,7 +163,7 @@ export const App = () => {
onSubmit={onSubmit}
onDraftChange={(value) => dispatch({ type: "draft_changed", value })}
/>
{connectedTarget && <LdaReportDemoPanel controller={demoController} />}
<LdaReportDemoPanel controller={demoController} />
<SourceInventory
sources={state.sources}
loading={state.sourcesLoading}