diff --git a/package.json b/package.json index 07d4c12a..4a693a32 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "@storybook/design-system": "^7.15.15", "chromatic": "^7.1.0", "date-fns": "^2.30.0", + "filesize": "^10.0.12", "pluralize": "^8.0.0", "ts-dedent": "^2.2.0", "urql": "^4.0.3", diff --git a/src/Panel.tsx b/src/Panel.tsx index 1f7f2327..f337fe5d 100644 --- a/src/Panel.tsx +++ b/src/Panel.tsx @@ -4,22 +4,13 @@ import { useChannel, useStorybookState } from "@storybook/manager-api"; import React, { useCallback } from "react"; import { Sections } from "./components/layout"; -import { - ADDON_ID, - GIT_INFO, - GitInfoPayload, - IS_OUTDATED, - PANEL_ID, - RUNNING_BUILD, - RunningBuildPayload, - START_BUILD, -} from "./constants"; +import { ADDON_ID, GIT_INFO, IS_OUTDATED, PANEL_ID, RUNNING_BUILD, START_BUILD } from "./constants"; import { Authentication } from "./screens/Authentication/Authentication"; import { LinkedProject } from "./screens/LinkProject/LinkedProject"; import { LinkingProjectFailed } from "./screens/LinkProject/LinkingProjectFailed"; import { LinkProject } from "./screens/LinkProject/LinkProject"; import { VisualTests } from "./screens/VisualTests/VisualTests"; -import { UpdateStatusFunction } from "./types"; +import { GitInfoPayload, RunningBuildPayload, UpdateStatusFunction } from "./types"; import { useAddonState } from "./useAddonState/manager"; import { client, Provider, useAccessToken } from "./utils/graphQLClient"; import { useProjectId } from "./utils/useProjectId"; diff --git a/src/SidebarTop.tsx b/src/SidebarTop.tsx index eace73dd..2191fd46 100644 --- a/src/SidebarTop.tsx +++ b/src/SidebarTop.tsx @@ -4,13 +4,8 @@ import pluralize from "pluralize"; import React, { useEffect, useRef } from "react"; import { SidebarTopButton } from "./components/SidebarTopButton"; -import { - ADDON_ID, - IS_OUTDATED, - RUNNING_BUILD, - RunningBuildPayload, - START_BUILD, -} from "./constants"; +import { ADDON_ID, IS_OUTDATED, RUNNING_BUILD, START_BUILD } from "./constants"; +import { RunningBuildPayload } from "./types"; import { useAddonState } from "./useAddonState/manager"; import { useAccessToken } from "./utils/graphQLClient"; import { useProjectId } from "./utils/useProjectId"; @@ -28,14 +23,14 @@ export const SidebarTop = ({ api }: SidebarTopProps) => { const [isOutdated] = useAddonState(IS_OUTDATED); const [runningBuild] = useAddonState(RUNNING_BUILD); - const isRunning = !!runningBuild && runningBuild.step !== "complete"; + const isRunning = !!runningBuild && runningBuild.currentStep !== "complete"; - const lastStep = useRef(runningBuild?.step); + const lastStep = useRef(runningBuild?.currentStep); useEffect(() => { - if (runningBuild?.step === lastStep.current) return; - lastStep.current = runningBuild?.step; + if (runningBuild?.currentStep === lastStep.current) return; + lastStep.current = runningBuild?.currentStep; - if (runningBuild?.step === "initialize") { + if (runningBuild?.currentStep === "initialize") { addNotification({ id: `${ADDON_ID}/build-initialize`, content: { @@ -51,7 +46,7 @@ export const SidebarTop = ({ api }: SidebarTopProps) => { setTimeout(() => clearNotification(`${ADDON_ID}/build-initialize`), 10_000); } - if (runningBuild?.step === "complete") { + if (runningBuild?.currentStep === "complete") { addNotification({ id: `${ADDON_ID}/build-complete`, content: { @@ -72,7 +67,7 @@ export const SidebarTop = ({ api }: SidebarTopProps) => { setTimeout(() => clearNotification(`${ADDON_ID}/build-complete`), 10_000); } - if (runningBuild?.step === "error") { + if (runningBuild?.currentStep === "error") { addNotification({ id: `${ADDON_ID}/build-error`, content: { @@ -89,7 +84,7 @@ export const SidebarTop = ({ api }: SidebarTopProps) => { }, [ addNotification, clearNotification, - runningBuild?.step, + runningBuild?.currentStep, runningBuild?.errorCount, runningBuild?.changeCount, ]); diff --git a/src/buildSteps.ts b/src/buildSteps.ts new file mode 100644 index 00000000..638a77b5 --- /dev/null +++ b/src/buildSteps.ts @@ -0,0 +1,118 @@ +// eslint-disable-next-line import/no-unresolved +import { TaskName } from "chromatic/node"; +import { filesize } from "filesize"; + +import { KnownStep, RunningBuildPayload, StepProgressPayload } from "./types"; + +export const isKnownStep = ( + taskOrStep: TaskName | RunningBuildPayload["currentStep"] +): taskOrStep is KnownStep => BUILD_STEP_ORDER.includes(taskOrStep as KnownStep); + +export const hasProgressEvent = (task: TaskName) => ["upload", "snapshot"].includes(task); + +// Note this does not include the "complete" and "error" steps +export const BUILD_STEP_ORDER: KnownStep[] = [ + "initialize", + "build", + "upload", + "verify", + "snapshot", +]; + +export const BUILD_STEP_CONFIG: Record< + RunningBuildPayload["currentStep"], + { + key: RunningBuildPayload["currentStep"]; + emoji: string; + renderName: () => string; + renderProgress: (payload: RunningBuildPayload) => string; + renderComplete: () => string; + estimateDuration: number; + } +> = { + initialize: { + key: "initialize", + emoji: "🚀", + renderName: () => `Initialize build`, + renderProgress: () => `Initializing build`, + renderComplete: () => `Initialized`, + estimateDuration: 2000, + }, + build: { + key: "build", + emoji: "🏗", + renderName: () => `Build Storybook`, + renderProgress: () => `Building your Storybook...`, + renderComplete: () => `Storybook built`, + estimateDuration: 30_000, + }, + upload: { + key: "upload", + emoji: "📡", + renderName: () => `Publish your Storybook`, + renderProgress: ({ stepProgress }) => { + const { numerator, denominator } = stepProgress.upload; + if (!denominator) return `Uploading files`; + const { value: total, exponent } = filesize(denominator, { + output: "object", + round: 1, + }); + const { value: progress, symbol } = filesize(numerator, { + exponent, + output: "object", + round: 1, + }); + return `Uploading files (${progress}/${total} ${symbol})`; + }, + renderComplete: () => `Publish complete`, + estimateDuration: 30_000, + }, + verify: { + key: "verify", + emoji: "🔍", + renderName: () => `Verify your Storybook`, + renderProgress: () => `Verifying contents...`, + renderComplete: () => `Storybook verified`, + estimateDuration: 10_000, + }, + snapshot: { + key: "snapshot", + emoji: "📸", + renderName: () => `Run visual tests`, + renderProgress: ({ stepProgress }) => { + const { numerator, denominator } = stepProgress.snapshot; + return denominator + ? `Running visual tests (${numerator}/${denominator})` + : `Running visual tests`; + }, + renderComplete: () => `Tested your stories`, + estimateDuration: 60_000, + }, + + // These are special steps that are not part of the build process + complete: { + key: "complete", + emoji: "🎉", + renderName: () => `Visual tests completed!`, + renderProgress: () => `Visual tests completed!`, + renderComplete: () => `Visual tests completed!`, + estimateDuration: 0, + }, + error: { + key: "error", + emoji: "🚨", + renderName: () => `Build failed`, + renderProgress: () => `Build failed`, + renderComplete: () => `Build failed`, + estimateDuration: 0, + }, +}; + +export const INITIAL_BUILD_PAYLOAD = { + buildProgressPercentage: 0, + currentStep: BUILD_STEP_ORDER[0], + stepProgress: Object.fromEntries(BUILD_STEP_ORDER.map((step) => [step, {}])) as Record< + KnownStep, + StepProgressPayload + >, +}; diff --git a/src/components/BuildProgressLabel.stories.tsx b/src/components/BuildProgressLabel.stories.tsx new file mode 100644 index 00000000..842d2e87 --- /dev/null +++ b/src/components/BuildProgressLabel.stories.tsx @@ -0,0 +1,102 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { INITIAL_BUILD_PAYLOAD } from "../buildSteps"; +import { withFigmaDesign } from "../utils/withFigmaDesign"; +import { BuildProgressLabel } from "./BuildProgressLabel"; + +const meta = { + component: BuildProgressLabel, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Initialize: Story = { + args: { + runningBuild: INITIAL_BUILD_PAYLOAD, + }, + parameters: withFigmaDesign( + "https://www.figma.com/file/GFEbCgCVDtbZhngULbw2gP/Visual-testing-in-Storybook?type=design&node-id=2892-73423&mode=design&t=gIM40WT0324ynPQD-4" + ), +}; + +export const Build: Story = { + args: { + runningBuild: { + ...INITIAL_BUILD_PAYLOAD, + buildProgressPercentage: 8, + currentStep: "build", + }, + }, + parameters: withFigmaDesign( + "https://www.figma.com/file/GFEbCgCVDtbZhngULbw2gP/Visual-testing-in-Storybook?type=design&node-id=2892-73453&mode=design&t=gIM40WT0324ynPQD-4" + ), +}; + +export const Upload: Story = { + args: { + runningBuild: { + ...INITIAL_BUILD_PAYLOAD, + buildProgressPercentage: 50, + currentStep: "upload", + stepProgress: { + ...INITIAL_BUILD_PAYLOAD.stepProgress, + upload: { + startedAt: Date.now() - 3000, + numerator: 4_200_000, + denominator: 123_000_000, + }, + }, + }, + }, + parameters: withFigmaDesign( + "https://www.figma.com/file/GFEbCgCVDtbZhngULbw2gP/Visual-testing-in-Storybook?type=design&node-id=2935-71430&mode=design&t=gIM40WT0324ynPQD-4" + ), +}; + +export const Verify: Story = { + args: { + runningBuild: { + ...INITIAL_BUILD_PAYLOAD, + buildProgressPercentage: 75, + currentStep: "verify", + }, + }, + parameters: withFigmaDesign( + "https://www.figma.com/file/GFEbCgCVDtbZhngULbw2gP/Visual-testing-in-Storybook?type=design&node-id=2935-72020&mode=design&t=gIM40WT0324ynPQD-4" + ), +}; + +export const Snapshot: Story = { + args: { + runningBuild: { + ...INITIAL_BUILD_PAYLOAD, + buildProgressPercentage: 90, + currentStep: "snapshot", + stepProgress: { + ...INITIAL_BUILD_PAYLOAD.stepProgress, + snapshot: { + startedAt: Date.now() - 5000, + numerator: 25, + denominator: 50, + }, + }, + }, + }, + parameters: withFigmaDesign( + "https://www.figma.com/file/GFEbCgCVDtbZhngULbw2gP/Visual-testing-in-Storybook?type=design&node-id=2892-74603&mode=design&t=gIM40WT0324ynPQD-4" + ), +}; + +export const Complete: Story = { + args: { + runningBuild: { + ...INITIAL_BUILD_PAYLOAD, + currentStep: "complete", + buildProgressPercentage: 100, + }, + }, + parameters: withFigmaDesign( + "https://www.figma.com/file/GFEbCgCVDtbZhngULbw2gP/Visual-testing-in-Storybook?type=design&node-id=2892-74801&mode=design&t=gIM40WT0324ynPQD-4" + ), +}; diff --git a/src/components/BuildProgressLabel.tsx b/src/components/BuildProgressLabel.tsx index 6dc48d6e..3cc3771f 100644 --- a/src/components/BuildProgressLabel.tsx +++ b/src/components/BuildProgressLabel.tsx @@ -1,21 +1,17 @@ import React from "react"; -import { RunningBuildPayload } from "../constants"; - -const messageMap: Record string> = { - initialize: () => `📦 Validating Storybook files...`, - build: () => `📦 Validating Storybook files...`, - upload: () => `📡 Uploading to Chromatic...`, // TODO represent progress in bytes - verify: () => `🛠️ Initiating build...`, // TODO build number - snapshot: () => `👀 Running visual tests...`, // TODO count - complete: () => `🎉 Visual tests completed!`, - error: () => `❌ Build failed`, // TODO error -}; +import { BUILD_STEP_CONFIG } from "../buildSteps"; +import { RunningBuildPayload } from "../types"; interface BuildProgressLabelProps { runningBuild: RunningBuildPayload; } -export const BuildProgressLabel = ({ runningBuild }: BuildProgressLabelProps) => ( - <>{messageMap[runningBuild.step](runningBuild)} -); +export const BuildProgressLabel = ({ runningBuild }: BuildProgressLabelProps) => { + const { emoji, renderProgress } = BUILD_STEP_CONFIG[runningBuild.currentStep]; + return ( + <> + {emoji} {renderProgress(runningBuild)} + + ); +}; diff --git a/src/components/RunTestsButton.stories.tsx b/src/components/RunTestsButton.stories.tsx deleted file mode 100644 index 4f8e9b56..00000000 --- a/src/components/RunTestsButton.stories.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { action } from "@storybook/addon-actions"; -import type { Meta, StoryObj } from "@storybook/react"; - -import { RunTestsButton } from "./RunTestsButton"; - -const meta = { - component: RunTestsButton, - args: { - isStarting: false, - projectId: "projectId", - isLoggedIn: true, - startBuild: action("startBuild"), - }, -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = {}; -export const IsStarting: Story = { - args: { isStarting: true }, -}; -export const NoUser: Story = { - args: { isLoggedIn: false }, -}; -export const NoProject: Story = { - args: { projectId: undefined }, -}; diff --git a/src/components/RunTestsButton.tsx b/src/components/RunTestsButton.tsx deleted file mode 100644 index e1889e26..00000000 --- a/src/components/RunTestsButton.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Icons } from "@storybook/components"; -import React from "react"; - -import { IconButton } from "./IconButton"; -import { ProgressIcon } from "./icons/ProgressIcon"; - -export function RunTestsButton({ - isStarting, - projectId, - isLoggedIn, - startBuild, -}: { - isStarting: boolean; - projectId: string; - isLoggedIn: boolean; - startBuild: () => void; -}) { - return projectId && isLoggedIn ? ( - startBuild()} - > - {isStarting ? ( - - ) : ( - - )} - Run tests - - ) : null; -} diff --git a/src/components/SidebarTopButton.stories.ts b/src/components/SidebarTopButton.stories.ts index 707e4b56..90eb7e6b 100644 --- a/src/components/SidebarTopButton.stories.ts +++ b/src/components/SidebarTopButton.stories.ts @@ -2,6 +2,7 @@ import { action } from "@storybook/addon-actions"; import type { Meta, StoryObj } from "@storybook/react"; import { userEvent, within } from "@storybook/testing-library"; +import { INITIAL_BUILD_PAYLOAD } from "../buildSteps"; import { playAll } from "../utils/playAll"; import { SidebarTopButton } from "./SidebarTopButton"; @@ -33,8 +34,9 @@ export const IsRunning: Story = { args: { isRunning: true, runningBuild: { - step: "build", + ...INITIAL_BUILD_PAYLOAD, buildProgressPercentage: 40, + currentStep: "build", }, }, play: playAll(async ({ canvasElement }) => { diff --git a/src/components/SidebarTopButton.tsx b/src/components/SidebarTopButton.tsx index 93a2bd5e..9977abcf 100644 --- a/src/components/SidebarTopButton.tsx +++ b/src/components/SidebarTopButton.tsx @@ -2,13 +2,13 @@ import { Icons, WithTooltip } from "@storybook/components"; import { styled } from "@storybook/theming"; import React, { ComponentProps } from "react"; -import { RunningBuildPayload } from "../constants"; +import { RunningBuildPayload } from "../types"; import { BuildProgressLabel } from "./BuildProgressLabel"; import { IconButton } from "./IconButton"; import { StatusDotWrapper } from "./StatusDot"; export const TooltipContent = styled.div(({ theme }) => ({ - width: 200, + width: 220, padding: 3, color: theme.color.defaultText, @@ -25,6 +25,7 @@ export const ProgressTrack = styled.div(({ theme }) => ({ export const ProgressBar = styled(ProgressTrack)(({ theme }) => ({ background: theme.color.secondary, + transition: "width 3s ease-out", })); export const ProgressCircle = styled.svg(({ theme }) => ({ diff --git a/src/constants.ts b/src/constants.ts index 7154435f..f340bcb5 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,5 +1,3 @@ -import type { GitInfo, TaskName } from "chromatic/node"; - export const { CHROMATIC_INDEX_URL, CHROMATIC_BASE_URL = CHROMATIC_INDEX_URL || "https://www.chromatic.com", @@ -15,52 +13,7 @@ export const ACCESS_TOKEN_KEY = `${ADDON_ID}/access-token/${CHROMATIC_BASE_URL}` export const DEV_BUILD_ID_KEY = `${ADDON_ID}/dev-build-id`; export const GIT_INFO = `${ADDON_ID}/gitInfo`; -export type GitInfoPayload = Omit; - export const PROJECT_INFO = `${ADDON_ID}/projectInfo`; -export type ProjectInfoPayload = { - projectId?: string; - projectToken?: string; - written?: boolean; - configDir?: string; - mainPath?: string; -}; - -// The CLI may have other steps that we don't respond to, so we just ignore updates -// to those steps and focus on the ones we know. -type KnownStep = Extract; -export const knownSteps = ["initialize", "build", "upload", "verify", "snapshot"]; -export const isKnownStep = (task: TaskName): task is KnownStep => knownSteps.includes(task); - export const IS_OUTDATED = `${ADDON_ID}/isOutdated`; export const START_BUILD = `${ADDON_ID}/startBuild`; export const RUNNING_BUILD = `${ADDON_ID}/runningBuild`; -export type RunningBuildPayload = { - /** The id of the build, available after the initialize step */ - buildId?: string; - - /** Overall percentage of build progress */ - buildProgressPercentage?: number; - - // Possibly this should be a type exported by the CLI -- these correspond to tasks - /** The step of the build process we have reached */ - step: KnownStep | "error" | "complete"; - - /** Current task progress value (e.g. bytes or snapshots) */ - stepProgressValue?: number; - - /** Current task progress total (e.g. bytes or snapshots) */ - stepProgressTotal?: number; - - /** Number of visual changes detected */ - changeCount?: number; - - /** Number of component errors detected */ - errorCount?: number; - - /** The error message formatted to display in CLI */ - formattedError?: string; - - /** The original error without formatting */ - originalError?: Error | Error[]; -}; diff --git a/src/index.ts b/src/index.ts index 028b1522..c05ba222 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,21 +1,18 @@ /* eslint-disable no-console */ import type { Channel } from "@storybook/channels"; // eslint-disable-next-line import/no-unresolved -import { Context, getGitInfo, GitInfo, run, TaskName } from "chromatic/node"; +import { getGitInfo, GitInfo } from "chromatic/node"; import { basename, relative } from "path"; import { CHROMATIC_BASE_URL, GIT_INFO, - GitInfoPayload, - isKnownStep, - knownSteps, PROJECT_INFO, - ProjectInfoPayload, RUNNING_BUILD, - RunningBuildPayload, START_BUILD, } from "./constants"; +import { runChromaticBuild } from "./runChromaticBuild"; +import { GitInfoPayload, ProjectInfoPayload, RunningBuildPayload } from "./types"; import { useAddonState } from "./useAddonState/server"; import { findConfig } from "./utils/storybook.config.utils"; import { updateMain } from "./utils/updateMain"; @@ -105,69 +102,10 @@ async function serverChannel( // eslint-disable-next-line react-hooks/rules-of-hooks const runningBuildState = useAddonState(channel, RUNNING_BUILD); - channel.on(START_BUILD, async () => { - if (!projectInfoState.value.projectToken) throw new Error("No project token set"); - - const onStartOrProgress = ( - ctx: Context, - { progress, total }: { progress?: number; total?: number } = {} - ) => { - if (isKnownStep(ctx.task)) { - let buildProgressPercentage = (knownSteps.indexOf(ctx.task) / knownSteps.length) * 100; - if (progress && total) { - buildProgressPercentage += (progress / total) * (100 / knownSteps.length); - } - runningBuildState.value = { - buildId: ctx.announcedBuild?.id, - buildProgressPercentage, - step: ctx.task, - stepProgressValue: progress, - stepProgressTotal: total, - }; - } - }; - runningBuildState.value = { step: "initialize" }; - await run({ - // Currently we have to have these flags. - // We should move the checks to after flags have been parsed into options. - flags: { - projectToken: projectInfoState.value.projectToken, - buildScriptName, - debug, - zip, - }, - options: { - // We might want to drop this later and instead record "uncommitted hashes" on builds - forceRebuild: true, - // Builds initiated from the addon are always considered local - isLocalBuild: true, - experimental_onTaskStart: onStartOrProgress, - experimental_onTaskProgress: onStartOrProgress, - experimental_onTaskComplete(ctx) { - if (ctx.task === "snapshot") { - runningBuildState.value = { - buildId: ctx.announcedBuild?.id, - buildProgressPercentage: 100, - step: "complete", - changeCount: ctx.build.changeCount, - errorCount: ctx.build.errorCount, - }; - } - }, - experimental_onTaskError(ctx, { formattedError, originalError }) { - runningBuildState.value = { - buildId: ctx.announcedBuild?.id, - buildProgressPercentage: - runningBuildState.value.buildProgressPercentage ?? - knownSteps.indexOf(ctx.task) / knownSteps.length, - step: "error", - formattedError, - originalError, - }; - }, - }, - }); + channel.on(START_BUILD, async () => { + const { projectToken } = projectInfoState.value; + await runChromaticBuild(runningBuildState, { projectToken, buildScriptName, debug, zip }); }); // eslint-disable-next-line react-hooks/rules-of-hooks diff --git a/src/runChromaticBuild.test.ts b/src/runChromaticBuild.test.ts new file mode 100644 index 00000000..23496576 --- /dev/null +++ b/src/runChromaticBuild.test.ts @@ -0,0 +1,137 @@ +import { INITIAL_BUILD_PAYLOAD } from "./buildSteps"; +import { onCompleteOrError, onStartOrProgress, runChromaticBuild } from "./runChromaticBuild"; + +jest.mock("chromatic/node", () => ({ run: jest.fn() })); + +const store = { + get value() { + return this.state; + }, + set value(newValue) { + this.state = newValue; + }, + on() {}, +}; + +describe("runChromaticBuild", () => { + it("requires project token", async () => { + await expect(runChromaticBuild(store, {})).rejects.toThrow("No project token set"); + }); + + it("sets initial build payload", async () => { + await runChromaticBuild(store, { projectToken: "token" }); + + expect(store.value).toMatchObject({ + buildProgressPercentage: 0, + currentStep: "initialize", + }); + }); +}); + +describe("onStartOrProgress", () => { + beforeEach(() => { + store.value = INITIAL_BUILD_PAYLOAD; + }); + + it("sets build and step progress percentage at each step", async () => { + expect(store.value).toMatchObject({ + buildProgressPercentage: 0, + currentStep: "initialize", + }); + + onStartOrProgress(store, null)({ task: "build" } as any); + expect(store.value).toMatchObject({ + buildProgressPercentage: expect.closeTo(3, 0), + currentStep: "build", + stepProgress: { build: { startedAt: expect.any(Number) } }, + }); + + onStartOrProgress(store, null)({ task: "upload" } as any); + expect(store.value).toMatchObject({ + buildProgressPercentage: expect.closeTo(24, 0), + currentStep: "upload", + stepProgress: { upload: { startedAt: expect.any(Number) } }, + }); + + onStartOrProgress(store, null)({ task: "verify" } as any); + expect(store.value).toMatchObject({ + buildProgressPercentage: expect.closeTo(48, 0), + currentStep: "verify", + stepProgress: { verify: { startedAt: expect.any(Number) } }, + }); + + onStartOrProgress(store, null)({ task: "snapshot" } as any); + expect(store.value).toMatchObject({ + buildProgressPercentage: expect.closeTo(55, 0), + currentStep: "snapshot", + stepProgress: { snapshot: { startedAt: expect.any(Number) } }, + }); + }); + + it("updates progress with each invocation", () => { + onStartOrProgress(store, null)({ task: "verify" } as any); + expect(store.value.buildProgressPercentage).toBeCloseTo(48, 0); + + onStartOrProgress(store, null)({ task: "verify" } as any); + expect(store.value.buildProgressPercentage).toBeCloseTo(50, 0); + + onStartOrProgress(store, null)({ task: "verify" } as any); + expect(store.value.buildProgressPercentage).toBeCloseTo(52, 0); + }); + + it("can never exceed progress for a step beyond the next step", () => { + for (let n = 10; n; n -= 1) onStartOrProgress(store, null)({ task: "verify" } as any); + expect(store.value.buildProgressPercentage).toBeCloseTo(55, 0); + + for (let n = 10; n; n -= 1) onStartOrProgress(store, null)({ task: "verify" } as any); + expect(store.value.buildProgressPercentage).toBeCloseTo(55, 0); + + onStartOrProgress(store, null)({ task: "snapshot" } as any); + expect(store.value.buildProgressPercentage).toBeCloseTo(55, 0); + }); + + it('updates build progress based on "progress" and "total" values', () => { + onStartOrProgress(store, null)({ task: "snapshot" } as any); + expect(store.value.buildProgressPercentage).toBeCloseTo(55, 0); + + onStartOrProgress(store, null)({ task: "snapshot" } as any, { progress: 1, total: 2 }); + expect(store.value.stepProgress.snapshot).toMatchObject({ numerator: 1, denominator: 2 }); + expect(store.value.buildProgressPercentage).toBeCloseTo(77, 0); + + onStartOrProgress(store, null)({ task: "snapshot" } as any, { progress: 2, total: 2 }); + expect(store.value.stepProgress.snapshot).toMatchObject({ numerator: 2, denominator: 2 }); + expect(store.value.buildProgressPercentage).toBeCloseTo(100, 0); + }); +}); + +describe("onCompleteOrError", () => { + beforeEach(() => { + store.value = INITIAL_BUILD_PAYLOAD; + }); + + it("sets build progress to 100% on completion of final step", () => { + const build = { changeCount: 1, errorCount: 2 }; + onCompleteOrError(store, null)({ task: "snapshot", build } as any); + expect(store.value).toMatchObject({ + buildProgressPercentage: 100, + currentStep: "complete", + stepProgress: { snapshot: { completedAt: expect.any(Number) } }, + changeCount: 1, + errorCount: 2, + }); + }); + + it("does not set build progress to 100% on error", () => { + onStartOrProgress(store, null)({ task: "snapshot" } as any); + expect(store.value.buildProgressPercentage).toBeCloseTo(55, 0); + + const error = { formattedError: "Oops!", originalError: new Error("Oops!") }; + onCompleteOrError(store, null)({ task: "snapshot" } as any, error); + expect(store.value).toMatchObject({ + buildProgressPercentage: expect.closeTo(55, 0), + currentStep: "error", + stepProgress: { snapshot: { completedAt: expect.any(Number) } }, + ...error, + }); + }); +}); diff --git a/src/runChromaticBuild.ts b/src/runChromaticBuild.ts new file mode 100644 index 00000000..6b464aa6 --- /dev/null +++ b/src/runChromaticBuild.ts @@ -0,0 +1,170 @@ +/* eslint-disable no-param-reassign */ +// eslint-disable-next-line import/no-unresolved +import { Context, Flags, run, TaskName } from "chromatic/node"; + +import { + BUILD_STEP_CONFIG, + BUILD_STEP_ORDER, + hasProgressEvent, + INITIAL_BUILD_PAYLOAD, + isKnownStep, +} from "./buildSteps"; +import { RunningBuildPayload } from "./types"; +import { useAddonState } from "./useAddonState/server"; + +const ESTIMATED_PROGRESS_INTERVAL = 2000; + +const getBuildStepData = ( + task: TaskName, + previousBuildProgress?: RunningBuildPayload["previousBuildProgress"] +) => { + if (!isKnownStep(task)) throw new Error(`Unknown step: ${task}`); + + const stepDurations = BUILD_STEP_ORDER.map((step) => { + const { startedAt, completedAt } = previousBuildProgress?.[step] || {}; + return startedAt && completedAt + ? completedAt - startedAt + : BUILD_STEP_CONFIG[step].estimateDuration; + }); + const totalDuration = stepDurations.reduce((sum, duration) => sum + duration, 0); + + const stepIndex = BUILD_STEP_ORDER.indexOf(task); + const startTime = stepDurations.slice(0, stepIndex).reduce((sum, duration) => sum + duration, 0); + const endTime = startTime + stepDurations[stepIndex]; + + const startPercentage = (startTime / totalDuration) * 100; + const endPercentage = (endTime / totalDuration) * 100; + return { + ...BUILD_STEP_CONFIG[task], + startPercentage, + endPercentage, + stepPercentage: endPercentage - startPercentage, + }; +}; + +export const onStartOrProgress = + ( + runningBuildState: ReturnType>, + timeout: ReturnType + ) => + ({ ...ctx }: Context, { progress, total }: { progress?: number; total?: number } = {}) => { + clearTimeout(timeout); + + if (!isKnownStep(ctx.task)) return; + + const { buildProgressPercentage, stepProgress, previousBuildProgress } = + runningBuildState.value; + + const { startPercentage, endPercentage, stepPercentage } = getBuildStepData( + ctx.task, + previousBuildProgress + ); + + let newPercentage = startPercentage; + if (progress && total) { + newPercentage += stepPercentage * (progress / total); + } + + // If the step doesn't have a progress event, simulate one by synthetically updating progress + if (!hasProgressEvent(ctx.task)) { + const { estimateDuration } = BUILD_STEP_CONFIG[ctx.task]; + const stepIndex = BUILD_STEP_ORDER.indexOf(ctx.task); + newPercentage = + Math.max(newPercentage, buildProgressPercentage) + + (ESTIMATED_PROGRESS_INTERVAL / estimateDuration) * stepPercentage; + + timeout = setTimeout(() => { + // Intentionally reference the present value here (after timeout) + const { currentStep } = runningBuildState.value; + // Only update if we haven't moved on to a later step + if (isKnownStep(currentStep) && BUILD_STEP_ORDER.indexOf(currentStep) <= stepIndex) { + onStartOrProgress(runningBuildState, timeout)(ctx); + } + }, ESTIMATED_PROGRESS_INTERVAL); + } + + stepProgress[ctx.task] = { + startedAt: Date.now(), + ...stepProgress[ctx.task], + ...(progress && total && { numerator: progress, denominator: total }), + }; + + runningBuildState.value = { + buildId: ctx.announcedBuild?.id, + buildProgressPercentage: Math.min(newPercentage, endPercentage), + currentStep: ctx.task, + stepProgress, + }; + }; + +export const onCompleteOrError = + ( + runningBuildState: ReturnType>, + timeout: ReturnType + ) => + (ctx: Context, error?: { formattedError: string; originalError: Error | Error[] }) => { + clearTimeout(timeout); + + const { buildProgressPercentage, stepProgress } = runningBuildState.value; + + if (isKnownStep(ctx.task)) { + stepProgress[ctx.task] = { + ...stepProgress[ctx.task], + completedAt: Date.now(), + }; + } + + if (error) { + runningBuildState.value = { + buildId: ctx.announcedBuild?.id, + buildProgressPercentage, + currentStep: "error", + stepProgress, + formattedError: error.formattedError, + originalError: error.originalError, + previousBuildProgress: stepProgress, + }; + return; + } + + if (ctx.task === "snapshot") { + runningBuildState.value = { + buildId: ctx.announcedBuild?.id, + buildProgressPercentage: 100, + currentStep: "complete", + stepProgress, + changeCount: ctx.build.changeCount, + errorCount: ctx.build.errorCount, + previousBuildProgress: stepProgress, + }; + } + }; + +export const runChromaticBuild = async ( + runningBuildState: ReturnType>, + flags: Flags +) => { + if (!flags.projectToken) throw new Error("No project token set"); + + runningBuildState.value = INITIAL_BUILD_PAYLOAD; + + // Timeout is defined here so it's shared between all handlers + let timeout: ReturnType; + + await run({ + // Currently we have to have these flags. + // We should move the checks to after flags have been parsed into options. + flags, + options: { + // We might want to drop this later and instead record "uncommitted hashes" on builds + forceRebuild: true, + // Builds initiated from the addon are always considered local + isLocalBuild: true, + + experimental_onTaskStart: onStartOrProgress(runningBuildState, timeout), + experimental_onTaskProgress: onStartOrProgress(runningBuildState, timeout), + experimental_onTaskComplete: onCompleteOrError(runningBuildState, timeout), + experimental_onTaskError: onCompleteOrError(runningBuildState, timeout), + }, + }); +}; diff --git a/src/screens/VisualTests/BuildEyebrow.stories.ts b/src/screens/VisualTests/BuildEyebrow.stories.ts new file mode 100644 index 00000000..dcb9ba1a --- /dev/null +++ b/src/screens/VisualTests/BuildEyebrow.stories.ts @@ -0,0 +1,72 @@ +import { action } from "@storybook/addon-actions"; +import type { Meta, StoryObj } from "@storybook/react"; +import { fireEvent, within } from "@storybook/testing-library"; + +import * as buildProgressStories from "../../components/BuildProgressLabel.stories"; +import { playAll } from "../../utils/playAll"; +import { withFigmaDesign } from "../../utils/withFigmaDesign"; +import { BuildEyebrow } from "./BuildEyebrow"; + +const meta = { + args: { + branch: "feature", + switchToNextBuild: action("switchToNextBuild"), + }, + component: BuildEyebrow, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const expandEyebrow = playAll(async ({ canvasElement }) => { + const canvas = within(canvasElement); + const button = await canvas.findByRole("button"); + await fireEvent.click(button); +}); + +export const Initialize: Story = { + args: buildProgressStories.Initialize.args, + parameters: buildProgressStories.Initialize.parameters, + play: expandEyebrow, +}; + +export const Build: Story = { + args: buildProgressStories.Build.args, + parameters: buildProgressStories.Build.parameters, + play: expandEyebrow, +}; + +export const Upload: Story = { + args: buildProgressStories.Upload.args, + parameters: buildProgressStories.Upload.parameters, + play: expandEyebrow, +}; + +export const Verify: Story = { + args: buildProgressStories.Verify.args, + parameters: buildProgressStories.Verify.parameters, + play: expandEyebrow, +}; + +export const Snapshot: Story = { + args: buildProgressStories.Snapshot.args, + parameters: buildProgressStories.Snapshot.parameters, + play: expandEyebrow, +}; + +export const Complete: Story = { + args: buildProgressStories.Complete.args, + parameters: buildProgressStories.Complete.parameters, + play: expandEyebrow, +}; + +export const NewerSnapshotAvailable: Story = {}; + +export const NewerBuildOnBranch: Story = { + args: { + switchToNextBuild: undefined, + }, + parameters: withFigmaDesign( + "https://www.figma.com/file/GFEbCgCVDtbZhngULbw2gP/Visual-testing-in-Storybook?type=design&node-id=2127-448761&mode=design&t=70EtYCn1H7hB8PAk-0" + ), +}; diff --git a/src/screens/VisualTests/BuildEyebrow.tsx b/src/screens/VisualTests/BuildEyebrow.tsx new file mode 100644 index 00000000..17c2bcaf --- /dev/null +++ b/src/screens/VisualTests/BuildEyebrow.tsx @@ -0,0 +1,186 @@ +import { Icons, Link } from "@storybook/components"; +import { css, keyframes, styled } from "@storybook/theming"; +import React, { useEffect, useRef } from "react"; + +import { BUILD_STEP_CONFIG, BUILD_STEP_ORDER } from "../../buildSteps"; +import { BuildProgressLabel } from "../../components/BuildProgressLabel"; +import { IconButton } from "../../components/IconButton"; +import { RunningBuildPayload } from "../../types"; + +const spin = keyframes({ + from: { transform: "rotate(0deg)" }, + to: { transform: "rotate(359deg)" }, +}); + +const Header = styled.button(({ onClick, theme }) => ({ + position: "relative", + display: "flex", + width: "100%", + lineHeight: "20px", + padding: "5px 5px 5px 15px", + justifyContent: "space-between", + alignItems: "center", + background: theme.background.app, + border: "none", + borderBottom: `1px solid ${theme.appBorderColor}`, + color: theme.color.defaultText, + cursor: onClick ? "pointer" : "default", + textAlign: "left", + + "& > *": { + zIndex: 1, + }, + + code: { + fontFamily: theme.typography.fonts.mono, + fontSize: theme.typography.size.s1, + }, +})); + +const Bar = styled.div<{ percentage: number }>(({ theme, percentage }) => ({ + display: "block", + position: "absolute", + top: "0", + height: "100%", + left: "0", + width: `${percentage}%`, + transition: "width 3s ease-out", + backgroundColor: theme.background.hoverable, + pointerEvents: "none", + zIndex: 0, +})); + +const Label = styled.div({ + padding: "5px 0", +}); + +const ExpandableDiv = styled.div<{ expanded: boolean }>(({ expanded, theme }) => ({ + display: "grid", + gridTemplateRows: expanded ? "1fr" : "0fr", + background: theme.background.app, + borderBottom: expanded ? `1px solid ${theme.appBorderColor}` : "none", + transition: "grid-template-rows 150ms ease-out", +})); + +const StepDetails = styled.div({ + whiteSpace: "nowrap", + overflow: "hidden", +}); + +const StepDetail = styled.div<{ isCurrent: boolean; isPending: boolean }>( + ({ isCurrent, isPending }) => ({ + display: "flex", + flexDirection: "row", + gap: 8, + opacity: isPending ? 0.7 : 1, + fontWeight: isCurrent ? "bold" : "normal", + fontFamily: "Menlo, monospace", + fontSize: "12px", + lineHeight: "24px", + margin: "0 10px", + "&:first-of-type": { + marginTop: 8, + }, + "&:last-of-type": { + marginBottom: 8, + }, + }) +); + +const StepIcon = styled(Icons)( + { width: 10, marginRight: 8 }, + ({ icon }) => icon === "sync" && css({ animation: `${spin} 1s linear infinite` }) +); + +type BuildProgressProps = { + buildProgress?: RunningBuildPayload; + expanded?: boolean; +}; + +const BuildProgress = ({ buildProgress, expanded }: BuildProgressProps) => { + const stepHistory = useRef< + Partial> + >({}); + + useEffect(() => { + stepHistory.current[buildProgress.currentStep] = { ...buildProgress }; + }, [buildProgress]); + + const currentIndex = BUILD_STEP_ORDER.findIndex((key) => key === buildProgress.currentStep); + const steps = BUILD_STEP_ORDER.map((step, index) => { + const isCurrent = index === currentIndex; + const isPending = index > currentIndex && currentIndex !== -1; + const config = { ...BUILD_STEP_CONFIG[step], isCurrent, isPending }; + if (isCurrent) { + return { ...config, icon: "sync", renderLabel: config.renderProgress }; + } + if (isPending) { + return { ...config, icon: "arrowright", renderLabel: config.renderName }; + } + return { ...config, icon: "check", renderLabel: config.renderComplete }; + }); + + return ( + + + {steps.map(({ icon, isCurrent, isPending, key, renderLabel }) => ( + +
+ + {renderLabel(stepHistory.current[key] || buildProgress)} +
+
+ ))} +
+
+ ); +}; + +type BuildEyebrowProps = { + branch: string; + runningBuild?: RunningBuildPayload; + switchToNextBuild?: () => void; +}; + +export const BuildEyebrow = ({ branch, runningBuild, switchToNextBuild }: BuildEyebrowProps) => { + const [expanded, setExpanded] = React.useState(false); + const toggleExpanded = () => { + setExpanded(!expanded); + }; + + if (runningBuild) { + return ( + <> +
+ + + + {expanded ? : } + +
+ + + ); + } + + const message = switchToNextBuild ? ( + + ) : ( + + ); + + return ( +
+ + {message} +
+ ); +}; diff --git a/src/screens/VisualTests/BuildProgress.stories.ts b/src/screens/VisualTests/BuildProgress.stories.ts deleted file mode 100644 index 455e1885..00000000 --- a/src/screens/VisualTests/BuildProgress.stories.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { action } from "@storybook/addon-actions"; -import type { Meta, StoryObj } from "@storybook/react"; - -import { withFigmaDesign } from "../../utils/withFigmaDesign"; -import { BuildProgress } from "./BuildProgress"; - -const meta = { - args: { - switchToNextBuild: action("switchToNextBuild"), - }, - component: BuildProgress, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Initialize: Story = { - args: { - runningBuild: { - step: "initialize", - }, - }, - parameters: withFigmaDesign( - "https://www.figma.com/file/GFEbCgCVDtbZhngULbw2gP/Visual-testing-in-Storybook?type=design&node-id=2303-353260&mode=design&t=vlcsXN2x67tQaQdy-0" - ), -}; - -export const Build: Story = { - args: { - runningBuild: { - step: "build", - buildProgressPercentage: 30, - }, - }, - parameters: withFigmaDesign( - "https://www.figma.com/file/GFEbCgCVDtbZhngULbw2gP/Visual-testing-in-Storybook?type=design&node-id=2303-353260&mode=design&t=vlcsXN2x67tQaQdy-0" - ), -}; - -export const Upload: Story = { - args: { - runningBuild: { - step: "upload", - stepProgressValue: 500, - stepProgressTotal: 1000, - buildProgressPercentage: 50, - }, - }, - parameters: withFigmaDesign( - "https://www.figma.com/file/GFEbCgCVDtbZhngULbw2gP/Visual-testing-in-Storybook?type=design&node-id=2303-370243&mode=design&t=vlcsXN2x67tQaQdy-0" - ), -}; - -export const Verify: Story = { - args: { - runningBuild: { - step: "verify", - buildProgressPercentage: 75, - }, - }, - parameters: withFigmaDesign( - "https://www.figma.com/file/GFEbCgCVDtbZhngULbw2gP/Visual-testing-in-Storybook?type=design&node-id=2303-371149&mode=design&t=vlcsXN2x67tQaQdy-0" - ), -}; - -export const Snapshot: Story = { - args: { - runningBuild: { - step: "snapshot", - stepProgressValue: 25, - stepProgressTotal: 50, - buildProgressPercentage: 90, - }, - }, - parameters: withFigmaDesign( - "https://www.figma.com/file/GFEbCgCVDtbZhngULbw2gP/Visual-testing-in-Storybook?type=design&node-id=2303-373686&mode=design&t=vlcsXN2x67tQaQdy-0" - ), -}; - -export const Complete: Story = { - args: { - runningBuild: { - step: "complete", - buildProgressPercentage: 100, - }, - }, - parameters: withFigmaDesign( - "https://www.figma.com/file/GFEbCgCVDtbZhngULbw2gP/Visual-testing-in-Storybook?type=design&node-id=2303-375342&mode=design&t=vlcsXN2x67tQaQdy-0" - ), -}; - -export const Latest: Story = { - // Could not find this state in the figma file, but it is in this video: https://chromaticqa.slack.com/archives/C051TQR6QLC/p1692372058786929?thread_ts=1692354384.352659&cid=C051TQR6QLC - // parameters: withFigmaDesign( - // ), -}; - -export const LatestNeedToUpdate: Story = { - args: { - switchToNextBuild: undefined, - }, - parameters: withFigmaDesign( - "https://www.figma.com/file/GFEbCgCVDtbZhngULbw2gP/Visual-testing-in-Storybook?type=design&node-id=2127-448761&mode=design&t=70EtYCn1H7hB8PAk-0" - ), -}; diff --git a/src/screens/VisualTests/BuildProgress.tsx b/src/screens/VisualTests/BuildProgress.tsx deleted file mode 100644 index 91566d65..00000000 --- a/src/screens/VisualTests/BuildProgress.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { Link } from "@storybook/components"; -import { styled } from "@storybook/theming"; -import React from "react"; - -import { BuildProgressLabel } from "../../components/BuildProgressLabel"; -import { RunningBuildPayload } from "../../constants"; - -export const Header = styled.div(({ theme }) => ({ - color: theme.color.darkest, - background: theme.background.app, - padding: "10px", - lineHeight: "18px", - position: "relative", -})); - -export const Bar = styled.div<{ percentage: number }>(({ theme, percentage }) => ({ - display: "block", - position: "absolute", - top: "0", - height: "100%", - left: "0", - width: `${percentage}%`, - transition: "all 150ms ease-out", - backgroundColor: theme.background.hoverable, -})); - -export const Text = styled.div({ - position: "relative", - zIndex: 1, -}); - -type BuildProgressProps = { - runningBuild?: RunningBuildPayload; - switchToNextBuild?: () => void; -}; - -export function BuildProgress({ runningBuild, switchToNextBuild }: BuildProgressProps) { - const percentage = runningBuild?.buildProgressPercentage; - - // eslint-disable-next-line no-nested-ternary - const text = runningBuild ? ( - - ) : switchToNextBuild ? ( - "There's a newer snapshot with changes" - ) : ( - "Reviewing is disabled because there's a newer snapshot on " - ); - - // We show the "go to next build" button if there's no build running or if the running build is complete - const showButton = switchToNextBuild && (!runningBuild || runningBuild.step === "complete"); - return ( -
- {runningBuild &&  } - - {text}{" "} - {showButton && ( - // eslint-disable-next-line jsx-a11y/anchor-is-valid - - Switch to next build - - )} - -
- ); -} diff --git a/src/screens/VisualTests/BuildResults.tsx b/src/screens/VisualTests/BuildResults.tsx index 513da28f..f2556df4 100644 --- a/src/screens/VisualTests/BuildResults.tsx +++ b/src/screens/VisualTests/BuildResults.tsx @@ -8,7 +8,6 @@ import { Heading } from "../../components/Heading"; import { IconButton } from "../../components/IconButton"; import { Bar, Col, Section, Sections, Text } from "../../components/layout"; import { Text as CenterText } from "../../components/Text"; -import { RunningBuildPayload } from "../../constants"; import { getFragment } from "../../gql"; import { BuildStatus, @@ -18,7 +17,8 @@ import { TestResult, TestStatus, } from "../../gql/graphql"; -import { BuildProgress } from "./BuildProgress"; +import { RunningBuildPayload } from "../../types"; +import { BuildEyebrow } from "./BuildEyebrow"; import { FragmentNextStoryTestFields, FragmentStoryTestFields } from "./graphql"; import { RenderSettings } from "./RenderSettings"; import { SnapshotComparison } from "./SnapshotComparison"; @@ -26,6 +26,7 @@ import { StoryInfo } from "./StoryInfo"; import { Warnings } from "./Warnings"; interface BuildResultsProps { + branch: string; runningBuild: RunningBuildPayload; storyBuild: StoryBuildFieldsFragment; nextBuild: NextBuildFieldsFragment; @@ -38,6 +39,7 @@ interface BuildResultsProps { } export const BuildResults = ({ + branch, runningBuild, nextBuild, switchToNextBuild, @@ -53,7 +55,7 @@ export const BuildResults = ({ const [baselineImageVisible, setBaselineImageVisible] = useState(false); const toggleBaselineImage = () => setBaselineImageVisible(!baselineImageVisible); - const isRunningBuildInProgress = runningBuild && runningBuild.step !== "complete"; + const isRunningBuildInProgress = runningBuild && runningBuild.currentStep !== "complete"; const isReviewable = nextBuild.id === storyBuild.id; const storyTests = [ @@ -79,7 +81,8 @@ export const BuildResults = ({ (!isReviewable && !(isStorySuperseded && switchToNextBuild)); const runningBuildIsNextBuild = runningBuild && runningBuild?.buildId === nextBuild.id; const buildStatus = showBuildStatus && ( - diff --git a/src/screens/VisualTests/VisualTests.stories.tsx b/src/screens/VisualTests/VisualTests.stories.tsx index 9fab5418..dd7a6c2a 100644 --- a/src/screens/VisualTests/VisualTests.stories.tsx +++ b/src/screens/VisualTests/VisualTests.stories.tsx @@ -10,6 +10,7 @@ import { graphql } from "msw"; import React from "react"; import { TypedDocumentNode } from "urql"; +import { INITIAL_BUILD_PAYLOAD } from "../../buildSteps"; import type { NextBuildFieldsFragment, StoryBuildFieldsFragment, @@ -229,7 +230,12 @@ export const NoNextBuildRunningBuildStarting: Story = { args: { ...NoNextBuild.args, runningBuild: { - step: "initialize", + buildProgressPercentage: 1, + currentStep: "initialize", + stepProgress: { + ...INITIAL_BUILD_PAYLOAD.stepProgress, + initialize: { startedAt: Date.now() - 1000 }, + }, }, }, }; @@ -239,9 +245,16 @@ export const NoNextBuildRunningBuildUploading: Story = { args: { ...NoNextBuild.args, runningBuild: { - step: "upload", - stepProgressValue: 10, - stepProgressTotal: 100, + ...INITIAL_BUILD_PAYLOAD, + currentStep: "upload", + stepProgress: { + ...INITIAL_BUILD_PAYLOAD.stepProgress, + upload: { + startedAt: Date.now() - 3000, + numerator: 10, + denominator: 100, + }, + }, }, }, }; @@ -272,9 +285,17 @@ export const Published: Story = { export const InProgress: Story = { args: { runningBuild: { - step: "snapshot", - stepProgressValue: 20, - stepProgressTotal: 100, + ...INITIAL_BUILD_PAYLOAD, + buildProgressPercentage: 60, + currentStep: "snapshot", + stepProgress: { + ...INITIAL_BUILD_PAYLOAD.stepProgress, + snapshot: { + startedAt: Date.now() - 5000, + numerator: 64, + denominator: 340, + }, + }, }, }, parameters: { diff --git a/src/screens/VisualTests/VisualTests.tsx b/src/screens/VisualTests/VisualTests.tsx index 1204d90f..d3c5dd2f 100644 --- a/src/screens/VisualTests/VisualTests.tsx +++ b/src/screens/VisualTests/VisualTests.tsx @@ -3,7 +3,6 @@ import type { API_StatusState } from "@storybook/types"; import React, { useCallback, useEffect, useState } from "react"; import { useMutation, useQuery } from "urql"; -import { GitInfoPayload, RunningBuildPayload } from "../../constants"; import { getFragment } from "../../gql"; import { AddonVisualTestsBuildQuery, @@ -12,7 +11,7 @@ import { ReviewTestInputStatus, TestStatus, } from "../../gql/graphql"; -import { UpdateStatusFunction } from "../../types"; +import { GitInfoPayload, RunningBuildPayload, UpdateStatusFunction } from "../../types"; import { statusMap, testsToStatusUpdate } from "../../utils/testsToStatusUpdate"; import { BuildResults } from "./BuildResults"; import { @@ -181,7 +180,8 @@ export const VisualTests = ({ [canSwitchToNextBuild, nextBuild?.id, storyId] ); - const isRunningBuildStarting = runningBuild && !["success", "error"].includes(runningBuild.step); + const isRunningBuildStarting = + runningBuild && !["success", "error"].includes(runningBuild.currentStep); return !nextBuild || error ? ( [1] export type UpdateStatusFunction = ( update: StoryStatusUpdater ) => ReturnType; + +export type GitInfoPayload = Omit; + +export type ProjectInfoPayload = { + projectId?: string; + projectToken?: string; + written?: boolean; + configDir?: string; + mainPath?: string; +}; + +// The CLI may have other steps that we don't respond to, so we just ignore updates +// to those steps and focus on the ones we know. +export type KnownStep = Extract< + TaskName, + "initialize" | "build" | "upload" | "verify" | "snapshot" +>; + +export type StepProgressPayload = { + /** Current task progress value (e.g. bytes or snapshots) */ + numerator?: number; + /** Current task progress total (e.g. bytes or snapshots) */ + denominator?: number; + startedAt?: number; + completedAt?: number; +}; + +export type RunningBuildPayload = { + /** The id of the build, available after the initialize step */ + buildId?: string; + + /** Overall percentage of build progress */ + buildProgressPercentage: number; + + // Possibly this should be a type exported by the CLI -- these correspond to tasks + /** The step of the build process we have reached */ + currentStep: KnownStep | "error" | "complete"; + + /** Number of visual changes detected */ + changeCount?: number; + + /** Number of component errors detected */ + errorCount?: number; + + /** The error message formatted to display in CLI */ + formattedError?: string; + + /** The original error without formatting */ + originalError?: Error | Error[]; + + /** Progress tracking data for each step */ + stepProgress: Record; + + /** Progress tracking data from the previous build (if any) */ + previousBuildProgress?: Record; +}; diff --git a/src/utils/useProjectId.ts b/src/utils/useProjectId.ts index bfc11015..3cb463df 100644 --- a/src/utils/useProjectId.ts +++ b/src/utils/useProjectId.ts @@ -1,6 +1,7 @@ import { useState } from "react"; -import { PROJECT_INFO, ProjectInfoPayload } from "../constants"; +import { PROJECT_INFO } from "../constants"; +import { ProjectInfoPayload } from "../types"; import { useAddonState } from "../useAddonState/manager"; export const useProjectId = () => { diff --git a/yarn.lock b/yarn.lock index 109e1cf4..45cb846b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7095,6 +7095,11 @@ filelist@^1.0.4: dependencies: minimatch "^5.0.1" +filesize@^10.0.12: + version "10.0.12" + resolved "https://registry.yarnpkg.com/filesize/-/filesize-10.0.12.tgz#6eef217c08e9633cdbf438d9124e8f5f524ffa05" + integrity sha512-6RS9gDchbn+qWmtV2uSjo5vmKizgfCQeb5jKmqx8HyzA3MoLqqyQxN+QcjkGBJt7FjJ9qFce67Auyya5rRRbpw== + fill-range@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40"