feat: expose Scene 10 live replay launch
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
|
||||
import type { TimelineAgentController, TimelineAgentMode } from "../demo/agent/timelineAgent.js";
|
||||
import { initialDemoTimelineState } from "../demo/timeline/reducer.js";
|
||||
import type { PresentationTargetHealth } from "./presentation-target-status.js";
|
||||
import { DemoRunLaunchControl } from "./DemoRunLaunchControl.js";
|
||||
|
||||
const target = "http://127.0.0.1:8765/rpc";
|
||||
|
||||
const demoController = (
|
||||
phase: DemoTimelineController["state"]["phase"] = "ready",
|
||||
): DemoTimelineController => ({
|
||||
state: { ...initialDemoTimelineState, phase },
|
||||
inFlight: false,
|
||||
interruptPayload: null,
|
||||
output: null,
|
||||
trace: null,
|
||||
missingDeploymentMessage: null,
|
||||
recordingId: null,
|
||||
canStart: true,
|
||||
setMode: vi.fn(),
|
||||
start: vi.fn(),
|
||||
pause: vi.fn(),
|
||||
play: vi.fn(),
|
||||
next: vi.fn(async () => {}),
|
||||
submitSelectedIssues: vi.fn(async () => {}),
|
||||
requestRevision: vi.fn(async () => {}),
|
||||
restart: vi.fn(),
|
||||
primeReplayToStage: vi.fn(),
|
||||
});
|
||||
|
||||
const timelineAgent = (
|
||||
runPreparedWorkflow: (mode?: TimelineAgentMode) => Promise<void>,
|
||||
overrides: Partial<TimelineAgentController> = {},
|
||||
): TimelineAgentController => ({
|
||||
messages: [],
|
||||
canRun: true,
|
||||
canRunLive: true,
|
||||
runLabel: "Run prepared workflow",
|
||||
runPreparedWorkflow,
|
||||
submitSelectedIssues: vi.fn(async () => {}),
|
||||
requestRevision: vi.fn(async () => {}),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const readyStatus: PresentationTargetHealth = {
|
||||
kind: "ready",
|
||||
target,
|
||||
label: "Live target ready",
|
||||
detail: "127.0.0.1:8765",
|
||||
};
|
||||
|
||||
const replayStatus: PresentationTargetHealth = {
|
||||
kind: "replay",
|
||||
label: "Replay evidence",
|
||||
detail: "reviewed recording",
|
||||
};
|
||||
|
||||
const failedStatus: PresentationTargetHealth = {
|
||||
kind: "failed",
|
||||
target,
|
||||
label: "Replay fallback",
|
||||
detail: "connection refused",
|
||||
};
|
||||
|
||||
const checkingStatus: PresentationTargetHealth = {
|
||||
kind: "checking",
|
||||
target,
|
||||
label: "Live target configured",
|
||||
detail: "checking",
|
||||
};
|
||||
|
||||
const renderControl = (options: {
|
||||
readonly status?: PresentationTargetHealth;
|
||||
readonly liveTargetReady?: boolean;
|
||||
readonly demo?: DemoTimelineController;
|
||||
readonly agent?: TimelineAgentController;
|
||||
} = {}) => render(
|
||||
<DemoRunLaunchControl
|
||||
status={options.status ?? readyStatus}
|
||||
liveTargetReady={options.liveTargetReady ?? true}
|
||||
demo={options.demo ?? demoController()}
|
||||
timelineAgent={options.agent ?? timelineAgent(async () => {})}
|
||||
retryHealth={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe("DemoRunLaunchControl", () => {
|
||||
it("launches live from a healthy target", async () => {
|
||||
const run = vi.fn(async () => {});
|
||||
renderControl({ agent: timelineAgent(run) });
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Run prepared workflow" }));
|
||||
|
||||
expect(run).toHaveBeenCalledWith("live");
|
||||
});
|
||||
|
||||
it("keeps the live action available from a direct replay view", async () => {
|
||||
const run = vi.fn(async () => {});
|
||||
renderControl({
|
||||
status: replayStatus,
|
||||
liveTargetReady: true,
|
||||
agent: timelineAgent(run),
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Run prepared workflow" }));
|
||||
|
||||
expect(run).toHaveBeenCalledWith("live");
|
||||
});
|
||||
|
||||
it("offers explicit replay and retry after health failure", async () => {
|
||||
const run = vi.fn(async () => {});
|
||||
const retry = vi.fn();
|
||||
render(
|
||||
<DemoRunLaunchControl
|
||||
status={failedStatus}
|
||||
liveTargetReady={false}
|
||||
demo={demoController()}
|
||||
timelineAgent={timelineAgent(run)}
|
||||
retryHealth={retry}
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Play replay walkthrough" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Retry live service" }));
|
||||
|
||||
expect(run).toHaveBeenCalledWith("replay");
|
||||
expect(retry).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps the action visible but disabled while health is checking", () => {
|
||||
renderControl({ status: checkingStatus, liveTargetReady: false });
|
||||
|
||||
expect(screen.getByRole("button", { name: "Checking live service" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Retry live service" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("disables duplicate starts while the live timeline is running", () => {
|
||||
renderControl({ status: { ...readyStatus, kind: "active", label: "Live run active", detail: "operations sent" }, demo: demoController("running") });
|
||||
|
||||
expect(screen.getByRole("button", { name: "Live workflow running" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
|
||||
import type { TimelineAgentController } from "../demo/agent/timelineAgent.js";
|
||||
import type { PresentationTargetHealth } from "./presentation-target-status.js";
|
||||
|
||||
export type DemoRunLaunchControlProps = {
|
||||
readonly status: PresentationTargetHealth;
|
||||
readonly liveTargetReady: boolean;
|
||||
readonly demo: DemoTimelineController;
|
||||
readonly timelineAgent: TimelineAgentController | undefined;
|
||||
readonly retryHealth: () => void;
|
||||
};
|
||||
|
||||
export const DemoRunLaunchControl = ({
|
||||
status,
|
||||
liveTargetReady,
|
||||
demo,
|
||||
timelineAgent,
|
||||
retryHealth,
|
||||
}: DemoRunLaunchControlProps) => {
|
||||
if (!timelineAgent) return null;
|
||||
|
||||
const isChecking = status.kind === "checking";
|
||||
const isRunning = demo.inFlight || demo.state.phase === "running" || status.kind === "active";
|
||||
const launchLive = liveTargetReady;
|
||||
const launchLabel = isChecking
|
||||
? "Checking live service"
|
||||
: isRunning && launchLive
|
||||
? "Live workflow running"
|
||||
: launchLive
|
||||
? "Run prepared workflow"
|
||||
: "Play replay walkthrough";
|
||||
const canLaunch = launchLive ? timelineAgent.canRunLive : timelineAgent.canRun;
|
||||
|
||||
return (
|
||||
<section
|
||||
className="demo-run-launch-control"
|
||||
data-target-kind={status.kind}
|
||||
data-launch-mode={launchLive ? "live" : "replay"}
|
||||
aria-label="prepared workflow launch"
|
||||
>
|
||||
<div className="demo-run-launch-control__copy" role="status" aria-live="polite">
|
||||
<span className="demo-run-launch-control__eyebrow">Prepared workflow</span>
|
||||
<strong>{status.label}</strong>
|
||||
<p>{status.detail}</p>
|
||||
</div>
|
||||
<div className="demo-run-launch-control__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="demo-run-launch-control__primary"
|
||||
onClick={() => void timelineAgent.runPreparedWorkflow(launchLive ? "live" : "replay")}
|
||||
disabled={isChecking || isRunning || !canLaunch}
|
||||
>
|
||||
{launchLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="demo-run-launch-control__retry"
|
||||
onClick={retryHealth}
|
||||
disabled={isChecking}
|
||||
>
|
||||
Retry live service
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -3,9 +3,11 @@ import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { loadCanonicalDemoRecording } from "../demo/timeline/replay.js";
|
||||
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
|
||||
import type { TimelineAgentController } from "../demo/agent/timelineAgent.js";
|
||||
import type { DemoApprovalActions } from "./demo-approval-actions.js";
|
||||
import { DemoWorkflowScene } from "./DemoWorkflowScene.js";
|
||||
import { findBeat, findScene } from "./storyboard.js";
|
||||
import type { PresentationTargetHealth } from "./presentation-target-status.js";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
@@ -53,6 +55,10 @@ const renderBeat = (
|
||||
options: {
|
||||
readonly openEvidence?: () => void;
|
||||
readonly approvalActions?: DemoApprovalActions;
|
||||
readonly timelineAgent?: TimelineAgentController;
|
||||
readonly targetStatus?: PresentationTargetHealth;
|
||||
readonly retryHealth?: () => void;
|
||||
readonly liveTargetReady?: boolean;
|
||||
} = {},
|
||||
) => {
|
||||
const openEvidence = options.openEvidence ?? vi.fn();
|
||||
@@ -66,6 +72,10 @@ const renderBeat = (
|
||||
selectNode={noop}
|
||||
openEvidence={openEvidence}
|
||||
approvalActions={options.approvalActions}
|
||||
timelineAgent={options.timelineAgent}
|
||||
targetStatus={options.targetStatus}
|
||||
retryHealth={options.retryHealth}
|
||||
liveTargetReady={options.liveTargetReady}
|
||||
/>,
|
||||
);
|
||||
return { ...rendered, openEvidence };
|
||||
@@ -81,6 +91,38 @@ describe("DemoWorkflowScene", () => {
|
||||
expect(openEvidence).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("exposes live launch only on the Scene 10 operation beat", async () => {
|
||||
const runPreparedWorkflow = vi.fn(async () => {});
|
||||
const targetStatus: PresentationTargetHealth = {
|
||||
kind: "ready",
|
||||
target: "http://127.0.0.1:8765/rpc",
|
||||
label: "Live target ready",
|
||||
detail: "127.0.0.1:8765",
|
||||
};
|
||||
const timelineAgent: TimelineAgentController = {
|
||||
messages: [],
|
||||
canRun: true,
|
||||
canRunLive: true,
|
||||
runLabel: "Run prepared workflow",
|
||||
runPreparedWorkflow,
|
||||
submitSelectedIssues: vi.fn(async () => {}),
|
||||
requestRevision: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
const operation = renderBeat("operation", "run-from-deployment", {
|
||||
timelineAgent,
|
||||
targetStatus,
|
||||
liveTargetReady: true,
|
||||
retryHealth: vi.fn(),
|
||||
});
|
||||
await userEvent.click(screen.getByRole("button", { name: "Run prepared workflow" }));
|
||||
expect(runPreparedWorkflow).toHaveBeenCalledWith("live");
|
||||
operation.unmount();
|
||||
|
||||
renderBeat("graph");
|
||||
expect(screen.queryByRole("region", { name: "prepared workflow launch" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the run receipt visible when the graph takes over", () => {
|
||||
renderBeat("graph");
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import { StageCaption } from "./StageCaption.js";
|
||||
import type { SceneBeatDefinition, SceneDefinition } from "./storyboard.js";
|
||||
import type { PresentationTargetHealth } from "./presentation-target-status.js";
|
||||
import { WorkflowGraphStage } from "./WorkflowGraphStage.js";
|
||||
import { DemoRunLaunchControl } from "./DemoRunLaunchControl.js";
|
||||
|
||||
type DemoWorkflowSceneProps = {
|
||||
readonly scene: SceneDefinition;
|
||||
@@ -123,6 +124,15 @@ export const DemoWorkflowScene = ({
|
||||
data-support-surface={surface.supportSurface}
|
||||
aria-label="demo workflow stage"
|
||||
>
|
||||
{beat.id === "operation" && timelineAgent && targetStatus && retryHealth ? (
|
||||
<DemoRunLaunchControl
|
||||
status={targetStatus}
|
||||
liveTargetReady={liveTargetReady ?? false}
|
||||
demo={demo}
|
||||
timelineAgent={timelineAgent}
|
||||
retryHealth={retryHealth}
|
||||
/>
|
||||
) : null}
|
||||
{isGuidedDemoMoment ? (
|
||||
<GuidedProductMoment
|
||||
beat={beat}
|
||||
|
||||
@@ -88,6 +88,90 @@
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.demo-run-launch-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.7rem;
|
||||
border: 1px solid color-mix(in oklch, var(--accent-cyan) 32%, var(--stage-line));
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.7rem 0.85rem;
|
||||
background: color-mix(in oklch, var(--stage-surface) 84%, var(--accent-cyan));
|
||||
}
|
||||
|
||||
.demo-run-launch-control__copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.demo-run-launch-control__eyebrow {
|
||||
display: block;
|
||||
margin-bottom: 0.15rem;
|
||||
color: var(--accent-cyan);
|
||||
font: 650 0.58rem/1 var(--font-mono, monospace);
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.demo-run-launch-control__copy strong {
|
||||
display: block;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.demo-run-launch-control__copy p {
|
||||
margin: 0.15rem 0 0;
|
||||
overflow: hidden;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.demo-run-launch-control__actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.demo-run-launch-control__actions button {
|
||||
border: 1px solid var(--stage-line);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem 0.7rem;
|
||||
color: var(--text-primary);
|
||||
font: 650 0.68rem/1 var(--font-mono, monospace);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.demo-run-launch-control__primary {
|
||||
background: var(--accent-cyan);
|
||||
color: oklch(0.12 0.03 250) !important;
|
||||
}
|
||||
|
||||
.demo-run-launch-control__retry {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.demo-run-launch-control__actions button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
@container presentation-canvas (max-width: 760px) {
|
||||
.demo-run-launch-control {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.demo-run-launch-control__actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.demo-run-launch-control__actions button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Operation handoff: a single large receipt, not a stack of dashboard cards. */
|
||||
.presentation-route .operation-block--expanded {
|
||||
position: relative;
|
||||
|
||||
Reference in New Issue
Block a user