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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 81 additions & 11 deletions workflow-canvas/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
88 changes: 77 additions & 11 deletions workflow-canvas/e2e/capture/screenshots.spec.ts
Original file line number Diff line number Diff line change
@@ -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 }) => {
Expand All @@ -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<void> {
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');
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed workflow-canvas/e2e/screenshots/execution-tab.png
Binary file not shown.
83 changes: 83 additions & 0 deletions workflow-canvas/e2e/support/run-routes.ts
Original file line number Diff line number Diff line change
@@ -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<typeof runReply>;

async function acceptRuns(page: Page): Promise<void> {
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<void> {
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<void> {
await page.route('**/api/run', (route) =>
route.fulfill({ status, json: body }),
);
}
Loading
Loading