diff --git a/workflow-canvas/README.md b/workflow-canvas/README.md index 20336e7f..e4f5b97f 100644 --- a/workflow-canvas/README.md +++ b/workflow-canvas/README.md @@ -8,15 +8,16 @@ The page has Designer and Execution tabs. The Designer tab loads the workflow from the API and draws it, read-only: the intent, each stage in list order (name, agent and harness) and the end, joined by run-order edges. It shows a loading message, an error message or an empty message when there is nothing to -draw. The Execution tab is still a placeholder. The contract, the stub that -serves it and the tests around both are in this folder. +draw. The Execution tab draws the same graph and runs it: a Run button starts +a run, and each node shows its live state until the run ends. The contract, the +stub that serves it and the tests around both are in this folder. ## Layout - `src/`: the UI. `graph/` turns a workflow into nodes and edges and does no IO; `api/` is the typed client of the API; `launch/` and `workflow/` open the session and load the workflow; `designer/` and `canvas/` draw it; - `execution/core/` starts a run and follows it, and does no IO. It reads a run + `execution/` runs it and shows the run; `execution/core/` does no IO and reads a run once a second. A read that fails with no answer or a server error is tried again, up to three in a row; any other failure ends the following. A start that finds a run already active follows that run. Every run call takes a @@ -132,7 +133,8 @@ The data covers the first release: a workflow of an intent and stages, and the `kiro-cli` harness. It is a `hello` workflow with a `draft` stage (`k-developer`) and a `review` stage (`k-quality-assurance`). A saved change lasts until the stub stops. A run is faked and calls no agent: its stages run -for 3 seconds each, one after the other, and all of them pass. +for 3 seconds each, one after the other, and all of them pass. It runs one run +at a time and never fails a stage. ### Run the page against the stub @@ -282,6 +284,60 @@ The rest wraps the core, so the rules can be tested without a browser: outcome short, and keeps the whole text in its `title`; an outcome too long for the node is also written out in full under the graph. +## The Execution tab + +The tab draws the same graph as the Designer, read-only, with a **Goal** box +and a **Run** button beside it. The goal starts as the outcome of the +intent, because the API needs one (1 to 2048 characters) to start a run. + +- **Run** calls `POST /api/run` with the goal, then reads + `GET /api/runs/{id}` once a second until the run is `passed` or `halted`. + Run is off and the goal box is read-only from the click until the run ends. + Read-only, not greyed out, so the box keeps focus and shows the goal of that + run. Enter in the box starts a run, like the button. +- Each node shows a badge with a word and a colour: **Queued** (with a dashed + outline), **Running**, **Done** and **Failed**. The word carries the state; + only Queued and Not run also differ in shape. The intent is Done once the run + has it. After a halt, the stages behind the failed one say **Not run**. The end + shows the result: **Passed** or **Halted**. +- A line under the button says where the run is ("Running draft. 0 of 2 stages + done."). A screen reader hears each change from a polite live region that + `App` keeps outside the tabs, so it is announced while the Designer tab is + open too. Each node's name for a screen reader ends with its state. +- An error from the API, whether the run does not start or a read fails, shows + in one alert under the Run button, with the sentence the server gave. A + screen reader hears it from an alert region that `App` keeps outside the + tabs, so it is read when it appears even if the Designer tab is open; the + status line on the tab says only "See the error below.", which the live status region leaves out. When a read fails, the + nodes are cleared and the alert adds that the run (with its id) may still be + going on the server. A failed stage is a result, not an error, so it has no + alert. The goal is one line: line breaks in the outcome of the intent become + spaces, and Enter that ends an input method composition starts no run. +- A workflow can be saved while a run goes on, so the run may have other stages + than the graph. Node states attach by stage name, so a warning above the + graph names the stages that are not drawn and those that are not in the run. +- A run goes on while the Designer tab is open, and the tab shows it again when + you come back, with the goal box as it was: what you typed, or the goal of + the run in flight. Closing the page stops the reading, not the run. +- The colours of the states are in `src/canvas/canvas.css` and match the theme + module, checked in both modes by the contrast test. + +The code follows the ports-and-adapters split of the graph: + +- `src/execution/core/` is the core and does no IO: the state of the tab and + its reducer, the loop that starts a run and reads it (`startAndFollow`), the + node states and the status sentences. Its ports are `RunPort` (start and + read a run) and `Scheduler` (wait for some milliseconds), so a test moves + time by hand and nothing sleeps. A test (`test-support/execution-purity.test.ts`) + fails if the core imports anything but its own modules, the graph core and the + wire types, or reads a clock, randomness, the environment or the network. +- The adapters are `createHttpApi` (`startRun`, `getRun`), which `loadWorkflow` + ties to the session token, and `timerScheduler`. The token is held only by + those run calls. +- `useExecution` keeps the state in `App`, and `Execution` draws it. The + Designer and Execution tabs share `WorkflowGate` (loading, error and empty + states) and `WorkflowGraphCard` (the card with the graph). + ## Theme The page has the look of the Konductor docs theme: its colours, fonts and @@ -357,7 +413,15 @@ npx playwright install chromium Then `npm run test:e2e`. The HTML report is written to `playwright-report/`; open it with `npx playwright show-report`. Accessibility is checked with -axe-core on each tab, on the error state and in both theme modes. +axe-core on each tab, on the error state, on the Execution tab with a stage +running, a failed stage, a passed run, the error of the API and a goal that +cannot be run, and in both theme modes. + +The stub runs one run at a time and never fails a stage. So the one test that +starts a real run on it (`execution.spec.ts`, the first run test) waits for the +end of the run, and the tests of a failed stage or a refused run answer the +run calls themselves (`e2e/support/run-routes.ts`). A test that starts another +run on the stub must also wait for its end, or the next test gets a 409. Shared page objects and helpers live in `e2e/support/`. New tests should extend them rather than repeat selectors: import `test` and `expect` from @@ -366,12 +430,18 @@ the app. ## Screenshots -`npm run screenshots` saves a full-page PNG of each tab to -`e2e/screenshots/`, with the Designer tab drawn from the stub, at 1280x800 with -animations frozen. The tabs are in the light theme (`designer-tab.png`, -`execution-tab.png`), and `designer-tab-dark.png` is the Designer tab after -the theme button is pressed. Pull request descriptions embed these -files, so regenerate and commit them when the UI changes. +`npm run screenshots` saves full-page PNGs to `e2e/screenshots/`, drawn from +the stub, at 1280x800 with animations frozen. The runs in the Execution +captures answer with fixed replies, so no capture depends on the clock of the +stub. The Designer tab is in the light +theme (`designer-tab.png`) and in the dark one (`designer-tab-dark.png`, after +the theme button is pressed). The Execution tab is saved before a run +(`execution-tab-idle.png`), while the first stage runs +(`execution-tab-running.png`) and after the run passed +(`execution-tab-finished.png`), all in the light theme, and while the first +stage runs in the dark theme (`execution-tab-dark.png`). Pull request +descriptions embed these files, so regenerate and commit them when the UI +changes. Only that script writes the PNGs. It uses its own config, `playwright.screenshots.config.ts`, so `npm run test:e2e`, a bare diff --git a/workflow-canvas/e2e/capture/screenshots.spec.ts b/workflow-canvas/e2e/capture/screenshots.spec.ts index 54d1e840..82432e8e 100644 --- a/workflow-canvas/e2e/capture/screenshots.spec.ts +++ b/workflow-canvas/e2e/capture/screenshots.spec.ts @@ -1,18 +1,18 @@ // SPDX-License-Identifier: Apache-2.0 -import { test } from '../support/fixtures'; +import { expect, test } from '../support/fixtures'; +import { answerRunsWith, runReply } from '../support/run-routes'; import { saveFullPageScreenshot } from '../support/screenshots'; -import { TAB_NAMES } from '../support/workflow-canvas-page'; +import type { WorkflowCanvasPage } from '../support/workflow-canvas-page'; -// Run with `npm run screenshots`. These tests assert nothing: they only -// regenerate the PNGs that PR descriptions embed. -for (const name of TAB_NAMES) { - test(`capture the ${name} tab`, async ({ canvas }) => { - await canvas.selectTab(name); - await canvas.expectTabLoaded(name); +// Run with `npm run screenshots`. These tests assert only what they must to +// reach the state they save: they regenerate the PNGs that PR descriptions +// embed. +test('capture the Designer tab', async ({ canvas }) => { + await canvas.selectTab('Designer'); + await canvas.expectTabLoaded('Designer'); - await saveFullPageScreenshot(canvas.page, `${name.toLowerCase()}-tab`); - }); -} + await saveFullPageScreenshot(canvas.page, 'designer-tab'); +}); // The Designer tab in the dark mode, switched with the button like a person would. test('capture the Designer tab in the dark mode', async ({ canvas }) => { @@ -21,3 +21,69 @@ test('capture the Designer tab in the dark mode', async ({ canvas }) => { await saveFullPageScreenshot(canvas.page, 'designer-tab-dark'); }); + +// After the click the pointer and the focus are still on Run, which is now off +// and shows its reason in a tooltip. A capture takes the page without it, so it +// does not depend on how fast the tooltip appears. +async function leaveRunButton(canvas: WorkflowCanvasPage): Promise { + await canvas.page.mouse.move(0, 0); + await canvas.runButton.blur(); +} + +// The Execution tab before a run, drawn from the stub. +test('capture the Execution tab before a run', async ({ canvas }) => { + await canvas.selectTab('Execution'); + await canvas.expectTabLoaded('Execution'); + + await saveFullPageScreenshot(canvas.page, 'execution-tab-idle'); +}); + +// The runs below answer with fixed replies, not a run on the stub, so a +// capture never races the three seconds that a stage takes there. +test('capture the Execution tab while the first stage runs', async ({ + canvas, +}) => { + await answerRunsWith( + canvas.page, + runReply('running', ['running', 'pending']), + ); + await canvas.selectTab('Execution'); + await canvas.expectTabLoaded('Execution'); + + await canvas.runButton.click(); + await canvas.expectBadges(['Done', 'Running', 'Queued', 'Queued']); + await leaveRunButton(canvas); + + await saveFullPageScreenshot(canvas.page, 'execution-tab-running'); +}); + +test('capture the Execution tab after the run passed', async ({ canvas }) => { + await answerRunsWith(canvas.page, runReply('passed', ['passed', 'passed'])); + await canvas.selectTab('Execution'); + await canvas.expectTabLoaded('Execution'); + + await canvas.runButton.click(); + await canvas.expectBadges(['Done', 'Done', 'Done', 'Passed']); + await expect(canvas.runStatus).toContainText('Run passed.'); + + await saveFullPageScreenshot(canvas.page, 'execution-tab-finished'); +}); + +// The Execution tab in the dark mode, while the first stage runs. +test('capture the Execution tab in the dark mode during a run', async ({ + canvas, +}) => { + await answerRunsWith( + canvas.page, + runReply('running', ['running', 'pending']), + ); + await canvas.switchTheme(); + await canvas.selectTab('Execution'); + await canvas.expectTabLoaded('Execution'); + + await canvas.runButton.click(); + await canvas.expectBadges(['Done', 'Running', 'Queued', 'Queued']); + await leaveRunButton(canvas); + + await saveFullPageScreenshot(canvas.page, 'execution-tab-dark'); +}); diff --git a/workflow-canvas/e2e/screenshots/execution-tab-dark.png b/workflow-canvas/e2e/screenshots/execution-tab-dark.png new file mode 100644 index 00000000..d38bf146 Binary files /dev/null and b/workflow-canvas/e2e/screenshots/execution-tab-dark.png differ diff --git a/workflow-canvas/e2e/screenshots/execution-tab-finished.png b/workflow-canvas/e2e/screenshots/execution-tab-finished.png new file mode 100644 index 00000000..54fe55db Binary files /dev/null and b/workflow-canvas/e2e/screenshots/execution-tab-finished.png differ diff --git a/workflow-canvas/e2e/screenshots/execution-tab-idle.png b/workflow-canvas/e2e/screenshots/execution-tab-idle.png new file mode 100644 index 00000000..31709f79 Binary files /dev/null and b/workflow-canvas/e2e/screenshots/execution-tab-idle.png differ diff --git a/workflow-canvas/e2e/screenshots/execution-tab-running.png b/workflow-canvas/e2e/screenshots/execution-tab-running.png new file mode 100644 index 00000000..121c68ab Binary files /dev/null and b/workflow-canvas/e2e/screenshots/execution-tab-running.png differ diff --git a/workflow-canvas/e2e/screenshots/execution-tab.png b/workflow-canvas/e2e/screenshots/execution-tab.png deleted file mode 100644 index 941e658b..00000000 Binary files a/workflow-canvas/e2e/screenshots/execution-tab.png and /dev/null differ diff --git a/workflow-canvas/e2e/support/run-routes.ts b/workflow-canvas/e2e/support/run-routes.ts new file mode 100644 index 00000000..ee1896c1 --- /dev/null +++ b/workflow-canvas/e2e/support/run-routes.ts @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 +import type { Page } from '@playwright/test'; + +const RUN_ID = '20261024-0412-a7b3c'; + +/** The stages of the sample workflow, in order, and what a run says of them. */ +export type StageStatuses = readonly [ + 'pending' | 'running' | 'passed' | 'blocked', + 'pending' | 'running' | 'passed' | 'blocked', +]; + +/** A reply of `GET /api/runs/{id}` for the sample workflow. */ +export function runReply( + status: 'running' | 'passed' | 'halted', + [draft, review]: StageStatuses, +) { + const finished = status !== 'running'; + return { + run: { + run_id: RUN_ID, + workflow: 'hello', + harness: 'kiro-cli', + status, + started_at: '2026-10-24T04:12:05Z', + finished_at: finished ? '2026-10-24T04:12:49Z' : null, + exit_code: finished ? (status === 'passed' ? 0 : 1) : null, + order: ['draft', 'review'], + stages: { draft: { status: draft }, review: { status: review } }, + }, + }; +} + +type RunReply = ReturnType; + +async function acceptRuns(page: Page): Promise { + await page.route('**/api/run', (route) => + route.fulfill({ status: 202, json: { run_id: RUN_ID } }), + ); +} + +/** + * Makes the page's calls to start a run and to read it answer with `reply`, + * without a run on the server. For states the stub never reaches, such as a + * failed stage, and so that the stub, which runs one run at a time, is left + * for the tests that run on it. + */ +export async function answerRunsWith( + page: Page, + reply: RunReply, +): Promise { + await acceptRuns(page); + await page.route('**/api/runs/*', (route) => route.fulfill({ json: reply })); +} + +/** + * Like `answerRunsWith`, but each read of the run gets the next reply, and + * the last one is repeated. `reads()` says how many reads the page has made, + * so a test can wait for one without a fixed pause. + */ +export async function answerRunsInTurn( + page: Page, + replies: readonly RunReply[], +): Promise<{ reads(): number }> { + await acceptRuns(page); + let reads = 0; + await page.route('**/api/runs/*', (route) => { + const reply = replies[Math.min(reads, replies.length - 1)]; + reads += 1; + return route.fulfill({ json: reply }); + }); + return { reads: () => reads }; +} + +/** Makes the page's call to start a run fail with the status and body. */ +export async function refuseRunsWith( + page: Page, + status: number, + body: { error: string; code: string }, +): Promise { + await page.route('**/api/run', (route) => + route.fulfill({ status, json: body }), + ); +} diff --git a/workflow-canvas/e2e/support/workflow-canvas-page.ts b/workflow-canvas/e2e/support/workflow-canvas-page.ts index 2bff325a..4c57275f 100644 --- a/workflow-canvas/e2e/support/workflow-canvas-page.ts +++ b/workflow-canvas/e2e/support/workflow-canvas-page.ts @@ -9,9 +9,6 @@ export type TabName = (typeof TAB_NAMES)[number]; /** The two modes of the page. */ export type ThemeName = 'light' | 'dark'; -/** Text the Execution tab shows until it has real content. */ -export const PLACEHOLDER_TEXT = 'Nothing here yet.'; - /** The nodes of the workflow canvas, top to bottom, by test id. */ export const NODE_IDS = [ 'node-intent', @@ -30,6 +27,16 @@ export const NODE_NAMES = [ 'End', ] as const; +/** The words a node shows for its state in a run. */ +export type BadgeText = + 'Queued' | 'Running' | 'Done' | 'Failed' | 'Not run' | 'Passed' | 'Halted'; + +/** + * How long to wait for a step of a run on the stub. A stage takes 3 seconds + * there and the page reads the run once a second. + */ +export const STEP_TIMEOUT_MS = 10_000; + /** The most Tab presses between one node and the next one to take focus. */ const MAX_TABS_TO_NEXT_NODE = 10; @@ -41,6 +48,12 @@ export class WorkflowCanvasPage { readonly edges: Locator; readonly themeButton: Locator; readonly breadcrumbs: Locator; + readonly runButton: Locator; + readonly goal: Locator; + readonly runStatus: Locator; + readonly runError: Locator; + readonly runAlert: Locator; + readonly runLiveStatus: Locator; constructor(readonly page: Page) { this.main = page.getByRole('main'); @@ -49,6 +62,12 @@ export class WorkflowCanvasPage { this.edges = this.graph.locator('.react-flow__edge'); this.themeButton = page.getByRole('button', { name: /^Theme:/ }); this.breadcrumbs = page.getByRole('navigation', { name: 'Breadcrumbs' }); + this.runButton = page.getByRole('button', { name: 'Run', exact: true }); + this.goal = page.getByLabel('Goal'); + this.runStatus = page.getByTestId('run-status'); + this.runError = page.getByTestId('run-error'); + this.runAlert = page.getByTestId('run-live-alert'); + this.runLiveStatus = page.getByTestId('run-live-status'); } /** @@ -96,6 +115,35 @@ export class WorkflowCanvasPage { return this.node(id).locator('.wf-port-notch'); } + /** The label that a node shows for its state in a run. */ + badge(id: NodeId): Locator { + return this.node(id).getByTestId('status-badge'); + } + + /** + * Asserts what every node shows for its state, top to bottom: the words of + * the badge, or `null` for a node with no badge. + */ + async expectBadges( + texts: readonly [ + BadgeText | null, + BadgeText | null, + BadgeText | null, + BadgeText | null, + ], + ): Promise { + for (const [index, id] of NODE_IDS.entries()) { + const text = texts[index]; + if (text === null || text === undefined) { + await expect(this.badge(id)).toHaveCount(0); + } else { + await expect(this.badge(id)).toHaveText(text, { + timeout: STEP_TIMEOUT_MS, + }); + } + } + } + /** The node that a screen reader names `name`. */ nodeNamed(name: string): Locator { return this.graph.getByRole('group', { name, exact: true }); @@ -127,15 +175,16 @@ export class WorkflowCanvasPage { ); } await expect(this.tabPanel(name)).toBeVisible(); - if (name === 'Execution') { - await expect(this.tabPanel(name)).toContainText(PLACEHOLDER_TEXT); - } } - /** Asserts a tab has finished showing its content: the graph, for Designer. */ + /** + * Asserts a tab has finished showing its content: the graph on both tabs, + * and the Run button on Execution. + */ async expectTabLoaded(name: TabName): Promise { await this.expectTabShown(name); - if (name === 'Designer') await this.expectWorkflowDrawn(); + await this.expectWorkflowDrawn(); + if (name === 'Execution') await expect(this.runButton).toBeVisible(); } /** Asserts the whole graph is on screen: every node and every edge. */ diff --git a/workflow-canvas/e2e/tests/accessibility.spec.ts b/workflow-canvas/e2e/tests/accessibility.spec.ts index da8f15dc..258cc918 100644 --- a/workflow-canvas/e2e/tests/accessibility.spec.ts +++ b/workflow-canvas/e2e/tests/accessibility.spec.ts @@ -1,6 +1,11 @@ // SPDX-License-Identifier: Apache-2.0 import { expectNoAccessibilityViolations } from '../support/accessibility'; import { test } from '../support/fixtures'; +import { + answerRunsWith, + refuseRunsWith, + runReply, +} from '../support/run-routes'; import { TAB_NAMES, WorkflowCanvasPage } from '../support/workflow-canvas-page'; for (const name of TAB_NAMES) { @@ -21,3 +26,67 @@ test('Designer tab has no axe violations when the workflow did not load', async await expectNoAccessibilityViolations(page); }); + +// The stub runs one run at a time, so these answer the run calls themselves. +const RUN_STATES = [ + ['a stage running', runReply('running', ['running', 'pending'])], + ['a failed stage', runReply('halted', ['blocked', 'pending'])], + ['a passed run', runReply('passed', ['passed', 'passed'])], +] as const; + +for (const [name, reply] of RUN_STATES) { + test(`Execution tab has no axe violations with ${name}`, async ({ + canvas, + }) => { + await answerRunsWith(canvas.page, reply); + await canvas.selectTab('Execution'); + await canvas.expectTabLoaded('Execution'); + + await canvas.runButton.click(); + await canvas.badge('node-end').waitFor(); + + await expectNoAccessibilityViolations(canvas.page); + }); +} + +test('Execution tab has no axe violations with a failed stage in the dark mode', async ({ + canvas, +}) => { + await answerRunsWith(canvas.page, runReply('halted', ['blocked', 'pending'])); + await canvas.switchTheme(); + await canvas.selectTab('Execution'); + await canvas.expectTabLoaded('Execution'); + + await canvas.runButton.click(); + await canvas.badge('node-end').waitFor(); + + await expectNoAccessibilityViolations(canvas.page); +}); + +test('Execution tab has no axe violations with the error of the API shown', async ({ + canvas, +}) => { + await refuseRunsWith(canvas.page, 409, { + error: 'kiro-cli is not installed.', + code: 'harness_unavailable', + }); + await canvas.selectTab('Execution'); + await canvas.expectTabLoaded('Execution'); + + await canvas.runButton.click(); + await canvas.runError.waitFor(); + + await expectNoAccessibilityViolations(canvas.page); +}); + +test('Execution tab has no axe violations with a goal that cannot be run', async ({ + canvas, +}) => { + await canvas.selectTab('Execution'); + await canvas.expectTabLoaded('Execution'); + + await canvas.goal.fill(''); + await canvas.page.getByText('Enter a goal to run.').first().waitFor(); + + await expectNoAccessibilityViolations(canvas.page); +}); diff --git a/workflow-canvas/e2e/tests/execution.spec.ts b/workflow-canvas/e2e/tests/execution.spec.ts new file mode 100644 index 00000000..2e829d29 --- /dev/null +++ b/workflow-canvas/e2e/tests/execution.spec.ts @@ -0,0 +1,256 @@ +// SPDX-License-Identifier: Apache-2.0 +import { expect, test } from '../support/fixtures'; +import { + answerRunsInTurn, + answerRunsWith, + refuseRunsWith, + runReply, +} from '../support/run-routes'; +import { STEP_TIMEOUT_MS } from '../support/workflow-canvas-page'; + +// The stub runs one run at a time. Only the first test here starts a run on +// it, and it waits for the end; the others answer the run calls themselves. + +test.beforeEach(async ({ canvas }) => { + await canvas.selectTab('Execution'); + await canvas.expectTabLoaded('Execution'); +}); + +test('draws the same graph as the Designer, read-only, with a Run button and no states', async ({ + canvas, +}) => { + await canvas.expectWorkflowDrawn(); + await expect(canvas.runButton).toBeEnabled(); + await expect(canvas.goal).toHaveValue('Add a greeting to the README'); + await expect(canvas.runStatus).toContainText('Ready. Press Run to start.'); + await canvas.expectBadges([null, null, null, null]); +}); + +test('runs the workflow on the stub: each stage goes from queued to running to done, in order', async ({ + canvas, +}) => { + await canvas.runButton.click(); + await expect(canvas.runButton).toBeDisabled(); + await expect(canvas.goal).not.toBeEditable(); + + await expect(canvas.badge('node-stage-draft')).toHaveText('Running', { + timeout: STEP_TIMEOUT_MS, + }); + await canvas.expectBadges(['Done', 'Running', 'Queued', 'Queued']); + await expect(canvas.runStatus).toContainText( + 'Running draft. 0 of 2 stages done.', + ); + await expect(canvas.runButton).toBeDisabled(); + + await expect(canvas.badge('node-stage-review')).toHaveText('Running', { + timeout: STEP_TIMEOUT_MS, + }); + await canvas.expectBadges(['Done', 'Done', 'Running', 'Queued']); + await expect(canvas.runStatus).toContainText( + 'Running review. 1 of 2 stages done.', + ); + + await expect(canvas.badge('node-end')).toHaveText('Passed', { + timeout: STEP_TIMEOUT_MS, + }); + await canvas.expectBadges(['Done', 'Done', 'Done', 'Passed']); + await expect(canvas.runStatus).toContainText( + 'Run passed. 2 of 2 stages done. Exit code 0.', + ); + await expect(canvas.runButton).toBeEnabled(); + await expect(canvas.runError).toHaveCount(0); +}); + +test('announces the status in a polite live region and names the state of each node', async ({ + canvas, +}) => { + await answerRunsWith( + canvas.page, + runReply('running', ['running', 'pending']), + ); + + await canvas.runButton.click(); + + await expect(canvas.runLiveStatus).toHaveAttribute('role', 'status'); + await expect(canvas.runLiveStatus).toContainText('Running draft.'); + await expect(canvas.runStatus).toContainText('Running draft.'); + await expect( + canvas.nodeNamed( + 'Stage draft, agent k-developer, harness kiro-cli, running', + ), + ).toBeVisible(); + await expect(canvas.nodeNamed('End, queued')).toBeVisible(); +}); + +test('shows a failed stage with its words, the stages that will not run, and the halted result', async ({ + canvas, +}) => { + // The stub never fails a stage, so the reply is made here. + await answerRunsWith(canvas.page, runReply('halted', ['blocked', 'pending'])); + + await canvas.runButton.click(); + + await canvas.expectBadges(['Done', 'Failed', 'Not run', 'Halted']); + await expect(canvas.runStatus).toContainText( + 'Run halted at draft. 0 of 2 stages done. Exit code 1.', + ); + await expect(canvas.runButton).toBeEnabled(); + await expect(canvas.runError).toHaveCount(0); +}); + +test('says so when the stages of the run are not the stages in the graph', async ({ + canvas, +}) => { + const reply = runReply('running', ['running', 'pending']); + const run = reply.run; + await answerRunsWith(canvas.page, { + run: { + ...run, + order: ['write', 'review'], + stages: { write: { status: 'running' }, review: { status: 'pending' } }, + }, + } as unknown as typeof reply); + + await canvas.runButton.click(); + + const notice = canvas.page.getByTestId('stage-drift'); + await expect(notice).toContainText('Not drawn: write.'); + await expect(notice).toContainText('Not in the run: draft.'); +}); + +test('shows the error of the API in one alert when the run does not start', async ({ + canvas, +}) => { + await refuseRunsWith(canvas.page, 409, { + error: 'kiro-cli is not installed.', + code: 'harness_unavailable', + }); + + await expect(canvas.runAlert).toBeEmpty(); + + await canvas.runButton.click(); + + await expect(canvas.runError).toHaveCount(1); + await expect(canvas.runError).toContainText('The run did not start'); + await expect(canvas.runError).toContainText('kiro-cli is not installed.'); + await expect(canvas.runStatus).toContainText('See the error below.'); + await expect(canvas.runAlert).toContainText('kiro-cli is not installed.'); + await canvas.expectBadges([null, null, null, null]); + await expect(canvas.runButton).toBeEnabled(); +}); + +test('says the run may still be going when it can no longer be followed', async ({ + canvas, +}) => { + await answerRunsWith( + canvas.page, + runReply('running', ['running', 'pending']), + ); + await canvas.page.route('**/api/runs/*', (route) => + route.fulfill({ + status: 404, + json: { error: 'There is no run with this id.', code: 'not_found' }, + }), + ); + + await canvas.runButton.click(); + + await expect(canvas.runError).toContainText('The run could not be followed'); + await expect(canvas.runAlert).toContainText('There is no run with this id.'); + await expect(canvas.runAlert).toContainText( + 'The run may still be going on the server (run 20261024-0412-a7b3c).', + ); +}); + +test('announces a failure to follow the run while the Designer tab is open', async ({ + canvas, +}) => { + await answerRunsWith( + canvas.page, + runReply('running', ['running', 'pending']), + ); + await canvas.runButton.click(); + await expect(canvas.runStatus).toContainText('Running draft.'); + await canvas.selectTab('Designer'); + await canvas.page.route('**/api/runs/*', (route) => + route.fulfill({ + status: 404, + json: { error: 'There is no run with this id.', code: 'not_found' }, + }), + ); + + await expect(canvas.runAlert).toContainText('The run could not be followed'); + await expect(canvas.runAlert).toContainText('There is no run with this id.'); + await expect(canvas.runError).toHaveCount(0); +}); + +test('keeps following the run while the Designer tab is open', async ({ + canvas, +}) => { + const server = await answerRunsInTurn(canvas.page, [ + runReply('running', ['running', 'pending']), + runReply('passed', ['passed', 'passed']), + ]); + await canvas.runButton.click(); + await canvas.expectBadges(['Done', 'Running', 'Queued', 'Queued']); + + await canvas.selectTab('Designer'); + await expect + .poll(server.reads, { timeout: STEP_TIMEOUT_MS }) + .toBeGreaterThanOrEqual(2); + await expect(canvas.page.getByTestId('status-badge')).toHaveCount(0); + await canvas.selectTab('Execution'); + + await canvas.expectBadges(['Done', 'Done', 'Done', 'Passed']); +}); + +test('keeps the goal that was typed when the person comes back from the Designer tab', async ({ + canvas, +}) => { + await canvas.goal.fill('Fix the typo'); + + await canvas.selectTab('Designer'); + await canvas.selectTab('Execution'); + + await expect(canvas.goal).toHaveValue('Fix the typo'); + await expect(canvas.runButton).toBeEnabled(); +}); + +test('shows the goal of the run in flight, read-only, when the person comes back from the Designer tab', async ({ + canvas, +}) => { + await answerRunsWith( + canvas.page, + runReply('running', ['running', 'pending']), + ); + await canvas.goal.fill('Fix the typo'); + const started = canvas.page.waitForRequest('**/api/run'); + + await canvas.runButton.click(); + await expect(canvas.runStatus).toContainText('Running draft.'); + expect((await started).postDataJSON()).toEqual({ + params: { goal: 'Fix the typo' }, + }); + + await canvas.selectTab('Designer'); + await canvas.selectTab('Execution'); + + await expect(canvas.goal).toHaveValue('Fix the typo'); + await expect(canvas.goal).not.toBeEditable(); + await expect(canvas.runButton).toBeDisabled(); +}); + +test('starts the run when Enter is pressed in the goal box, and keeps focus there', async ({ + canvas, +}) => { + await answerRunsWith( + canvas.page, + runReply('running', ['running', 'pending']), + ); + await canvas.goal.focus(); + + await canvas.goal.press('Enter'); + + await expect(canvas.runStatus).toContainText('Running draft.'); + await expect(canvas.goal).toBeFocused(); +}); diff --git a/workflow-canvas/src/App.test.tsx b/workflow-canvas/src/App.test.tsx index 2b2f389d..8e782fce 100644 --- a/workflow-canvas/src/App.test.tsx +++ b/workflow-canvas/src/App.test.tsx @@ -79,14 +79,14 @@ describe('App', () => { ).toBeTruthy(); }); - it('shows a placeholder on the Execution tab', () => { + it('draws the workflow with a Run button on the Execution tab', async () => { render(); fireEvent.click(screen.getByRole('tab', { name: 'Execution' })); - expect( - screen.getByRole('tabpanel', { name: 'Execution' }).textContent, - ).toContain('Nothing here yet.'); + const panel = screen.getByRole('tabpanel', { name: 'Execution' }); + expect(await within(panel).findByTestId('node-stage-draft')).toBeTruthy(); + expect(within(panel).getByRole('button', { name: 'Run' })).toBeTruthy(); }); it('keeps the loaded workflow when the tabs are switched back and forth', async () => { diff --git a/workflow-canvas/src/App.tsx b/workflow-canvas/src/App.tsx index 3f294dec..91c4a4a2 100644 --- a/workflow-canvas/src/App.tsx +++ b/workflow-canvas/src/App.tsx @@ -1,6 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 import AppLayout from '@cloudscape-design/components/app-layout'; -import Box from '@cloudscape-design/components/box'; import ContentLayout from '@cloudscape-design/components/content-layout'; import Header from '@cloudscape-design/components/header'; import Tabs from '@cloudscape-design/components/tabs'; @@ -10,28 +9,41 @@ import './chrome/chrome.css'; import { TOP_BAR_ID, TopBar } from './chrome/TopBar'; import { Designer } from './designer/Designer'; import { useSettled } from './designer/use-settled'; +import type { Scheduler } from './execution/core'; +import { Execution } from './execution/Execution'; +import { RunAnnouncements } from './execution/RunAnnouncements'; +import { timerScheduler } from './execution/timer-scheduler'; +import { useExecution } from './execution/use-execution'; +import { useGoal } from './execution/use-goal'; import { TAB_STRIP_STYLE } from './theme/tokens'; import { useThemeMode } from './theme/use-theme-mode'; import { openWorkflowName, type LoadResult } from './workflow/load-workflow'; -const placeholder = Nothing here yet.; - /** * Root component: the top bar, then the page header (breadcrumbs and title) - * with the Designer and Execution tabs. The Designer draws the workflow that - * `load` settles with; the Execution tab shows a placeholder until its - * content is built. + * with the Designer and Execution tabs. Both tabs draw the workflow that + * `load` settles with, and the Execution tab runs it, waiting with + * `scheduler` between two reads of the run. */ export function App({ load, notice, + scheduler = timerScheduler, }: { load: Promise; /** Shown above the tabs, such as the refusal of a pasted launch address. */ notice?: ReactNode; + scheduler?: Scheduler; }) { // Read here, not in the tab, so switching tabs does not load again. const result = useSettled(load); + // Kept here too, so a run goes on and the goal stays while the Designer tab + // is open. + const execution = useExecution( + result?.ok ? result.workflow.runs : undefined, + scheduler, + ); + const [goal, setGoal] = useGoal(result); const [mode, toggleMode] = useThemeMode(); return ( <> @@ -52,6 +64,7 @@ export function App({ header={
Workflow canvas
} > {notice} +
, }, - { id: 'execution', label: 'Execution', content: placeholder }, + { + id: 'execution', + label: 'Execution', + content: ( + + ), + }, ]} />
diff --git a/workflow-canvas/src/canvas/WorkflowGraphCard.tsx b/workflow-canvas/src/canvas/WorkflowGraphCard.tsx new file mode 100644 index 00000000..b43be92f --- /dev/null +++ b/workflow-canvas/src/canvas/WorkflowGraphCard.tsx @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: Apache-2.0 +import Box from '@cloudscape-design/components/box'; +import Container from '@cloudscape-design/components/container'; +import Header from '@cloudscape-design/components/header'; +import { useMemo } from 'react'; +import type { WorkflowView } from '../api/types'; +import { buildGraph, withStatuses, type NodeStatus } from '../graph'; +import { INTENT_FITS_IN_NODE, clippedStages, textWidth } from './clipping'; +import { WorkflowCanvas } from './WorkflowCanvas'; + +/** The text that the nodes cut short, in full, under the graph. */ +function FullText({ view, harness }: { view: WorkflowView; harness: string }) { + const stages = clippedStages(view, harness); + const showIntent = textWidth(view.intent.outcome) > INTENT_FITS_IN_NODE; + if (!showIntent && stages.length === 0) return null; + return ( + + {showIntent && ( + + Intent: {view.intent.outcome} + + )} + {stages.map(({ name, agent }) => ( + + Stage {name}: {agent} · {harness} + + ))} + + ); +} + +const NO_STATUSES: ReadonlyMap = new Map(); + +/** + * The card both tabs draw the workflow in: its name, then the read-only graph. + * `statuses` puts a live state on the nodes it names, by node id. + */ +export function WorkflowGraphCard({ + view, + harness, + statuses = NO_STATUSES, +}: { + view: WorkflowView; + harness: string; + statuses?: ReadonlyMap; +}) { + const graph = useMemo( + () => withStatuses(buildGraph(view, harness), statuses), + [view, harness, statuses], + ); + return ( + + Workflow: {view.name} + + } + > + + + + ); +} diff --git a/workflow-canvas/src/canvas/canvas.css b/workflow-canvas/src/canvas/canvas.css index 98cfae8c..527c4134 100644 --- a/workflow-canvas/src/canvas/canvas.css +++ b/workflow-canvas/src/canvas/canvas.css @@ -3,6 +3,8 @@ /* * The colours of the parts that the canvas draws itself, for the light and the * dark mode. The dark values apply when the page root has data-theme="dark". + * The --wf-state-* pairs colour the state of a node in a run: the text and the + * outline of a state, and the fill behind its label. * A test (canvas-contrast.test.ts) reads this file, checks that the text, the * edges and the ports reach WCAG AA in both modes, and that the colours and * radii match the theme module. @@ -26,6 +28,14 @@ --wf-controls-bg: #ffffff; --wf-controls-bg-hover: #efebff; --wf-controls-border: #cfc7ba; + --wf-state-queued: #56516a; + --wf-state-queued-bg: #faf8f4; + --wf-state-running: #6236f0; + --wf-state-running-bg: #efebff; + --wf-state-done: #0b7a52; + --wf-state-done-bg: #e7f5ee; + --wf-state-failed: #b3261e; + --wf-state-failed-bg: #fdecec; border-radius: 12px; background: var(--wf-canvas-bg); @@ -50,6 +60,14 @@ --wf-controls-bg: #171331; --wf-controls-bg-hover: #1f1a44; --wf-controls-border: #3a3168; + --wf-state-queued: #9c93b8; + --wf-state-queued-bg: #1c1740; + --wf-state-running: #a78bfa; + --wf-state-running-bg: #1f1a44; + --wf-state-done: #3ddc97; + --wf-state-done-bg: #10281f; + --wf-state-failed: #ff8b8b; + --wf-state-failed-bg: #2b1722; } .wf-canvas .react-flow__attribution a { @@ -230,6 +248,63 @@ pointer-events: none; } +/* + * The state of a node in a run. The word in the badge is what says the state; + * the colour and the outline only back it up. A queued node has a dashed + * outline, so the states differ without colour too. + */ +.wf-badge { + position: absolute; + bottom: 10px; + left: 14px; + padding: 0 8px; + border: 1.5px solid var(--wf-badge-color); + border-radius: 8px; + background: var(--wf-badge-bg); + color: var(--wf-badge-color); + font-size: 11px; + font-weight: 700; + line-height: 1.5; +} + +.wf-node-end .wf-badge { + top: 50%; + bottom: auto; + transform: translateY(-50%); +} + +.wf-node { + --wf-badge-color: var(--wf-state-queued); + --wf-badge-bg: var(--wf-state-queued-bg); +} + +.wf-node[data-state='running'] { + --wf-badge-color: var(--wf-state-running); + --wf-badge-bg: var(--wf-state-running-bg); +} + +.wf-node[data-state='done'] { + --wf-badge-color: var(--wf-state-done); + --wf-badge-bg: var(--wf-state-done-bg); +} + +.wf-node[data-state='failed'] { + --wf-badge-color: var(--wf-state-failed); + --wf-badge-bg: var(--wf-state-failed-bg); +} + +.wf-node[data-state] .wf-node-body { + stroke: var(--wf-badge-color); +} + +.wf-node[data-state='queued'] .wf-node-body { + stroke-dasharray: 6 4; +} + +.wf-node[data-state='queued'] .wf-badge { + border-style: dashed; +} + /* * In forced-colors mode the card takes the system colours, so its text does * not sit on a patch of the dark fill. After the rules above, to win over them. diff --git a/workflow-canvas/src/designer/clipping.test.ts b/workflow-canvas/src/canvas/clipping.test.ts similarity index 100% rename from workflow-canvas/src/designer/clipping.test.ts rename to workflow-canvas/src/canvas/clipping.test.ts diff --git a/workflow-canvas/src/designer/clipping.ts b/workflow-canvas/src/canvas/clipping.ts similarity index 100% rename from workflow-canvas/src/designer/clipping.ts rename to workflow-canvas/src/canvas/clipping.ts diff --git a/workflow-canvas/src/canvas/nodes.tsx b/workflow-canvas/src/canvas/nodes.tsx index f606a6f5..39922e2e 100644 --- a/workflow-canvas/src/canvas/nodes.tsx +++ b/workflow-canvas/src/canvas/nodes.tsx @@ -5,7 +5,9 @@ import { NODE_WIDTH, SEQUENCE_IN, SEQUENCE_OUT, + type EndGraphNode, type IntentGraphNode, + type NodeStatus, type StageGraphNode, } from '../graph'; import { @@ -103,6 +105,16 @@ function Tab({ top }: { top: number }) { ); } +// The state of a node in a run, as a word in a coloured pill. +function StatusBadge({ status }: { status: NodeStatus | undefined }) { + if (!status) return null; + return ( + + {status.label} + + ); +} + /** * The card behind the content of a node: one rounded outline, with a notch * just inside the top edge when run order comes in and a tab hanging from the @@ -166,7 +178,11 @@ function NodeShape({ /** The start of the workflow: the outcome it is for, with a run order out. */ export function IntentNode({ data }: NodeProps>) { return ( -
+
>) {
{data.outcome}
+
); @@ -188,6 +205,7 @@ export function StageNode({ data }: NodeProps>) {
@@ -197,15 +215,20 @@ export function StageNode({ data }: NodeProps>) {
{data.agent} · {data.harness}
+
); } -/** The finish of the workflow, with a run order in. */ -export function EndNode() { +/** The finish of the workflow, with a run order in and, after a run, its result. */ +export function EndNode({ data }: NodeProps>) { return ( -
+
end
+
); } diff --git a/workflow-canvas/src/designer/Designer.tsx b/workflow-canvas/src/designer/Designer.tsx index 0bc5989c..07446f1e 100644 --- a/workflow-canvas/src/designer/Designer.tsx +++ b/workflow-canvas/src/designer/Designer.tsx @@ -1,75 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 -import Box from '@cloudscape-design/components/box'; -import Container from '@cloudscape-design/components/container'; -import Header from '@cloudscape-design/components/header'; -import { useMemo } from 'react'; -import { WorkflowCanvas } from '../canvas/WorkflowCanvas'; -import { buildGraph } from '../graph'; -import type { WorkflowView } from '../api/types'; -import type { LoadedWorkflow, LoadResult } from '../workflow/load-workflow'; -import { INTENT_FITS_IN_NODE, clippedStages, textWidth } from './clipping'; -import { EmptyState, ErrorState, LoadingState } from './states'; -import { useChangeCount } from './use-change-count'; - -/** The text that the nodes cut short, in full, under the graph. */ -function FullText({ view, harness }: { view: WorkflowView; harness: string }) { - const stages = clippedStages(view, harness); - const showIntent = textWidth(view.intent.outcome) > INTENT_FITS_IN_NODE; - if (!showIntent && stages.length === 0) return null; - return ( - - {showIntent && ( - - Intent: {view.intent.outcome} - - )} - {stages.map(({ name, agent }) => ( - - Stage {name}: {agent} · {harness} - - ))} - - ); -} - -function DrawnGraph({ - view, - harness, -}: { - view: WorkflowView; - harness: string; -}) { - const graph = useMemo(() => buildGraph(view, harness), [view, harness]); - return ( - - Workflow: {view.name} - - } - > - - - - ); -} - -function DrawnWorkflow({ workflow }: { workflow: LoadedWorkflow }) { - const { document, harness } = workflow; - const { view } = document; - if (!view) { - return ( - - ); - } - return ; -} +import { WorkflowGraphCard } from '../canvas/WorkflowGraphCard'; +import type { LoadResult } from '../workflow/load-workflow'; +import { WorkflowGate } from '../workflow/WorkflowGate'; /** * The Designer tab: the workflow drawn as a graph, or a message while it @@ -77,9 +9,11 @@ function DrawnWorkflow({ workflow }: { workflow: LoadedWorkflow }) { * nothing to draw. */ export function Designer({ result }: { result: LoadResult | undefined }) { - // A new workflow gets a new canvas, which is fitted to it when it mounts. - const swaps = useChangeCount(result); - if (!result) return ; - if (!result.ok) return ; - return ; + return ( + + {({ view, harness }) => ( + + )} + + ); } diff --git a/workflow-canvas/src/execution/Execution.test.tsx b/workflow-canvas/src/execution/Execution.test.tsx new file mode 100644 index 00000000..900dbc5d --- /dev/null +++ b/workflow-canvas/src/execution/Execution.test.tsx @@ -0,0 +1,810 @@ +// SPDX-License-Identifier: Apache-2.0 +import { + act, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { App } from '../App'; +import { Execution } from './Execution'; +import type { RunView } from '../api/types'; +import { FakeScheduler } from '../test-support/fake-scheduler'; +import { HELLO_DOCUMENT, helloRun, loaded } from '../test-support/fixtures'; +import { + IDLE, + MAX_READ_FAILURES, + RunReadRefused, + RunStartUnsure, + type RunPort, +} from './core'; + +const RUN_ID = helloRun(0).runId; +const MAY_STILL_BE_RUNNING = `The run may still be going on the server (run ${RUN_ID}).`; + +// A server that answers with the readings in turn and then repeats the last. +function serverShowing(...readings: RunView[]): RunPort { + let next = 0; + return { + start: vi.fn().mockResolvedValue(RUN_ID), + get: vi.fn(async () => readings[Math.min(next++, readings.length - 1)]!), + }; +} + +async function openExecution(runs: RunPort) { + const scheduler = new FakeScheduler(); + const { unmount } = render( + , + ); + await screen.findByTestId('node-end'); + fireEvent.click(screen.getByRole('tab', { name: 'Execution' })); + await screen.findByRole('button', { name: 'Run' }); + return Object.assign(scheduler, { unmount }); +} + +const runButton = () => screen.getByRole('button', { name: 'Run' }); +const isOff = (button: HTMLElement) => + button.hasAttribute('disabled') || + button.getAttribute('aria-disabled') === 'true'; +const statusLine = () => screen.getByTestId('run-status'); +const badgeOf = (testId: string) => + within(screen.getByTestId(testId)).queryByTestId('status-badge') + ?.textContent ?? null; +const badges = () => [ + badgeOf('node-intent'), + badgeOf('node-stage-draft'), + badgeOf('node-stage-review'), + badgeOf('node-end'), +]; + +async function press(button: HTMLElement) { + await act(async () => { + fireEvent.click(button); + }); +} + +async function tick(scheduler: FakeScheduler) { + await act(async () => { + scheduler.tick(); + }); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('the Execution tab before a run', () => { + it('draws the same graph as the Designer, read-only, with a Run button', async () => { + await openExecution(serverShowing(helloRun(0))); + + const graph = screen.getByRole('region', { name: 'Workflow graph' }); + for (const id of [ + 'node-intent', + 'node-stage-draft', + 'node-stage-review', + 'node-end', + ]) { + expect(within(graph).getByTestId(id)).toBeTruthy(); + } + expect( + screen.getByRole('heading', { name: 'Workflow: hello' }), + ).toBeTruthy(); + expect(isOff(runButton())).toBe(false); + }); + + it('shows no state on any node and says it is ready', async () => { + await openExecution(serverShowing(helloRun(0))); + + expect(badges()).toEqual([null, null, null, null]); + expect(statusLine().textContent).toContain('Ready. Press Run to start.'); + }); + + it('offers the outcome of the intent as the goal', async () => { + await openExecution(serverShowing(helloRun(0))); + + expect((screen.getByLabelText('Goal') as HTMLInputElement).value).toBe( + 'Add a greeting to the README', + ); + }); + + it('announces the status in a polite live region that is always there', async () => { + await openExecution(serverShowing(helloRun(0))); + + const live = screen.getByTestId('run-live-status'); + expect(live.getAttribute('role')).toBe('status'); + expect(live.textContent).toBe('Ready. Press Run to start.'); + }); + + it('keeps the visible status line out of the live regions, so nothing is said twice', async () => { + await openExecution(serverShowing(helloRun(0))); + + expect(statusLine().closest('[role="status"], [role="alert"]')).toBeNull(); + expect(screen.getAllByRole('status')).toHaveLength(1); + }); +}); + +describe('a run', () => { + it('starts with the goal the person typed', async () => { + const runs = serverShowing(helloRun(0)); + await openExecution(runs); + + fireEvent.change(screen.getByLabelText('Goal'), { + target: { value: 'Fix the typo' }, + }); + await press(runButton()); + + expect(runs.start).toHaveBeenCalledOnce(); + expect(runs.start).toHaveBeenCalledWith( + 'Fix the typo', + expect.any(AbortSignal), + ); + }); + + it('shows each node going from queued to running to done, in order', async () => { + const scheduler = await openExecution( + serverShowing(helloRun(0), helloRun(1), helloRun(2)), + ); + + await press(runButton()); + expect(badges()).toEqual(['Done', 'Running', 'Queued', 'Queued']); + expect(statusLine().textContent).toContain( + 'Running draft. 0 of 2 stages done.', + ); + + await tick(scheduler); + expect(badges()).toEqual(['Done', 'Done', 'Running', 'Queued']); + expect(statusLine().textContent).toContain( + 'Running review. 1 of 2 stages done.', + ); + + await tick(scheduler); + expect(badges()).toEqual(['Done', 'Done', 'Done', 'Passed']); + expect(statusLine().textContent).toContain( + 'Run passed. 2 of 2 stages done. Exit code 0.', + ); + }); + + it('puts the state in the name a screen reader reads for each node', async () => { + await openExecution(serverShowing(helloRun(0))); + + await press(runButton()); + + const graph = screen.getByRole('region', { name: 'Workflow graph' }); + expect( + within(graph).getByRole('group', { + name: 'Stage draft, agent k-developer, harness kiro-cli, running', + }), + ).toBeTruthy(); + expect( + within(graph).getByRole('group', { name: 'End, queued' }), + ).toBeTruthy(); + }); + + it('reads the run once a second, with no read in between', async () => { + const runs = serverShowing(helloRun(0), helloRun(1), helloRun(2)); + const scheduler = await openExecution(runs); + + await press(runButton()); + expect(runs.get).toHaveBeenCalledTimes(1); + expect(scheduler.waits).toEqual([1000]); + + await tick(scheduler); + await tick(scheduler); + + expect(runs.get).toHaveBeenCalledTimes(3); + expect(scheduler.waits).toEqual([1000, 1000]); + expect(runs.get).toHaveBeenCalledWith(RUN_ID, expect.any(AbortSignal)); + }); + + it('cannot be started again while it is in flight, and can after it ends', async () => { + const runs = serverShowing(helloRun(0), helloRun(2)); + const scheduler = await openExecution(runs); + + await press(runButton()); + expect(isOff(runButton())).toBe(true); + expect(screen.getByLabelText('Goal').hasAttribute('readonly')).toBe(true); + await press(runButton()); + expect(runs.start).toHaveBeenCalledOnce(); + + await tick(scheduler); + expect(isOff(runButton())).toBe(false); + expect(screen.getByLabelText('Goal').hasAttribute('readonly')).toBe(false); + }); + + it('keeps keyboard focus in the goal box when Enter starts the run', async () => { + await openExecution(serverShowing(helloRun(0))); + const goal = screen.getByLabelText('Goal'); + goal.focus(); + + await act(async () => { + fireEvent.keyDown(goal, { key: 'Enter', keyCode: 13 }); + }); + + expect(statusLine().textContent).toContain('Running draft.'); + expect(document.activeElement).toBe(goal); + expect(goal.hasAttribute('disabled')).toBe(false); + }); + + it('disables Run from the moment of the click, before the run is accepted', async () => { + let accept: (runId: string) => void = () => {}; + const runs: RunPort = { + start: () => new Promise((resolve) => (accept = resolve)), + get: async () => helloRun(0), + }; + await openExecution(runs); + + await press(runButton()); + + expect(isOff(runButton())).toBe(true); + expect(statusLine().textContent).toContain('Starting the run.'); + await act(async () => accept(RUN_ID)); + expect(statusLine().textContent).toContain('Running draft.'); + }); + + it('shows a failed stage, the stages that will not run and the halted result', async () => { + const scheduler = await openExecution( + serverShowing(helloRun(0), helloRun(0, { blockedAt: 0 })), + ); + + await press(runButton()); + await tick(scheduler); + + expect(badges()).toEqual(['Done', 'Failed', 'Not run', 'Halted']); + expect(statusLine().textContent).toContain( + 'Run halted at draft. 0 of 2 stages done. Exit code 1.', + ); + expect(isOff(runButton())).toBe(false); + }); + + it('shows no error alert when a stage fails: the failure is a result, not an error', async () => { + await openExecution(serverShowing(helloRun(0, { blockedAt: 1 }))); + + await press(runButton()); + + expect(screen.queryByTestId('run-error')).toBeNull(); + }); + + it('can be run again, starting from the first stage', async () => { + const runs = serverShowing(helloRun(2)); + await openExecution(runs); + await press(runButton()); + expect(badges()).toEqual(['Done', 'Done', 'Done', 'Passed']); + + runs.get = vi.fn().mockResolvedValue(helloRun(0)); + await press(runButton()); + + expect(runs.start).toHaveBeenCalledTimes(2); + expect(badges()).toEqual(['Done', 'Running', 'Queued', 'Queued']); + }); + + it('goes on while the Designer tab is open, and shows the result when back', async () => { + const scheduler = await openExecution( + serverShowing(helloRun(0), helloRun(2)), + ); + await press(runButton()); + + fireEvent.click(screen.getByRole('tab', { name: 'Designer' })); + await tick(scheduler); + fireEvent.click(screen.getByRole('tab', { name: 'Execution' })); + + expect(badges()).toEqual(['Done', 'Done', 'Done', 'Passed']); + expect(statusLine().textContent).toContain('Run passed.'); + }); + + it('shows no state on the Designer tab', async () => { + await openExecution(serverShowing(helloRun(0))); + await press(runButton()); + + fireEvent.click(screen.getByRole('tab', { name: 'Designer' })); + + expect(screen.queryAllByTestId('status-badge')).toEqual([]); + }); + + it('stops reading when the page is closed', async () => { + const runs = serverShowing(helloRun(0), helloRun(1), helloRun(2)); + const scheduler = await openExecution(runs); + await press(runButton()); + + scheduler.unmount(); + await tick(scheduler); + + expect(runs.get).toHaveBeenCalledTimes(1); + }); +}); + +describe('the goal', () => { + const goalBox = () => screen.getByLabelText('Goal') as HTMLInputElement; + const openTab = (name: string) => + fireEvent.click(screen.getByRole('tab', { name })); + + it('is kept when the person goes to the Designer tab and comes back', async () => { + await openExecution(serverShowing(helloRun(0))); + fireEvent.change(goalBox(), { target: { value: 'Fix the typo' } }); + + openTab('Designer'); + openTab('Execution'); + + expect(goalBox().value).toBe('Fix the typo'); + expect(isOff(runButton())).toBe(false); + }); + + it('shows the goal of the run in flight, read-only, when the person comes back', async () => { + const runs = serverShowing(helloRun(0)); + await openExecution(runs); + fireEvent.change(goalBox(), { target: { value: 'Fix the typo' } }); + await press(runButton()); + + openTab('Designer'); + openTab('Execution'); + + expect(goalBox().value).toBe('Fix the typo'); + expect(goalBox().readOnly).toBe(true); + expect(isOff(runButton())).toBe(true); + expect(runs.start).toHaveBeenCalledWith( + 'Fix the typo', + expect.any(AbortSignal), + ); + }); + + it('does not start a run when Enter ends the composition of a character', async () => { + const runs = serverShowing(helloRun(0)); + await openExecution(runs); + + await act(async () => { + fireEvent.keyDown(goalBox(), { + key: 'Enter', + keyCode: 13, + isComposing: true, + }); + }); + + expect(runs.start).not.toHaveBeenCalled(); + }); + + it('does not start a run when Enter ends a composition that Safari reports late', async () => { + const runs = serverShowing(helloRun(0)); + await openExecution(runs); + + await act(async () => { + fireEvent.keyDown(goalBox(), { + key: 'Enter', + keyCode: 229, + isComposing: false, + }); + }); + + expect(runs.start).not.toHaveBeenCalled(); + }); + + it('offers a goal of one line when the outcome of the intent has line breaks', async () => { + const many = { + ...HELLO_DOCUMENT, + view: { + ...HELLO_DOCUMENT.view!, + intent: { outcome: 'Fix the typo\n and add a test\r\nthen ship' }, + }, + }; + const runs = serverShowing(helloRun(0)); + render( + , + ); + await screen.findByTestId('node-end'); + openTab('Execution'); + await screen.findByRole('button', { name: 'Run' }); + + expect(goalBox().value).toBe('Fix the typo and add a test then ship'); + await press(runButton()); + + expect(runs.start).toHaveBeenCalledWith( + 'Fix the typo and add a test then ship', + expect.any(AbortSignal), + ); + }); + + it('starts a run when Enter is pressed in the goal box', async () => { + const runs = serverShowing(helloRun(0)); + await openExecution(runs); + + await act(async () => { + fireEvent.keyDown(goalBox(), { key: 'Enter', keyCode: 13 }); + }); + + expect(runs.start).toHaveBeenCalledOnce(); + }); + + it('does not start a run on Enter when the goal is empty', async () => { + const runs = serverShowing(helloRun(0)); + await openExecution(runs); + fireEvent.change(goalBox(), { target: { value: '' } }); + + await act(async () => { + fireEvent.keyDown(goalBox(), { key: 'Enter', keyCode: 13 }); + }); + + expect(runs.start).not.toHaveBeenCalled(); + }); + + it('does not start a run on Enter while a run is in flight', async () => { + const runs = serverShowing(helloRun(0)); + await openExecution(runs); + await press(runButton()); + + await act(async () => { + fireEvent.keyDown(goalBox(), { key: 'Enter', keyCode: 13 }); + }); + + expect(runs.start).toHaveBeenCalledOnce(); + }); + + it('turns Run off, and says why, when the goal is empty', async () => { + const runs = serverShowing(helloRun(0)); + await openExecution(runs); + + fireEvent.change(screen.getByLabelText('Goal'), { + target: { value: ' ' }, + }); + + expect(isOff(runButton())).toBe(true); + expect(screen.getAllByText('Enter a goal to run.').length).toBeGreaterThan( + 0, + ); + await press(runButton()); + expect(runs.start).not.toHaveBeenCalled(); + }); + + it('turns Run off when the goal is too long', async () => { + await openExecution(serverShowing(helloRun(0))); + + fireEvent.change(screen.getByLabelText('Goal'), { + target: { value: 'x'.repeat(2049) }, + }); + + expect(isOff(runButton())).toBe(true); + expect( + screen.getAllByText('The goal can have at most 2048 characters.').length, + ).toBeGreaterThan(0); + }); +}); + +describe('errors from the API', () => { + it('shows one alert with the sentence of the server when the run does not start', async () => { + const runs: RunPort = { + start: vi.fn().mockRejectedValue(new Error('A run is already active.')), + get: vi.fn(), + }; + await openExecution(runs); + + await press(runButton()); + + const alert = screen.getByTestId('run-error'); + expect(within(alert).getByText('The run did not start')).toBeTruthy(); + expect(within(alert).getByText('A run is already active.')).toBeTruthy(); + expect(screen.getAllByTestId('run-error')).toHaveLength(1); + expect(within(alert).queryByText(MAY_STILL_BE_RUNNING)).toBeNull(); + expect(statusLine().textContent).toContain('See the error below.'); + expect(badges()).toEqual([null, null, null, null]); + expect(isOff(runButton())).toBe(false); + }); + + it('shows one alert, and clears the states, when a reading fails', async () => { + const runs: RunPort = { + start: vi.fn().mockResolvedValue(RUN_ID), + get: vi + .fn() + .mockResolvedValueOnce(helloRun(0)) + .mockRejectedValue(new Error('The server did not answer.')), + }; + const scheduler = await openExecution(runs); + await press(runButton()); + + for (let attempt = 0; attempt < MAX_READ_FAILURES; attempt += 1) { + expect(screen.queryByTestId('run-error')).toBeNull(); + await tick(scheduler); + } + + const alert = screen.getByTestId('run-error'); + expect( + within(alert).getByText('The run could not be followed'), + ).toBeTruthy(); + expect(within(alert).getByText('The server did not answer.')).toBeTruthy(); + expect(within(alert).getByText(MAY_STILL_BE_RUNNING)).toBeTruthy(); + expect(screen.getAllByTestId('run-error')).toHaveLength(1); + expect(statusLine().textContent).toContain('See the error below.'); + expect(badges()).toEqual([null, null, null, null]); + expect(isOff(runButton())).toBe(false); + }); + + it('puts the alert under the Run button', async () => { + const runs: RunPort = { + start: vi.fn().mockRejectedValue(new Error('A run is already active.')), + get: vi.fn(), + }; + await openExecution(runs); + + await press(runButton()); + + expect( + runButton().compareDocumentPosition(screen.getByTestId('run-error')) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + + it('names the run in the alert when the following failed', async () => { + const runs: RunPort = { + start: vi.fn().mockResolvedValue(RUN_ID), + get: vi + .fn() + .mockResolvedValueOnce(helloRun(0)) + .mockRejectedValue(new RunReadRefused('There is no run with this id.')), + }; + const scheduler = await openExecution(runs); + await press(runButton()); + + await tick(scheduler); + + expect( + within(screen.getByTestId('run-error')).getByText( + `The run may still be going on the server (run ${RUN_ID}).`, + ), + ).toBeTruthy(); + }); + + it('says the run may not have started when the start got no clear answer', async () => { + const runs: RunPort = { + start: vi.fn().mockRejectedValue(new RunStartUnsure('No answer.')), + get: vi.fn(), + }; + await openExecution(runs); + + await press(runButton()); + + const error = screen.getByTestId('run-error'); + expect(error.textContent).toContain('The run may not have started'); + expect(error.textContent).toContain('Press Run to join it.'); + expect(screen.getByTestId('run-live-alert').textContent).toBe( + 'The run may not have started. No answer. The run may be going on the server. Press Run to join it.', + ); + }); +}); + +describe('the live regions', () => { + const alertLive = () => screen.getByTestId('run-live-alert'); + const statusLive = () => screen.getByTestId('run-live-status'); + + it('are on the page before any run, and the alert one is empty', async () => { + await openExecution(serverShowing(helloRun(0))); + + expect(alertLive().getAttribute('role')).toBe('alert'); + expect(alertLive().textContent).toBe(''); + expect(statusLive().getAttribute('role')).toBe('status'); + }); + + it('say why a run did not start', async () => { + const runs: RunPort = { + start: vi.fn().mockRejectedValue(new Error('A run is already active.')), + get: vi.fn(), + }; + await openExecution(runs); + + await press(runButton()); + + expect(alertLive().textContent).toBe( + 'The run did not start. A run is already active.', + ); + expect(statusLive().textContent).toBe(''); + }); + + it('say that the run ended, while the Designer tab is open', async () => { + const runs = serverShowing(helloRun(0), helloRun(1), helloRun(2)); + const scheduler = await openExecution(runs); + await press(runButton()); + + fireEvent.click(screen.getByRole('tab', { name: 'Designer' })); + expect(screen.queryByTestId('run-status')).toBeNull(); + await tick(scheduler); + await tick(scheduler); + + expect(statusLive().textContent).toBe( + 'Run passed. 2 of 2 stages done. Exit code 0.', + ); + }); + + it('say that the run could not be followed, while the Designer tab is open', async () => { + const runs: RunPort = { + start: vi.fn().mockResolvedValue(RUN_ID), + get: vi + .fn() + .mockResolvedValueOnce(helloRun(0)) + .mockRejectedValue(new RunReadRefused('There is no run with this id.')), + }; + const scheduler = await openExecution(runs); + await press(runButton()); + + fireEvent.click(screen.getByRole('tab', { name: 'Designer' })); + expect(screen.queryByTestId('run-error')).toBeNull(); + await tick(scheduler); + + expect(alertLive().textContent).toBe( + `The run could not be followed. There is no run with this id. The run may still be going on the server (run ${RUN_ID}).`, + ); + expect(statusLive().textContent).toBe(''); + }); +}); + +describe('the alert, when another run is started', () => { + it('takes the alert away when a new run starts', async () => { + const runs: RunPort = { + start: vi + .fn() + .mockRejectedValueOnce(new Error('A run is already active.')) + .mockResolvedValue(RUN_ID), + get: vi.fn().mockResolvedValue(helloRun(0)), + }; + await openExecution(runs); + await press(runButton()); + expect(screen.getByTestId('run-error')).toBeTruthy(); + + await press(runButton()); + + await waitFor(() => expect(screen.queryByTestId('run-error')).toBeNull()); + expect(badges()).toEqual(['Done', 'Running', 'Queued', 'Queued']); + }); +}); + +describe('a run whose stages are not the stages of the graph', () => { + const OTHER_STAGES: RunView = { + ...helloRun(0), + stages: [ + { name: 'write', status: 'running' }, + { name: 'review', status: 'pending' }, + ], + }; + const notice = () => screen.queryByTestId('stage-drift'); + + it('says so, naming the stages that differ, once the run is read', async () => { + const scheduler = await openExecution(serverShowing(OTHER_STAGES)); + expect(notice()).toBeNull(); + + await press(runButton()); + await tick(scheduler); + + expect(notice()?.textContent).toContain('not the stages in the graph'); + expect(notice()?.textContent).toContain('Not drawn: write.'); + expect(notice()?.textContent).toContain('Not in the run: draft.'); + }); + + it('says the file changed after the page loaded or after the run started, and tells the person to reload the page', async () => { + const scheduler = await openExecution(serverShowing(OTHER_STAGES)); + + await press(runButton()); + await tick(scheduler); + + expect(notice()?.textContent).toContain('after this page loaded'); + expect(notice()?.textContent).toContain('after the run started'); + expect(notice()?.textContent).toContain('Reload the page to draw it.'); + expect(notice()?.textContent).not.toMatch(/launch address/i); + }); + + it('says nothing for a run of the stages that are drawn', async () => { + const scheduler = await openExecution(serverShowing(helloRun(0))); + + await press(runButton()); + await tick(scheduler); + + expect(statusLine().textContent).toContain('Running draft'); + expect(notice()).toBeNull(); + }); +}); + +describe('when there is no workflow to run', () => { + it('shows the message of the failed load, with no Run button', async () => { + render( + , + ); + await screen.findByText('The code is not valid.'); + + fireEvent.click(screen.getByRole('tab', { name: 'Execution' })); + + expect( + screen.getAllByText('The code is not valid.').length, + ).toBeGreaterThan(0); + expect(screen.queryByRole('button', { name: 'Run' })).toBeNull(); + }); + + it('says it is loading while the load is under way', () => { + render( + {})} scheduler={new FakeScheduler()} />, + ); + + fireEvent.click(screen.getByRole('tab', { name: 'Execution' })); + + expect(screen.getAllByText('Loading the workflow').length).toBeGreaterThan( + 0, + ); + expect(screen.queryByRole('button', { name: 'Run' })).toBeNull(); + }); +}); + +describe('when the workflow is replaced', () => { + it('draws a new canvas, so it is fitted to the new workflow', () => { + const controller = { state: IDLE, start: vi.fn() }; + const draw = () => ( + + ); + const { rerender } = render(draw()); + const before = screen.getByRole('region', { name: 'Workflow graph' }); + + rerender(draw()); + + expect(screen.getByRole('region', { name: 'Workflow graph' })).not.toBe( + before, + ); + }); +}); + +describe('when another workflow is opened during a run', () => { + async function openSecondWorkflowMidRun() { + const scheduler = new FakeScheduler(); + const first = serverShowing(helloRun(0)); + const second = serverShowing(helloRun(0)); + const view = (runs: RunPort) => ( + + ); + const { rerender } = render(view(first)); + await screen.findByTestId('node-end'); + fireEvent.click(screen.getByRole('tab', { name: 'Execution' })); + await press(await screen.findByRole('button', { name: 'Run' })); + await tick(scheduler); + expect(statusLine().textContent).toContain('Running draft'); + + rerender(view(second)); + await waitFor(() => + expect(statusLine().textContent).toContain('Ready. Press Run to start.'), + ); + return { scheduler, first, second }; + } + + it('lets go of the run and clears its status', async () => { + await openSecondWorkflowMidRun(); + + expect(badges()).toEqual([null, null, null, null]); + expect(isOff(runButton())).toBe(false); + }); + + it('stops reading the run of the workflow that was left', async () => { + const { scheduler, first } = await openSecondWorkflowMidRun(); + const reads = vi.mocked(first.get).mock.calls.length; + + await tick(scheduler); + + expect(vi.mocked(first.get).mock.calls).toHaveLength(reads); + }); + + it('starts the next run on the workflow that is open', async () => { + const { first, second } = await openSecondWorkflowMidRun(); + + await press(runButton()); + + expect(second.start).toHaveBeenCalledOnce(); + expect(vi.mocked(first.start)).toHaveBeenCalledTimes(1); + }); +}); diff --git a/workflow-canvas/src/execution/Execution.tsx b/workflow-canvas/src/execution/Execution.tsx new file mode 100644 index 00000000..e5381a5c --- /dev/null +++ b/workflow-canvas/src/execution/Execution.tsx @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: Apache-2.0 +import Alert from '@cloudscape-design/components/alert'; +import Button from '@cloudscape-design/components/button'; +import Container from '@cloudscape-design/components/container'; +import FormField from '@cloudscape-design/components/form-field'; +import Header from '@cloudscape-design/components/header'; +import Input, { type InputProps } from '@cloudscape-design/components/input'; +import SpaceBetween from '@cloudscape-design/components/space-between'; +import StatusIndicator, { + type StatusIndicatorProps, +} from '@cloudscape-design/components/status-indicator'; +import { useMemo } from 'react'; +import type { RunView, WorkflowView } from '../api/types'; +import { WorkflowGraphCard } from '../canvas/WorkflowGraphCard'; +import type { LoadResult } from '../workflow/load-workflow'; +import { WorkflowGate, type DrawableWorkflow } from '../workflow/WorkflowGate'; +import { + GOAL_MAX_LENGTH, + goalProblem, + isInFlight, + nodeStatuses, + runOf, + stageDrift, + statusKind, + statusText, + type ExecutionState, + type StatusKind, +} from './core'; +import './execution.css'; +import { runErrorOf } from './run-error'; +import type { ExecutionController } from './use-execution'; + +// Safari reports the Enter that commits a composition after it has ended, with +// `isComposing` false and the key code 229, so both are checked. +function isPlainEnter(key: InputProps.KeyDetail): boolean { + return key.key === 'Enter' && !key.isComposing && key.keyCode !== 229; +} + +const INDICATOR: Record = { + idle: 'pending', + busy: 'in-progress', + success: 'success', + error: 'error', +}; + +// The sentence that says where the run is. A screen reader hears the same +// words from the live region that `RunAnnouncements` keeps outside the tabs. +function RunStatusLine({ state }: { state: ExecutionState }) { + return ( +
+ + {statusText(state)} + +
+ ); +} + +// The alert sits under the Run button, so it is next to the control that was +// pressed on a narrow screen too. +function RunError({ state }: { state: ExecutionState }) { + const error = runErrorOf(state); + if (!error) return null; + return ( + + {error.message} + {error.note &&
{error.note}
} +
+ ); +} + +// A run reads the saved file, which can change while it goes on, so its stages +// can differ from the graph, and then some nodes show no state. +function StageDriftNotice({ + view, + run, +}: { + view: WorkflowView; + run: RunView | undefined; +}) { + const drift = stageDrift(view, run); + if (!drift) return null; + const { notInGraph, notInRun } = drift; + return ( + + The stages of this run are not the stages in the graph, so some nodes show + no state. The workflow file changed after this page loaded or after the + run started. Reload the page to draw it. + {notInGraph.length > 0 && ` Not drawn: ${notInGraph.join(', ')}.`} + {notInRun.length > 0 && ` Not in the run: ${notInRun.join(', ')}.`} + + ); +} + +function RunPanel({ + goal, + onGoalChange, + execution, +}: { + goal: string; + onGoalChange: (goal: string) => void; + execution: ExecutionController; +}) { + const inFlight = isInFlight(execution.state); + const problem = goalProblem(goal); + const canRun = !inFlight && problem === undefined; + return ( + Run}> + + + onGoalChange(detail.value)} + onKeyDown={({ detail }) => { + if (isPlainEnter(detail) && canRun) { + execution.start(goal); + } + }} + /> + + + + + + + ); +} + +function ExecutionView({ + workflow, + goal, + onGoalChange, + execution, +}: { + workflow: DrawableWorkflow; + goal: string; + onGoalChange: (goal: string) => void; + execution: ExecutionController; +}) { + const { view, harness } = workflow; + const run = runOf(execution.state); + const statuses = useMemo(() => nodeStatuses(run), [run]); + return ( + + +
+ + +
+
+ ); +} + +/** + * The Execution tab: the workflow graph, read-only, a goal and a Run button, + * and the live state of every node while the run goes on. `execution` holds + * the state and `goal` the text of the Goal box, both kept by the caller, so a + * run goes on and the goal stays while the Designer tab is open. The box is + * read-only while a run is in flight, so it then shows the goal of that run. + */ +export function Execution({ + result, + execution, + goal, + onGoalChange, +}: { + result: LoadResult | undefined; + execution: ExecutionController; + goal: string; + onGoalChange: (goal: string) => void; +}) { + return ( + + {(workflow) => ( + + )} + + ); +} diff --git a/workflow-canvas/src/execution/RunAnnouncements.tsx b/workflow-canvas/src/execution/RunAnnouncements.tsx new file mode 100644 index 00000000..01dc2280 --- /dev/null +++ b/workflow-canvas/src/execution/RunAnnouncements.tsx @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: Apache-2.0 +import { statusText, type ExecutionState } from './core'; +import { runErrorSentence } from './run-error'; +import './announcements.css'; + +/** + * The two live regions of the run, for a screen reader: where the run is, and + * why it failed. The status stays empty on an error, as the alert says it all + * and the visible line's "See the error below." points at nothing here. The page keeps them mounted outside the tabs, so a change is + * announced while the Designer tab is open too, when the Execution tab and its + * visible copy of these sentences are not on the page. + */ +export function RunAnnouncements({ state }: { state: ExecutionState }) { + return ( + <> +
+ {state.phase === 'error' ? '' : statusText(state)} +
+
+ {runErrorSentence(state)} +
+ + ); +} diff --git a/workflow-canvas/src/execution/announcements.css b/workflow-canvas/src/execution/announcements.css new file mode 100644 index 00000000..bb653cc3 --- /dev/null +++ b/workflow-canvas/src/execution/announcements.css @@ -0,0 +1,14 @@ +/* SPDX-License-Identifier: Apache-2.0 */ + +/* On the page for a screen reader, not for the eye. */ +.wf-visually-hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + border: 0; + clip-path: inset(50%); + white-space: nowrap; +} diff --git a/workflow-canvas/src/execution/core/execution-state.test.ts b/workflow-canvas/src/execution/core/execution-state.test.ts index 7db9332c..4aa1e693 100644 --- a/workflow-canvas/src/execution/core/execution-state.test.ts +++ b/workflow-canvas/src/execution/core/execution-state.test.ts @@ -118,6 +118,7 @@ describe('reduce', () => { phase: 'error', message: 'Lost.', started: true, + runId: RUN_ID, }); }); diff --git a/workflow-canvas/src/execution/core/execution-state.ts b/workflow-canvas/src/execution/core/execution-state.ts index 296e72a1..c605740f 100644 --- a/workflow-canvas/src/execution/core/execution-state.ts +++ b/workflow-canvas/src/execution/core/execution-state.ts @@ -19,6 +19,8 @@ export type ExecutionState = started: boolean; /** Set when the start got no clear answer, so the run may be going. */ maybeStarted?: true; + /** The run that was being followed, when the failure came after it began. */ + runId?: string; }; /** Something that happened to the run, in the order it can. */ @@ -88,7 +90,7 @@ function failedOf( : { phase: 'error', message, started: false }; } if (state.phase === 'following' && runId === state.runId) { - return { phase: 'error', message, started: true }; + return { phase: 'error', message, started: true, runId: state.runId }; } return state; } diff --git a/workflow-canvas/src/execution/core/index.ts b/workflow-canvas/src/execution/core/index.ts index 2a8cd288..fd6f1a35 100644 --- a/workflow-canvas/src/execution/core/index.ts +++ b/workflow-canvas/src/execution/core/index.ts @@ -28,4 +28,5 @@ export { type RunPort, type Scheduler, } from './ports'; +export { stageDrift, type StageDrift } from './stage-drift'; export { statusKind, statusText, type StatusKind } from './status-text'; diff --git a/workflow-canvas/src/execution/core/stage-drift.test.ts b/workflow-canvas/src/execution/core/stage-drift.test.ts new file mode 100644 index 00000000..4c8bd85e --- /dev/null +++ b/workflow-canvas/src/execution/core/stage-drift.test.ts @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from 'vitest'; +import { HELLO_VIEW, helloRun } from '../../test-support/fixtures'; +import { stageDrift } from './stage-drift'; + +describe('stageDrift', () => { + it('is nothing before there is a run', () => { + expect(stageDrift(HELLO_VIEW, undefined)).toBeUndefined(); + }); + + it('is nothing when the run has the stages of the graph', () => { + expect(stageDrift(HELLO_VIEW, helloRun(1))).toBeUndefined(); + }); + + it('names a stage of the run that the graph does not have', () => { + const view = { ...HELLO_VIEW, stages: [HELLO_VIEW.stages[0]!] }; + + expect(stageDrift(view, helloRun(0))).toEqual({ + notInGraph: ['review'], + notInRun: [], + }); + }); + + it('names a stage of the graph that the run does not have', () => { + const view = { + ...HELLO_VIEW, + stages: [...HELLO_VIEW.stages, { name: 'ship', agent: 'k-developer' }], + }; + + expect(stageDrift(view, helloRun(0))).toEqual({ + notInGraph: [], + notInRun: ['ship'], + }); + }); + + it('names a stage that was renamed on both sides', () => { + const view = { + ...HELLO_VIEW, + stages: [{ name: 'write', agent: 'k-developer' }, HELLO_VIEW.stages[1]!], + }; + + expect(stageDrift(view, helloRun(0))).toEqual({ + notInGraph: ['draft'], + notInRun: ['write'], + }); + }); + + it('is nothing when only the order differs', () => { + const view = { ...HELLO_VIEW, stages: [...HELLO_VIEW.stages].reverse() }; + + expect(stageDrift(view, helloRun(0))).toBeUndefined(); + }); +}); diff --git a/workflow-canvas/src/execution/core/stage-drift.ts b/workflow-canvas/src/execution/core/stage-drift.ts new file mode 100644 index 00000000..ed6f495e --- /dev/null +++ b/workflow-canvas/src/execution/core/stage-drift.ts @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 +import type { RunView, WorkflowView } from '../../api/types'; + +/** The stages that the graph and the run do not share, by name. */ +export interface StageDrift { + /** Stages of the run that the graph does not draw. */ + notInGraph: string[]; + /** Stages of the graph that the run does not have. */ + notInRun: string[]; +} + +/** + * How the stages of `run` differ from those of the graph, or `undefined` when + * they are the same stages. A workflow can be saved while a run is active, so + * the run may be of an older or newer file than the graph, and node states, + * which attach by stage name, then miss some nodes. + */ +export function stageDrift( + view: WorkflowView, + run: RunView | undefined, +): StageDrift | undefined { + if (!run) return undefined; + const inGraph = view.stages.map((stage) => stage.name); + const inRun = run.stages.map((stage) => stage.name); + const notInGraph = inRun.filter((name) => !inGraph.includes(name)); + const notInRun = inGraph.filter((name) => !inRun.includes(name)); + return notInGraph.length + notInRun.length === 0 + ? undefined + : { notInGraph, notInRun }; +} diff --git a/workflow-canvas/src/execution/core/status-text.test.ts b/workflow-canvas/src/execution/core/status-text.test.ts index ed5ec30b..8d769143 100644 --- a/workflow-canvas/src/execution/core/status-text.test.ts +++ b/workflow-canvas/src/execution/core/status-text.test.ts @@ -63,19 +63,13 @@ describe('statusText and statusKind', () => { [ 'an error before the run started', { phase: 'error', message: 'x', started: false }, - 'The run did not start.', - 'error', - ], - [ - 'an error before an unsure start knew if the run started', - { phase: 'error', message: 'x', started: false, maybeStarted: true }, - 'The run may not have started.', + 'See the error below.', 'error', ], [ 'an error after the run started', { phase: 'error', message: 'x', started: true }, - 'Stopped following the run.', + 'See the error below.', 'error', ], ])('says %s', (_, state, text, kind) => { diff --git a/workflow-canvas/src/execution/core/status-text.ts b/workflow-canvas/src/execution/core/status-text.ts index 7af55e94..fa45ac3c 100644 --- a/workflow-canvas/src/execution/core/status-text.ts +++ b/workflow-canvas/src/execution/core/status-text.ts @@ -31,13 +31,6 @@ function finishedText(run: RunView): string { return `${lead} ${stagesDone(run)}${exit}`; } -function errorText(state: Extract): string { - if (state.started) return 'Stopped following the run.'; - return state.maybeStarted - ? 'The run may not have started.' - : 'The run did not start.'; -} - /** The sentence that says where the run is. A screen reader announces it. */ export function statusText(state: ExecutionState): string { switch (state.phase) { @@ -52,7 +45,7 @@ export function statusText(state: ExecutionState): string { case 'finished': return finishedText(state.run); case 'error': - return errorText(state); + return 'See the error below.'; } } diff --git a/workflow-canvas/src/execution/execution.css b/workflow-canvas/src/execution/execution.css new file mode 100644 index 00000000..dd3df030 --- /dev/null +++ b/workflow-canvas/src/execution/execution.css @@ -0,0 +1,18 @@ +/* SPDX-License-Identifier: Apache-2.0 */ + +/* + * The graph beside the run panel. A plain grid, not a library breakpoint, so + * the canvas has its final width when it first fits the graph in the view. + */ +.wf-execution { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); + gap: 20px; + align-items: start; +} + +@media (max-width: 900px) { + .wf-execution { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/workflow-canvas/src/execution/run-error.ts b/workflow-canvas/src/execution/run-error.ts new file mode 100644 index 00000000..ba185536 --- /dev/null +++ b/workflow-canvas/src/execution/run-error.ts @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: Apache-2.0 +import type { ExecutionState } from './core'; + +/** What the person is told when a run could not start or be followed. */ +export interface RunErrorText { + header: string; + /** The sentence of the server or the page. */ + message: string; + /** Said after a failure to follow: the run itself may be going on. */ + note?: string; +} + +/** The words for the error of a run, or `undefined` when there is none. */ +export function runErrorOf(state: ExecutionState): RunErrorText | undefined { + if (state.phase !== 'error') return undefined; + if (!state.started) { + return state.maybeStarted + ? { + header: 'The run may not have started', + message: state.message, + note: 'The run may be going on the server. Press Run to join it.', + } + : { header: 'The run did not start', message: state.message }; + } + const which = state.runId === undefined ? '' : ` (run ${state.runId})`; + return { + header: 'The run could not be followed', + message: state.message, + note: `The run may still be going on the server${which}.`, + }; +} + +const withStop = (text: string) => (/[.!?]$/.test(text) ? text : `${text}.`); + +/** The error as one run of sentences for a screen reader, or an empty string. */ +export function runErrorSentence(state: ExecutionState): string { + const error = runErrorOf(state); + if (!error) return ''; + return [error.header, error.message, error.note] + .filter((part): part is string => part !== undefined) + .map(withStop) + .join(' '); +} diff --git a/workflow-canvas/src/execution/timer-scheduler.test.ts b/workflow-canvas/src/execution/timer-scheduler.test.ts new file mode 100644 index 00000000..1268fdc0 --- /dev/null +++ b/workflow-canvas/src/execution/timer-scheduler.test.ts @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { timerScheduler } from './timer-scheduler'; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +function track(promise: Promise) { + const state = { done: false }; + void promise.then(() => { + state.done = true; + }); + return state; +} + +describe('timerScheduler', () => { + it('resolves once the time has passed, and not before', async () => { + const state = track( + timerScheduler.wait(1000, new AbortController().signal), + ); + + await vi.advanceTimersByTimeAsync(999); + expect(state.done).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + expect(state.done).toBe(true); + }); + + it('resolves at once when the signal aborts, and leaves no timer behind', async () => { + const abort = new AbortController(); + const state = track(timerScheduler.wait(1000, abort.signal)); + + abort.abort(); + await vi.advanceTimersByTimeAsync(0); + + expect(state.done).toBe(true); + expect(vi.getTimerCount()).toBe(0); + }); + + it('resolves at once when the signal is already aborted', async () => { + const abort = new AbortController(); + abort.abort(); + + const state = track(timerScheduler.wait(1000, abort.signal)); + await vi.advanceTimersByTimeAsync(0); + + expect(state.done).toBe(true); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/workflow-canvas/src/execution/timer-scheduler.ts b/workflow-canvas/src/execution/timer-scheduler.ts new file mode 100644 index 00000000..78a2e530 --- /dev/null +++ b/workflow-canvas/src/execution/timer-scheduler.ts @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 +import type { Scheduler } from './core'; + +/** The scheduler of the page: waits with a browser timer. */ +export const timerScheduler: Scheduler = { + wait(ms, signal) { + return new Promise((resolve) => { + if (signal.aborted) { + resolve(); + return; + } + const finish = () => { + clearTimeout(timer); + signal.removeEventListener('abort', finish); + resolve(); + }; + const timer = setTimeout(finish, ms); + signal.addEventListener('abort', finish); + }); + }, +}; diff --git a/workflow-canvas/src/execution/use-execution.ts b/workflow-canvas/src/execution/use-execution.ts new file mode 100644 index 00000000..cb18384e --- /dev/null +++ b/workflow-canvas/src/execution/use-execution.ts @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: Apache-2.0 +import { useCallback, useEffect, useReducer, useRef } from 'react'; +import { + IDLE, + reduce, + startAndFollow, + type ExecutionEvent, + type ExecutionState, + type RunPort, + type Scheduler, +} from './core'; + +// `reset` is the hook's own event: another workflow was opened, so the run of +// the one that was left is no longer the run of this page. +type Action = ExecutionEvent | { type: 'reset' }; + +function reduceAction(state: ExecutionState, action: Action): ExecutionState { + return action.type === 'reset' ? IDLE : reduce(state, action); +} + +/** The state of the Execution tab and the one way to change it. */ +export interface ExecutionController { + state: ExecutionState; + /** Starts a run with the goal; does nothing while a run is in flight. */ + start(goal: string): void; +} + +/** + * Drives one run at a time through `runs`, waiting with `scheduler`, and + * stops following it when the component unmounts. The state lives with the + * caller, so it survives a switch of tab. When `runs` is replaced by those of + * another workflow, the run in hand is let go: it stays on its server, but + * this page no longer follows it or shows its status. + */ +export function useExecution( + runs: RunPort | undefined, + scheduler: Scheduler, +): ExecutionController { + const [state, dispatch] = useReducer(reduceAction, IDLE); + const active = useRef(undefined); + const opened = useRef(runs); + + useEffect(() => () => active.current?.abort(), []); + + useEffect(() => { + const left = opened.current; + opened.current = runs; + if (left === undefined || runs === undefined || left === runs) return; + active.current?.abort(); + active.current = undefined; + dispatch({ type: 'reset' }); + }, [runs]); + + const start = useCallback( + (goal: string) => { + if (!runs || active.current) return; + const controller = new AbortController(); + active.current = controller; + dispatch({ type: 'start' }); + void startAndFollow({ + runs, + scheduler, + goal, + emit: dispatch, + signal: controller.signal, + }).finally(() => { + if (active.current === controller) active.current = undefined; + }); + }, + [runs, scheduler], + ); + + return { state, start }; +} diff --git a/workflow-canvas/src/execution/use-goal.ts b/workflow-canvas/src/execution/use-goal.ts new file mode 100644 index 00000000..0ea7b97e --- /dev/null +++ b/workflow-canvas/src/execution/use-goal.ts @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +import { useState } from 'react'; +import type { LoadResult } from '../workflow/load-workflow'; + +// The box is one line, so a line break of the outcome becomes a space, and +// what the person reads is what the run is given. +function oneLine(text: string): string { + return text.replace(/\s*[\r\n]+\s*/g, ' '); +} + +/** + * The text of the Goal box: what the person typed, or the outcome of the + * intent until they type. The caller keeps it, so it survives a switch of + * tab, which unmounts the Execution tab. + */ +export function useGoal( + result: LoadResult | undefined, +): [goal: string, setGoal: (goal: string) => void] { + const [typed, setTyped] = useState(); + const outcome = result?.ok + ? result.workflow.document.view?.intent.outcome + : ''; + return [typed ?? oneLine(outcome ?? ''), setTyped]; +} diff --git a/workflow-canvas/src/workflow/WorkflowGate.tsx b/workflow-canvas/src/workflow/WorkflowGate.tsx new file mode 100644 index 00000000..08350755 --- /dev/null +++ b/workflow-canvas/src/workflow/WorkflowGate.tsx @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 +import { Fragment, type ReactNode } from 'react'; +import type { WorkflowView } from '../api/types'; +import type { LoadResult } from './load-workflow'; +import { EmptyState, ErrorState, LoadingState } from './states'; +import { useChangeCount } from './use-change-count'; + +/** A workflow that can be drawn, with the harness that runs its stages. */ +export interface DrawableWorkflow { + view: WorkflowView; + harness: string; +} + +/** + * Shows what a tab shows before it has a workflow to draw: a message while + * the load is under way (`result` is `undefined`), when it failed, or when + * there is nothing to draw. Once there is a workflow it renders `children`. + */ +export function WorkflowGate({ + result, + children, +}: { + result: LoadResult | undefined; + children: (workflow: DrawableWorkflow) => ReactNode; +}) { + // A new workflow gets a new canvas, which is fitted to it when it mounts. + const swaps = useChangeCount(result); + if (!result) return ; + if (!result.ok) return ; + const { document, harness } = result.workflow; + if (!document.view) { + return ( + + ); + } + return ( + + {children({ view: document.view, harness })} + + ); +} diff --git a/workflow-canvas/src/designer/states.tsx b/workflow-canvas/src/workflow/states.tsx similarity index 100% rename from workflow-canvas/src/designer/states.tsx rename to workflow-canvas/src/workflow/states.tsx diff --git a/workflow-canvas/src/designer/use-change-count.ts b/workflow-canvas/src/workflow/use-change-count.ts similarity index 100% rename from workflow-canvas/src/designer/use-change-count.ts rename to workflow-canvas/src/workflow/use-change-count.ts diff --git a/workflow-canvas/test-support/canvas-contrast.test.ts b/workflow-canvas/test-support/canvas-contrast.test.ts index 9965784a..657a92e5 100644 --- a/workflow-canvas/test-support/canvas-contrast.test.ts +++ b/workflow-canvas/test-support/canvas-contrast.test.ts @@ -42,6 +42,8 @@ const MODES: [ThemeMode, Record][] = [ ['dark', dark], ]; +const RUN_STATES = ['queued', 'running', 'done', 'failed'] as const; + describe.each(MODES)('canvas.css colours in %s', (mode, v) => { const ratio = (a: string, b: string) => contrastRatio(v[a]!, v[b]!); @@ -92,6 +94,16 @@ describe.each(MODES)('canvas.css colours in %s', (mode, v) => { ); }); + it.each(RUN_STATES)( + 'keeps the %s state readable in its badge and visible as an outline', + (state) => { + const color = `--wf-state-${state}`; + expect(ratio(color, `${color}-bg`)).toBeGreaterThanOrEqual(AA_TEXT); + expect(ratio(color, '--wf-canvas-bg')).toBeGreaterThanOrEqual(AA_GRAPHIC); + expect(ratio(color, '--wf-node-bg')).toBeGreaterThanOrEqual(AA_GRAPHIC); + }, + ); + it('uses the colours of the theme module, and names every one it uses', () => { const c = colorsOf(mode); const k = CANVAS[mode]; @@ -112,6 +124,14 @@ describe.each(MODES)('canvas.css colours in %s', (mode, v) => { '--wf-port-letter-out': k.portLetterOut, '--wf-port-letter-in': k.portLetterIn, '--wf-port-notch-bg': k.portNotchBg, + '--wf-state-queued': c.textMuted, + '--wf-state-queued-bg': c.surfaceAlt, + '--wf-state-running': c.link, + '--wf-state-running-bg': c.infoBg, + '--wf-state-done': c.success, + '--wf-state-done-bg': c.successBg, + '--wf-state-failed': c.error, + '--wf-state-failed-bg': c.errorBg, }; expect(v).toEqual(expected);