Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/main/frontend/common/components/status-icon.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,23 @@ describe("StatusIcon", () => {
expect(result.current).to.equal(0);
unmount();
});
it("should continue progress when waitingForInput is true", async () => {
const { result, unmount } = renderHook(() => {
return useStageProgress({
...mockStage,
state: Result.running,
waitingForInput: true,
startTimeMillis: now - 2_000,
previousTotalDurationMillis: 20_000,
});
});
expect(result.current).to.equal(10);
await act(() => vi.advanceTimersByTime(1_000));
expect(result.current).to.equal(15);
await act(() => vi.advanceTimersByTime(1_000));
expect(result.current).to.equal(20);
unmount();
});
});
describe("other states", function () {
for (const state in Result) {
Expand Down
33 changes: 21 additions & 12 deletions src/main/frontend/common/components/status-icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ import {
export function useStageProgress(stage: StageInfo) {
const [percentage, setPercentage] = useState(0);
useEffect(() => {
// If no longer running, immediately reset progress.
if (stage.state !== Result.running) {
// percentage is only needed for the running icon.
setPercentage(0);
return;
}
// Keep updating progress while waitingForInput: the glyph changes to paused but
// the ring should continue to reflect underlying running progress.
const update = () => {
const currentTiming =
stage.totalDurationMillis ?? Date.now() - stage.startTimeMillis;
Expand All @@ -37,6 +39,7 @@ export function StageStatusIcon({ stage }: { stage: StageInfo }) {
return (
<StatusIcon
status={stage.state}
waitingForInput={stage.waitingForInput}
percentage={useStageProgress(stage)}
skeleton={stage.skeleton}
/>
Expand All @@ -48,11 +51,15 @@ export function StageStatusIcon({ stage }: { stage: StageInfo }) {
*/
export default function StatusIcon({
status,
waitingForInput,
percentage,
skeleton,
}: StatusIconProps) {
const viewBoxSize = 512;
const strokeWidth = status === "running" ? 50 : 0;
const iconStatus =
waitingForInput && status === Result.running ? Result.paused : status;
// Keep ring when underlying status is running (even if visually paused)
const strokeWidth = status === Result.running ? 50 : 0;
const radius = (viewBoxSize - strokeWidth) / 2.2;
const circumference = 2 * Math.PI * radius;
const offset = circumference - ((percentage ?? 100) / 100) * circumference;
Expand All @@ -63,7 +70,7 @@ export default function StatusIcon({
className={"pgv-status-icon " + resultToColor(status, skeleton)}
opacity={skeleton ? 0.5 : 1}
role={"img"}
aria-label={status}
aria-label={iconStatus}
>
<circle
cx={viewBoxSize / 2}
Expand Down Expand Up @@ -107,7 +114,7 @@ export default function StatusIcon({
}}
/>

<Group currentStatus={status} status={Result.running}>
<Group currentStatus={iconStatus} status={Result.running}>
<circle
cx="256"
cy="256"
Expand All @@ -117,7 +124,7 @@ export default function StatusIcon({
/>
</Group>

<Group currentStatus={status} status={Result.success}>
<Group currentStatus={iconStatus} status={Result.success}>
<path
d="M336 189L224 323L176 269.4"
fill="transparent"
Expand All @@ -128,7 +135,7 @@ export default function StatusIcon({
/>
</Group>

<Group currentStatus={status} status={Result.failure}>
<Group currentStatus={iconStatus} status={Result.failure}>
<path
fill="none"
stroke="var(--color)"
Expand All @@ -139,7 +146,7 @@ export default function StatusIcon({
/>
</Group>

<Group currentStatus={status} status={Result.aborted}>
<Group currentStatus={iconStatus} status={Result.aborted}>
<path
fill="none"
stroke="var(--color)"
Expand All @@ -150,7 +157,7 @@ export default function StatusIcon({
/>
</Group>

<Group currentStatus={status} status={Result.unstable}>
<Group currentStatus={iconStatus} status={Result.unstable}>
<path
d="M250.26 166.05L256 288l5.73-121.95a5.74 5.74 0 00-5.79-6h0a5.74 5.74 0 00-5.68 6z"
fill="none"
Expand All @@ -162,7 +169,7 @@ export default function StatusIcon({
<ellipse cx="256" cy="350" rx="26" ry="26" fill="var(--color)" />
</Group>

<Group currentStatus={status} status={Result.skipped}>
<Group currentStatus={iconStatus} status={Result.skipped}>
<g transform="scale(0.8)">
<path
fill="none"
Expand All @@ -185,7 +192,7 @@ export default function StatusIcon({
</g>
</Group>

<Group currentStatus={status} status={Result.paused}>
<Group currentStatus={iconStatus} status={Result.paused}>
<path
fill="none"
stroke="var(--color)"
Expand All @@ -196,13 +203,13 @@ export default function StatusIcon({
/>
</Group>

<Group currentStatus={status} status={Result.not_built}>
<Group currentStatus={iconStatus} status={Result.not_built}>
<circle cx="256" cy="256" r="30" fill="var(--color)" />
<circle cx="352" cy="256" r="30" fill="var(--color)" />
<circle cx="160" cy="256" r="30" fill="var(--color)" />
</Group>

<Group currentStatus={status} status={Result.unknown}>
<Group currentStatus={iconStatus} status={Result.unknown}>
<path
d="M200 202.29s.84-17.5 19.57-32.57C230.68 160.77 244 158.18 256 158c10.93-.14 20.69 1.67 26.53 4.45 10 4.76 29.47 16.38 29.47 41.09 0 26-17 37.81-36.37 50.8S251 281.43 251 296"
fill="none"
Expand Down Expand Up @@ -259,6 +266,8 @@ export function resultToColor(result: Result, skeleton: boolean | undefined) {

interface StatusIconProps {
status: Result;
/** True when a running stage is awaiting input; renders paused glyph but keeps running color & progress */
waitingForInput?: boolean;
percentage?: number;
skeleton?: boolean;
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ it("should handle skeleton stage with steps", () => {
startTimeMillis: 123,
totalDurationMillis: undefined,
pauseLiveTotal: false,
waitingForInput: false,
},
]);
expect(stages).to.not.equal(originalStages);
Expand All @@ -36,6 +37,7 @@ it("should handle skeleton stage with started children", () => {
startTimeMillis: 123,
totalDurationMillis: undefined,
pauseLiveTotal: false,
waitingForInput: false,
children: [
{
...mockStage,
Expand All @@ -44,6 +46,7 @@ it("should handle skeleton stage with started children", () => {
startTimeMillis: 123,
totalDurationMillis: undefined,
pauseLiveTotal: false,
waitingForInput: false,
},
],
},
Expand Down Expand Up @@ -84,6 +87,7 @@ it("should handle finished not_built stage", () => {
skeleton: false,
totalDurationMillis: undefined,
pauseLiveTotal: true,
waitingForInput: false,
},
]);
expect(stages).to.not.equal(originalStages);
Expand Down Expand Up @@ -111,6 +115,7 @@ it("should handle finished running stage", () => {
skeleton: false,
totalDurationMillis: undefined,
pauseLiveTotal: true,
waitingForInput: false,
},
]);
expect(stages).to.not.equal(originalStages);
Expand Down Expand Up @@ -149,6 +154,7 @@ it("should handle finished stage with children", () => {
totalDurationMillis: undefined,
skeleton: false,
pauseLiveTotal: true,
waitingForInput: false,
children: [
{
...mockStage,
Expand All @@ -158,6 +164,7 @@ it("should handle finished stage with children", () => {
totalDurationMillis: undefined,
skeleton: false,
pauseLiveTotal: true,
waitingForInput: false,
},
],
},
Expand All @@ -168,6 +175,60 @@ it("should handle finished stage with children", () => {
expect(stages[0].children[0]).to.not.equal(originalChildren[0]);
});

it("should mark running stage waitingForInput when input step present", () => {
const originalStages: StageInfo[] = [
{
...mockStage,
state: Result.running,
startTimeMillis: 42,
skeleton: false,
},
];
const stages = refreshStagesFromSteps(originalStages, [
{
...mockStep,
stageId: "1",
state: Result.running,
inputStep: {
message: "msg",
cancel: "Cancel",
id: "x",
ok: "OK",
parameters: false,
},
},
]);
expect(stages[0].state).to.equal(Result.running);
expect(stages[0].waitingForInput).to.equal(true);
});

it("should bubble waitingForInput if child branch paused", () => {
const originalStages: StageInfo[] = [
{
...mockStage,
state: Result.running,
startTimeMillis: 42,
skeleton: false,
children: [
{
...mockStage,
id: 2,
state: Result.paused,
startTimeMillis: 50,
skeleton: false,
totalDurationMillis: undefined,
children: [],
pauseDurationMillis: 0,
url: "",
},
],
},
];
const stages = refreshStagesFromSteps(originalStages, []);
expect(stages[0].state).to.equal(Result.running);
expect(stages[0].waitingForInput).to.equal(true);
});

const mockStage: StageInfo = {
name: "Build",
state: Result.not_built,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ export function refreshStagesFromSteps(stages: StageInfo[], steps: StepInfo[]) {
totalDurationMillis = undefined;
}
}
// Derived front-end flag: if any step has an inputStep or any child is paused/waiting, mark waitingForInput.
const waitingForInput =
state === Result.running &&
(stageSteps.some((s) => Boolean(s.inputStep)) ||
children.some((c) => c.waitingForInput || c.state === Result.paused));
// Best effort: Pause timer when a stage is expected to have finished to avoid having to decrement the total duration when done (as confirmed by the next run polling).
pauseLiveTotal =
startTimeMillis > 0 &&
Expand All @@ -40,14 +45,16 @@ export function refreshStagesFromSteps(stages: StageInfo[], steps: StepInfo[]) {
Boolean(stage.pauseLiveTotal) !== pauseLiveTotal ||
stage.startTimeMillis !== startTimeMillis ||
stage.state !== state ||
stage.totalDurationMillis !== totalDurationMillis
stage.totalDurationMillis !== totalDurationMillis ||
Boolean(stage.waitingForInput) !== waitingForInput
) {
// Update in-place to avoid frequent re-render, then trigger re-render.
stage.children = children;
stage.pauseLiveTotal = pauseLiveTotal;
stage.startTimeMillis = startTimeMillis;
stage.state = state;
stage.totalDurationMillis = totalDurationMillis;
stage.waitingForInput = waitingForInput;
if (!changed) stages = stages.slice();
changed = true;
stages[idx] = { ...stage };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ export interface StageInfo {

skeleton?: boolean;
pauseLiveTotal?: boolean;
/**
* Front-end derived flag indicating a running stage is awaiting input.
* Used to override the icon to the paused glyph while retaining the running state for filters & color.
*/
waitingForInput?: boolean;
}

interface BaseNodeInfo {
Expand Down
23 changes: 23 additions & 0 deletions src/main/frontend/setupTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,29 @@ import "@testing-library/jest-dom/vitest";

import { vi } from "vitest";

// Ensure a functional localStorage implementation for tests that persist user preferences.
if (
!("localStorage" in window) ||
typeof window.localStorage?.getItem !== "function" ||
typeof window.localStorage?.setItem !== "function"
) {
const store: Record<string, string> = {};
const localStoragePolyfill = {
getItem: (key: string) => (key in store ? store[key] : null),
setItem: (key: string, value: string) => {
store[key] = String(value);
},
removeItem: (key: string) => {
delete store[key];
},
clear: () => {
Object.keys(store).forEach((k) => delete store[k]);
},
};
// @ts-expect-error override for test environment
window.localStorage = localStoragePolyfill;
}

const IntersectionObserverMock = vi.fn(() => ({
disconnect: vi.fn(),
observe: vi.fn(),
Expand Down
Loading