From cb347141e1ac5d56c33bbe98174c679a609d1031 Mon Sep 17 00:00:00 2001 From: Nihit <24500178+knihit@users.noreply.github.com> Date: Sun, 25 Oct 2026 04:56:05 +0000 Subject: [PATCH 01/41] Share the no-IO scan between the stub core and later cores Move the scan out of the stub core's test into test-support/purity.ts, parameterised by the rules of one core, so a second core can be held to the same rule. The scan's own tests move with it, unchanged. --- workflow-canvas/stub-api/core/purity.test.ts | 184 ++----------------- workflow-canvas/test-support/purity.test.ts | 94 ++++++++++ workflow-canvas/test-support/purity.ts | 116 ++++++++++++ workflow-canvas/tsconfig.node.json | 3 +- workflow-canvas/vite.config.ts | 6 +- 5 files changed, 230 insertions(+), 173 deletions(-) create mode 100644 workflow-canvas/test-support/purity.test.ts create mode 100644 workflow-canvas/test-support/purity.ts diff --git a/workflow-canvas/stub-api/core/purity.test.ts b/workflow-canvas/stub-api/core/purity.test.ts index a21bd8213..ed377f551 100644 --- a/workflow-canvas/stub-api/core/purity.test.ts +++ b/workflow-canvas/stub-api/core/purity.test.ts @@ -3,185 +3,27 @@ * Checks that the core does no IO and reads no clock, randomness or * environment. It may import its own modules, `yaml` and `createHash`. */ -import { - mkdirSync, - mkdtempSync, - readdirSync, - readFileSync, - rmSync, - writeFileSync, -} from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; - -const CORE_DIR = fileURLToPath(new URL('.', import.meta.url)); -const ALLOWED_PACKAGES = new Set(['yaml']); -// A named import is allowed from a module that is otherwise not: the names. -const ALLOWED_NAMES: Readonly> = { - 'node:crypto': ['createHash'], +import { + problemsUnder, + sourcesUnder, + type PurityRules, +} from '../../test-support/purity.ts'; + +const RULES: PurityRules = { + dir: fileURLToPath(new URL('.', import.meta.url)), + allowedPackages: new Set(['yaml']), + // A named import is allowed from a module that is otherwise not: the names. + allowedNames: { 'node:crypto': ['createHash'] }, }; -const IMPURE = [ - /\bDate\b/, - /\bMath\.random\b/, - /\bperformance\b/, - /\b(?:randomBytes|randomUUID|randomInt|randomFillSync|getRandomValues)\b/, - /\bcrypto\./, - /\bprocess\b/, - /\b(?:setTimeout|setInterval|setImmediate)\b/, - /\bfetch\s*\(/, - /\brequire\s*\(/, -]; - -/** The module names that a source file imports, statically or dynamically. */ -function importedModules(source: string): string[] { - const pattern = /(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)['"]([^'"]+)['"]/g; - return [...source.matchAll(pattern)].map((match) => match[1] ?? ''); -} - -/** The names of the `import { a, b as c } from 'module'` statements. */ -function namedImports(source: string, module: string): string[] { - const pattern = new RegExp( - `\\bimport\\s+(?:type\\s+)?\\{([^}]*)\\}\\s*from\\s*['"]${module}['"]`, - 'g', - ); - return [...source.matchAll(pattern)].flatMap((match) => - (match[1] ?? '') - .split(',') - .map( - (name) => - name - .trim() - .replace(/^type\s+/, '') - .split(/\s+as\s+/)[0] ?? '', - ) - .filter((name) => name !== ''), - ); -} - -function isOwnModule(specifier: string, fromFile: string): boolean { - if (!specifier.startsWith('.')) return false; - const target = fileURLToPath( - new URL(specifier, `file://${CORE_DIR}${fromFile}`), - ); - return target.startsWith(CORE_DIR); -} - -function isAllowedImport(specifier: string, fromFile: string, source: string) { - const names = ALLOWED_NAMES[specifier]; - if (names) { - const count = importedModules(source).filter((m) => m === specifier).length; - const named = namedImports(source, specifier); - return named.length >= count && named.every((n) => names.includes(n)); - } - return ALLOWED_PACKAGES.has(specifier) || isOwnModule(specifier, fromFile); -} - -/** Every way that a core source breaks the rules of the core. */ -function problemsIn(file: string, source: string): string[] { - const imports = importedModules(source) - .filter((module) => !isAllowedImport(module, file, source)) - .map((module) => `${file} imports ${module}`); - const calls = IMPURE.filter((pattern) => pattern.test(source)).map( - (pattern) => `${file} matches ${pattern}`, - ); - return [...imports, ...calls]; -} - -/** The `.ts` sources under a directory, at any depth, tests left out. */ -function sourcesUnder(dir: string): { file: string; source: string }[] { - return readdirSync(dir, { recursive: true, encoding: 'utf8' }) - .filter((file) => file.endsWith('.ts') && !file.endsWith('.test.ts')) - .map((file) => ({ file, source: readFileSync(join(dir, file), 'utf8') })); -} - -const coreSources = () => sourcesUnder(CORE_DIR); describe('the core', () => { it('has sources to check', () => { - expect(coreSources().length).toBeGreaterThan(5); + expect(sourcesUnder(RULES.dir).length).toBeGreaterThan(5); }); it('imports only its own modules, yaml and createHash, and reads no clock, randomness, environment or network', () => { - const problems = coreSources().flatMap(({ file, source }) => - problemsIn(file, source), - ); - expect(problems).toEqual([]); - }); -}); - -describe('the scan, on code that breaks the rules', () => { - const HASH = "import { createHash } from 'node:crypto';\n"; - - it('accepts the named import of createHash and own modules', () => { - const source = `${HASH}import { a } from './a.ts';\nimport { b } from './sub/b.ts';\n`; - expect(problemsIn('x.ts', source)).toEqual([]); - }); - - it.each([ - ['randomBytes', "import { createHash, randomBytes } from 'node:crypto';"], - ['a renamed randomUUID', "import { randomUUID as id } from 'node:crypto';"], - ['a namespace import of node:crypto', "import * as c from 'node:crypto';"], - ['a default import of node:crypto', "import crypto from 'node:crypto';"], - [ - 'a dynamic import of node:crypto', - "const c = await import('node:crypto');", - ], - ['node:fs', "import { readFileSync } from 'node:fs';"], - ['a module above core', "import { x } from '../http/server.ts';"], - ['a package that is not allowed', "import x from 'express';"], - ])('rejects %s', (_, line) => { - expect(problemsIn('x.ts', line)).not.toEqual([]); - }); - - it.each([ - ['Date.now()', 'const t = Date.now();'], - ['new Date()', 'const t = new Date();'], - ['new Date(ms)', 'const t = new Date(ms);'], - ['Date()', 'const t = Date();'], - ['Math.random()', 'const r = Math.random();'], - ['performance.now()', 'const t = performance.now();'], - ['randomUUID()', 'const id = randomUUID();'], - ['randomBytes()', 'const b = randomBytes(8);'], - ['getRandomValues()', 'crypto.getRandomValues(new Uint8Array(4));'], - ['globalThis.crypto', 'const id = globalThis.crypto.randomUUID();'], - ['process.env', 'const v = process.env.HOME;'], - ['setTimeout()', 'setTimeout(() => {}, 1);'], - ['fetch()', "await fetch('http://x');"], - ])('rejects %s', (_, line) => { - expect(problemsIn('x.ts', `${HASH}${line}\n`)).not.toEqual([]); - }); - - it('walks sub-directories', () => { - const dir = mkdtempSync(join(tmpdir(), 'purity-')); - try { - mkdirSync(join(dir, 'deep')); - writeFileSync( - join(dir, 'deep', 'bad.ts'), - 'export const t = Date.now();', - ); - writeFileSync(join(dir, 'deep', 'bad.test.ts'), ''); - expect(sourcesUnder(dir).map((s) => s.file)).toEqual(['deep/bad.ts']); - } finally { - rmSync(dir, { recursive: true }); - } - }); - - it('finds static, side-effect, re-export and dynamic imports', () => { - const source = [ - "import { a } from 'node:fs';", - 'import type { B } from "./b.ts";', - "import 'node:http';", - "export { c } from 'node:net';", - "const d = await import('node:child_process');", - ].join('\n'); - expect(importedModules(source)).toEqual([ - 'node:fs', - './b.ts', - 'node:http', - 'node:net', - 'node:child_process', - ]); + expect(problemsUnder(RULES)).toEqual([]); }); }); diff --git a/workflow-canvas/test-support/purity.test.ts b/workflow-canvas/test-support/purity.test.ts new file mode 100644 index 000000000..7642f85be --- /dev/null +++ b/workflow-canvas/test-support/purity.test.ts @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: Apache-2.0 +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + importedModules, + problemsIn as scan, + sourcesUnder, + type PurityRules, +} from './purity.ts'; + +// The rules of the stub core: its own modules, yaml and createHash. +const RULES: PurityRules = { + dir: '/core/', + allowedPackages: new Set(['yaml']), + allowedNames: { 'node:crypto': ['createHash'] }, +}; +const problemsIn = (file: string, source: string) => scan(file, source, RULES); + +describe('the scan, on code that breaks the rules', () => { + const HASH = "import { createHash } from 'node:crypto';\n"; + + it('accepts the named import of createHash and own modules', () => { + const source = `${HASH}import { a } from './a.ts';\nimport { b } from './sub/b.ts';\n`; + expect(problemsIn('x.ts', source)).toEqual([]); + }); + + it.each([ + ['randomBytes', "import { createHash, randomBytes } from 'node:crypto';"], + ['a renamed randomUUID', "import { randomUUID as id } from 'node:crypto';"], + ['a namespace import of node:crypto', "import * as c from 'node:crypto';"], + ['a default import of node:crypto', "import crypto from 'node:crypto';"], + [ + 'a dynamic import of node:crypto', + "const c = await import('node:crypto');", + ], + ['node:fs', "import { readFileSync } from 'node:fs';"], + ['a module above core', "import { x } from '../http/server.ts';"], + ['a package that is not allowed', "import x from 'express';"], + ])('rejects %s', (_, line) => { + expect(problemsIn('x.ts', line)).not.toEqual([]); + }); + + it.each([ + ['Date.now()', 'const t = Date.now();'], + ['new Date()', 'const t = new Date();'], + ['new Date(ms)', 'const t = new Date(ms);'], + ['Date()', 'const t = Date();'], + ['Math.random()', 'const r = Math.random();'], + ['performance.now()', 'const t = performance.now();'], + ['randomUUID()', 'const id = randomUUID();'], + ['randomBytes()', 'const b = randomBytes(8);'], + ['getRandomValues()', 'crypto.getRandomValues(new Uint8Array(4));'], + ['globalThis.crypto', 'const id = globalThis.crypto.randomUUID();'], + ['process.env', 'const v = process.env.HOME;'], + ['setTimeout()', 'setTimeout(() => {}, 1);'], + ['fetch()', "await fetch('http://x');"], + ])('rejects %s', (_, line) => { + expect(problemsIn('x.ts', `${HASH}${line}\n`)).not.toEqual([]); + }); + + it('walks sub-directories', () => { + const dir = mkdtempSync(join(tmpdir(), 'purity-')); + try { + mkdirSync(join(dir, 'deep')); + writeFileSync( + join(dir, 'deep', 'bad.ts'), + 'export const t = Date.now();', + ); + writeFileSync(join(dir, 'deep', 'bad.test.ts'), ''); + expect(sourcesUnder(dir).map((s) => s.file)).toEqual(['deep/bad.ts']); + } finally { + rmSync(dir, { recursive: true }); + } + }); + + it('finds static, side-effect, re-export and dynamic imports', () => { + const source = [ + "import { a } from 'node:fs';", + 'import type { B } from "./b.ts";', + "import 'node:http';", + "export { c } from 'node:net';", + "const d = await import('node:child_process');", + ].join('\n'); + expect(importedModules(source)).toEqual([ + 'node:fs', + './b.ts', + 'node:http', + 'node:net', + 'node:child_process', + ]); + }); +}); diff --git a/workflow-canvas/test-support/purity.ts b/workflow-canvas/test-support/purity.ts new file mode 100644 index 000000000..b431f4f26 --- /dev/null +++ b/workflow-canvas/test-support/purity.ts @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * The scan behind the "no IO" tests of an IO-free core. A core may import its + * own modules and the packages and names that its rules allow, and may not + * read a clock, randomness, the environment or the network. + */ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** What one core may import. Anything else is a problem. */ +export interface PurityRules { + /** The core's directory, as a path that ends in a separator. */ + dir: string; + /** Packages that any file of the core may import. */ + allowedPackages: ReadonlySet; + /** A module that is otherwise not allowed, with the names it may give. */ + allowedNames: Readonly>; +} + +const IMPURE = [ + /\bDate\b/, + /\bMath\.random\b/, + /\bperformance\b/, + /\b(?:randomBytes|randomUUID|randomInt|randomFillSync|getRandomValues)\b/, + /\bcrypto\./, + /\bprocess\b/, + /\b(?:setTimeout|setInterval|setImmediate)\b/, + /\bfetch\s*\(/, + /\brequire\s*\(/, +]; + +/** The module names that a source file imports, statically or dynamically. */ +export function importedModules(source: string): string[] { + const pattern = /(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)['"]([^'"]+)['"]/g; + return [...source.matchAll(pattern)].map((match) => match[1] ?? ''); +} + +/** The names of the `import { a, b as c } from 'module'` statements. */ +export function namedImports(source: string, module: string): string[] { + const pattern = new RegExp( + `\\bimport\\s+(?:type\\s+)?\\{([^}]*)\\}\\s*from\\s*['"]${module}['"]`, + 'g', + ); + return [...source.matchAll(pattern)].flatMap((match) => + (match[1] ?? '') + .split(',') + .map( + (name) => + name + .trim() + .replace(/^type\s+/, '') + .split(/\s+as\s+/)[0] ?? '', + ) + .filter((name) => name !== ''), + ); +} + +function isOwnModule( + specifier: string, + fromFile: string, + rules: PurityRules, +): boolean { + if (!specifier.startsWith('.')) return false; + const target = fileURLToPath( + new URL(specifier, `file://${rules.dir}${fromFile}`), + ); + return target.startsWith(rules.dir); +} + +function isAllowedImport( + specifier: string, + fromFile: string, + source: string, + rules: PurityRules, +): boolean { + const names = rules.allowedNames[specifier]; + if (names) { + const count = importedModules(source).filter((m) => m === specifier).length; + const named = namedImports(source, specifier); + return named.length >= count && named.every((n) => names.includes(n)); + } + return ( + rules.allowedPackages.has(specifier) || + isOwnModule(specifier, fromFile, rules) + ); +} + +/** Every way that a core source breaks the rules of the core. */ +export function problemsIn( + file: string, + source: string, + rules: PurityRules, +): string[] { + const imports = importedModules(source) + .filter((module) => !isAllowedImport(module, file, source, rules)) + .map((module) => `${file} imports ${module}`); + const calls = IMPURE.filter((pattern) => pattern.test(source)).map( + (pattern) => `${file} matches ${pattern}`, + ); + return [...imports, ...calls]; +} + +/** The `.ts` and `.tsx` sources under a directory, tests left out. */ +export function sourcesUnder(dir: string): { file: string; source: string }[] { + return readdirSync(dir, { recursive: true, encoding: 'utf8' }) + .filter((file) => /\.tsx?$/.test(file) && !/\.test\.tsx?$/.test(file)) + .map((file) => ({ file, source: readFileSync(join(dir, file), 'utf8') })); +} + +/** The problems of every source under the directory of the rules. */ +export function problemsUnder(rules: PurityRules): string[] { + return sourcesUnder(rules.dir).flatMap(({ file, source }) => + problemsIn(file, source, rules), + ); +} diff --git a/workflow-canvas/tsconfig.node.json b/workflow-canvas/tsconfig.node.json index 29e4795f9..6782ba36e 100644 --- a/workflow-canvas/tsconfig.node.json +++ b/workflow-canvas/tsconfig.node.json @@ -10,6 +10,7 @@ "vite.config.ts", "playwright.*.ts", "api/**/*.ts", - "stub-api/**/*.ts" + "stub-api/**/*.ts", + "test-support/**/*.ts" ] } diff --git a/workflow-canvas/vite.config.ts b/workflow-canvas/vite.config.ts index 8feb54e0a..228d9cfc0 100644 --- a/workflow-canvas/vite.config.ts +++ b/workflow-canvas/vite.config.ts @@ -53,7 +53,11 @@ export default defineConfig(({ command, mode }) => { test: { name: 'node', environment: 'node', - include: ['api/**/*.test.ts', 'stub-api/**/*.test.ts'], + include: [ + 'api/**/*.test.ts', + 'stub-api/**/*.test.ts', + 'test-support/**/*.test.ts', + ], }, }, ], From ab870f7e83194738b7c77996f93832e94c1091f8 Mon Sep 17 00:00:00 2001 From: Nihit <24500178+knihit@users.noreply.github.com> Date: Sun, 25 Oct 2026 04:56:05 +0000 Subject: [PATCH 02/41] Draw the workflow on the Designer tab from the API Open a session with the launch code in the address, load the workflow, and draw the intent, each stage in list order and the end with React Flow, read-only. The graph is built by an IO-free core. Loading, error and empty states are shown. --- workflow-canvas/package-lock.json | 244 +++++++++++++++++- workflow-canvas/package.json | 1 + workflow-canvas/src/App.test.tsx | 62 ++++- workflow-canvas/src/App.tsx | 18 +- workflow-canvas/src/api/api.ts | 27 ++ workflow-canvas/src/api/http-api.test.ts | 187 ++++++++++++++ workflow-canvas/src/api/http-api.ts | 64 +++++ workflow-canvas/src/api/parse.ts | 95 +++++++ workflow-canvas/src/api/types.ts | 39 +++ workflow-canvas/src/canvas/WorkflowCanvas.tsx | 64 +++++ workflow-canvas/src/canvas/canvas.css | 95 +++++++ workflow-canvas/src/canvas/nodes.tsx | 78 ++++++ .../src/designer/Designer.test.tsx | 75 ++++++ workflow-canvas/src/designer/Designer.tsx | 50 ++++ workflow-canvas/src/designer/states.tsx | 48 ++++ .../src/designer/use-settled.test.ts | 51 ++++ workflow-canvas/src/designer/use-settled.ts | 23 ++ workflow-canvas/src/graph/build-graph.test.ts | 134 ++++++++++ workflow-canvas/src/graph/build-graph.ts | 74 ++++++ workflow-canvas/src/graph/index.ts | 13 + workflow-canvas/src/graph/layout.ts | 25 ++ workflow-canvas/src/graph/model.ts | 62 +++++ .../src/launch/launch-code.test.ts | 50 ++++ workflow-canvas/src/launch/launch-code.ts | 29 +++ workflow-canvas/src/main.tsx | 9 +- workflow-canvas/src/test-support/fixtures.ts | 39 +++ workflow-canvas/src/test-support/setup.ts | 17 +- .../src/workflow/load-workflow.test.ts | 74 ++++++ workflow-canvas/src/workflow/load-workflow.ts | 38 +++ .../test-support/graph-purity.test.ts | 38 +++ 30 files changed, 1805 insertions(+), 18 deletions(-) create mode 100644 workflow-canvas/src/api/api.ts create mode 100644 workflow-canvas/src/api/http-api.test.ts create mode 100644 workflow-canvas/src/api/http-api.ts create mode 100644 workflow-canvas/src/api/parse.ts create mode 100644 workflow-canvas/src/api/types.ts create mode 100644 workflow-canvas/src/canvas/WorkflowCanvas.tsx create mode 100644 workflow-canvas/src/canvas/canvas.css create mode 100644 workflow-canvas/src/canvas/nodes.tsx create mode 100644 workflow-canvas/src/designer/Designer.test.tsx create mode 100644 workflow-canvas/src/designer/Designer.tsx create mode 100644 workflow-canvas/src/designer/states.tsx create mode 100644 workflow-canvas/src/designer/use-settled.test.ts create mode 100644 workflow-canvas/src/designer/use-settled.ts create mode 100644 workflow-canvas/src/graph/build-graph.test.ts create mode 100644 workflow-canvas/src/graph/build-graph.ts create mode 100644 workflow-canvas/src/graph/index.ts create mode 100644 workflow-canvas/src/graph/layout.ts create mode 100644 workflow-canvas/src/graph/model.ts create mode 100644 workflow-canvas/src/launch/launch-code.test.ts create mode 100644 workflow-canvas/src/launch/launch-code.ts create mode 100644 workflow-canvas/src/test-support/fixtures.ts create mode 100644 workflow-canvas/src/workflow/load-workflow.test.ts create mode 100644 workflow-canvas/src/workflow/load-workflow.ts create mode 100644 workflow-canvas/test-support/graph-purity.test.ts diff --git a/workflow-canvas/package-lock.json b/workflow-canvas/package-lock.json index 3d0f9031b..139d142f5 100644 --- a/workflow-canvas/package-lock.json +++ b/workflow-canvas/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@cloudscape-design/components": "3.0.1404", "@cloudscape-design/global-styles": "1.0.71", + "@xyflow/react": "12.12.0", "react": "19.3.1", "react-dom": "19.3.1" }, @@ -1176,6 +1177,55 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.12", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.12.tgz", + "integrity": "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.9.tgz", + "integrity": "sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -1218,7 +1268,7 @@ "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1228,7 +1278,7 @@ "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.3.0" @@ -1532,6 +1582,48 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xyflow/react": { + "version": "12.12.0", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.12.0.tgz", + "integrity": "sha512-74oXI0Rgm1Eu33cHQAd74J6oVgCH9mbFyTVQbsGgp2kmWwh0HdtBr2VP04z8mbPD+M/xO69nqG5dDEt7/EN2rQ==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.83", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.83", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.83.tgz", + "integrity": "sha512-a//eDjez3WZF47aaPTfVUDD0OINOq+WVwBZCRTqG+m0a5euX8EwlJ4jMY1Wxz0Ocj4xOeekJ70V22Pq0ll8RJw==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/ace-builds": { "version": "1.45.2", "resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.45.2.tgz", @@ -1745,6 +1837,12 @@ "node": ">=18" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/clsx": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", @@ -1817,12 +1915,73 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-path": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", "license": "BSD-3-Clause" }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-shape": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", @@ -1832,6 +1991,50 @@ "d3-path": "1" } }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz", @@ -3561,6 +3764,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "8.3.5", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", @@ -3860,6 +4072,34 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/workflow-canvas/package.json b/workflow-canvas/package.json index 01278c65f..a053db86a 100644 --- a/workflow-canvas/package.json +++ b/workflow-canvas/package.json @@ -24,6 +24,7 @@ "dependencies": { "@cloudscape-design/components": "3.0.1404", "@cloudscape-design/global-styles": "1.0.71", + "@xyflow/react": "12.12.0", "react": "19.3.1", "react-dom": "19.3.1" }, diff --git a/workflow-canvas/src/App.test.tsx b/workflow-canvas/src/App.test.tsx index 5079a86db..9e6d88f56 100644 --- a/workflow-canvas/src/App.test.tsx +++ b/workflow-canvas/src/App.test.tsx @@ -2,10 +2,14 @@ import { fireEvent, render, screen, within } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { App } from './App'; +import { HELLO_DOCUMENT, failed, loaded } from './test-support/fixtures'; +import type { LoadResult } from './workflow/load-workflow'; + +const settled = (result: LoadResult) => Promise.resolve(result); describe('App', () => { it('renders the page title inside a main landmark', () => { - render(); + render(); const main = screen.getByRole('main'); expect( @@ -17,7 +21,7 @@ describe('App', () => { }); it('renders the Designer and Execution tabs with Designer selected', () => { - render(); + render(); expect( screen @@ -31,14 +35,52 @@ describe('App', () => { ).toBe('false'); }); - it('shows a placeholder when each tab is selected', () => { - render(); + it('shows that the workflow is loading until the load settles', async () => { + let finish: (result: LoadResult) => void = () => {}; + const pending = new Promise((resolve) => { + finish = resolve; + }); + render(); + expect(screen.getByText('Loading the workflow')).toBeTruthy(); + + finish(loaded(HELLO_DOCUMENT)); + + expect(await screen.findByTestId('node-stage-draft')).toBeTruthy(); + expect(screen.queryByText('Loading the workflow')).toBeNull(); + }); + + it('draws the workflow on the Designer tab', async () => { + render(); + + expect(await screen.findByTestId('node-stage-draft')).toBeTruthy(); + }); + + it('shows the error on the Designer tab when the load failed', async () => { + render(); + + expect( + await screen.findByText('The launch code is not valid.'), + ).toBeTruthy(); + }); + + it('shows a placeholder on the Execution tab', () => { + render(); + + fireEvent.click(screen.getByRole('tab', { name: 'Execution' })); + + expect( + screen.getByRole('tabpanel', { name: 'Execution' }).textContent, + ).toContain('Nothing here yet.'); + }); + + it('keeps the loaded workflow when the tabs are switched back and forth', async () => { + render(); + await screen.findByTestId('node-end'); + + fireEvent.click(screen.getByRole('tab', { name: 'Execution' })); + fireEvent.click(screen.getByRole('tab', { name: 'Designer' })); - for (const name of ['Designer', 'Execution']) { - fireEvent.click(screen.getByRole('tab', { name })); - expect(screen.getByRole('tabpanel', { name }).textContent).toContain( - 'Nothing here yet.', - ); - } + expect(screen.getByTestId('node-end')).toBeTruthy(); + expect(screen.queryByText('Loading the workflow')).toBeNull(); }); }); diff --git a/workflow-canvas/src/App.tsx b/workflow-canvas/src/App.tsx index 583192c3c..481692cbb 100644 --- a/workflow-canvas/src/App.tsx +++ b/workflow-canvas/src/App.tsx @@ -4,14 +4,20 @@ 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'; +import { Designer } from './designer/Designer'; +import { useSettled } from './designer/use-settled'; +import type { LoadResult } from './workflow/load-workflow'; const placeholder = Nothing here yet.; /** - * Root component: the page title and the Designer and Execution tabs. Both - * tabs show a placeholder until their content is built. + * Root component: the page title and 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. */ -export function App() { +export function App({ load }: { load: Promise }) { + // Read here, not in the tab, so switching tabs does not load again. + const result = useSettled(load); return ( , + }, { id: 'execution', label: 'Execution', content: placeholder }, ]} /> diff --git a/workflow-canvas/src/api/api.ts b/workflow-canvas/src/api/api.ts new file mode 100644 index 000000000..448ab190a --- /dev/null +++ b/workflow-canvas/src/api/api.ts @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 +import type { Session, WorkflowDocument } from './types'; + +/** + * The calls of the workflow API that the page makes. The page depends on + * this port; `createHttpApi` is the adapter that talks to the server. + */ +export interface WorkflowApi { + /** Trades the launch code for a session. */ + createSession(code: string): Promise; + /** Loads the root workflow of the session. */ + getWorkflow(token: string): Promise; +} + +/** A call that failed, with a message that is fit to show to a person. */ +export class ApiError extends Error { + constructor( + message: string, + /** The HTTP status, or 0 when the server did not answer. */ + readonly status: number, + /** The `code` of the error body, when there was one. */ + readonly code?: string, + ) { + super(message); + this.name = 'ApiError'; + } +} diff --git a/workflow-canvas/src/api/http-api.test.ts b/workflow-canvas/src/api/http-api.test.ts new file mode 100644 index 000000000..4bd2760a5 --- /dev/null +++ b/workflow-canvas/src/api/http-api.test.ts @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it, vi } from 'vitest'; +import { ApiError } from './api'; +import { createHttpApi } from './http-api'; + +const SESSION_BODY = { + token: 't-1', + root: 'hello', + harness: 'kiro-cli', + api_version: 1, + future_field: true, +}; + +const DOCUMENT_BODY = { + name: 'hello', + text: 'name: hello\n', + hash: 'a'.repeat(64), + read_only: false, + view: { + name: 'hello', + intent: { outcome: 'Add a greeting' }, + stages: [{ name: 'draft', agent: 'k-developer' }], + }, + diagnostics: [], +}; + +function reply(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status }); +} + +function apiFor(response: Response | Error) { + const fetchFn = vi.fn(() => + response instanceof Error + ? Promise.reject(response) + : Promise.resolve(response), + ); + return { fetchFn, api: createHttpApi(fetchFn as unknown as typeof fetch) }; +} + +describe('createHttpApi', () => { + it('posts the launch code as JSON to /api/session', async () => { + const { api, fetchFn } = apiFor(reply(SESSION_BODY)); + + await api.createSession('code-1'); + + const [path, init] = fetchFn.mock.calls[0] as unknown as [ + string, + RequestInit, + ]; + expect(path).toBe('/api/session'); + expect(init.method).toBe('POST'); + expect(init.headers).toEqual({ 'Content-Type': 'application/json' }); + expect(JSON.parse(init.body as string)).toEqual({ code: 'code-1' }); + }); + + it('reads the session and ignores fields it does not know', async () => { + const { api } = apiFor(reply(SESSION_BODY)); + + expect(await api.createSession('c')).toEqual({ + token: 't-1', + root: 'hello', + harness: 'kiro-cli', + apiVersion: 1, + }); + }); + + it('gets /api/workflow with the bearer token', async () => { + const { api, fetchFn } = apiFor(reply(DOCUMENT_BODY)); + + await api.getWorkflow('t-1'); + + const [path, init] = fetchFn.mock.calls[0] as unknown as [ + string, + RequestInit, + ]; + expect(path).toBe('/api/workflow'); + expect(init.headers).toEqual({ Authorization: 'Bearer t-1' }); + }); + + it('reads the workflow view', async () => { + const { api } = apiFor(reply(DOCUMENT_BODY)); + + expect(await api.getWorkflow('t')).toEqual({ + name: 'hello', + text: 'name: hello\n', + view: DOCUMENT_BODY.view, + diagnostics: [], + }); + }); + + it('reads a workflow with no file as no text and no view', async () => { + const none = { ...DOCUMENT_BODY, text: null, hash: null, view: null }; + const { api } = apiFor(reply(none)); + + expect(await api.getWorkflow('t')).toMatchObject({ + text: null, + view: null, + }); + }); + + it('reads the message and line of each diagnostic', async () => { + const body = { + ...DOCUMENT_BODY, + view: null, + diagnostics: [ + { code: 'MISSING_KEY', message: 'Missing key', line: 3 }, + { code: 'WRONG_TYPE', message: 'Wrong type' }, + ], + }; + const { api } = apiFor(reply(body)); + + expect((await api.getWorkflow('t')).diagnostics).toEqual([ + { message: 'Missing key', line: 3 }, + { message: 'Wrong type', line: 0 }, + ]); + }); + + it('fails with the sentence and code of an error body', async () => { + const { api } = apiFor( + reply( + { error: 'The launch code is not valid.', code: 'invalid_code' }, + 401, + ), + ); + + await expect(api.createSession('x')).rejects.toMatchObject({ + name: 'ApiError', + message: 'The launch code is not valid.', + status: 401, + code: 'invalid_code', + }); + }); + + it('fails with the status when the error body is not an error body', async () => { + const { api } = apiFor(new Response('', { status: 502 })); + + await expect(api.getWorkflow('t')).rejects.toMatchObject({ + message: 'The server answered 502.', + status: 502, + }); + }); + + it('fails with a plain message when the server does not answer', async () => { + const { api } = apiFor(new TypeError('Failed to fetch')); + + const error = await api.getWorkflow('t').catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ApiError); + expect(error).toMatchObject({ status: 0 }); + expect((error as ApiError).message).toContain('did not answer'); + }); + + it.each([ + ['a body that is not an object', 'oops'], + [ + 'a session with no token', + { root: 'hello', harness: 'kiro-cli', api_version: 1 }, + ], + [ + 'a session with no version', + { token: 't', root: 'hello', harness: 'kiro-cli' }, + ], + ])('fails on %s', async (_, body) => { + const { api } = apiFor(reply(body)); + + await expect(api.createSession('c')).rejects.toBeInstanceOf(ApiError); + }); + + it.each([ + [ + 'a view with no intent', + { ...DOCUMENT_BODY, view: { name: 'h', stages: [] } }, + ], + ['diagnostics that are not a list', { ...DOCUMENT_BODY, diagnostics: {} }], + [ + 'a stage with no agent', + { + ...DOCUMENT_BODY, + view: { ...DOCUMENT_BODY.view, stages: [{ name: 'd' }] }, + }, + ], + ])('fails on a workflow with %s', async (_, body) => { + const { api } = apiFor(reply(body)); + + await expect(api.getWorkflow('t')).rejects.toBeInstanceOf(ApiError); + }); +}); diff --git a/workflow-canvas/src/api/http-api.ts b/workflow-canvas/src/api/http-api.ts new file mode 100644 index 000000000..035fd60f9 --- /dev/null +++ b/workflow-canvas/src/api/http-api.ts @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: Apache-2.0 +import { ApiError, type WorkflowApi } from './api'; +import { + errorCodeOf, + errorMessageOf, + parseSession, + parseWorkflowDocument, +} from './parse'; + +type Fetch = typeof fetch; + +const UNREACHABLE = + 'The server did not answer. Check that it is still running.'; + +async function bodyOf(response: Response): Promise { + try { + return await response.json(); + } catch { + return undefined; + } +} + +async function send( + fetchFn: Fetch, + path: string, + init: RequestInit, +): Promise { + let response: Response; + try { + response = await fetchFn(path, init); + } catch { + throw new ApiError(UNREACHABLE, 0); + } + const body = await bodyOf(response); + if (response.ok) return body; + throw new ApiError( + errorMessageOf(body) ?? `The server answered ${response.status}.`, + response.status, + errorCodeOf(body), + ); +} + +/** + * The adapter of the workflow API over HTTP. It calls the relative `/api`, so + * the page and the server share one origin, and keeps no token itself. + */ +export function createHttpApi(fetchFn: Fetch = fetch): WorkflowApi { + return { + async createSession(code) { + const body = await send(fetchFn, '/api/session', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code }), + }); + return parseSession(body); + }, + async getWorkflow(token) { + const body = await send(fetchFn, '/api/workflow', { + headers: { Authorization: `Bearer ${token}` }, + }); + return parseWorkflowDocument(body); + }, + }; +} diff --git a/workflow-canvas/src/api/parse.ts b/workflow-canvas/src/api/parse.ts new file mode 100644 index 000000000..a3df2d405 --- /dev/null +++ b/workflow-canvas/src/api/parse.ts @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: Apache-2.0 +import { ApiError } from './api'; +import type { + DiagnosticView, + Session, + StageView, + WorkflowDocument, + WorkflowView, +} from './types'; + +type Json = Record; + +const BAD_REPLY = 'The server sent a reply that this page does not understand.'; + +function fail(): never { + throw new ApiError(BAD_REPLY, 200); +} + +function isObject(value: unknown): value is Json { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function objectOf(value: unknown): Json { + return isObject(value) ? value : fail(); +} + +function stringOf(value: unknown): string { + return typeof value === 'string' ? value : fail(); +} + +function arrayOf(value: unknown): unknown[] { + return Array.isArray(value) ? value : fail(); +} + +function parseStage(value: unknown): StageView { + const stage = objectOf(value); + return { name: stringOf(stage.name), agent: stringOf(stage.agent) }; +} + +function parseView(value: unknown): WorkflowView | null { + if (value === null) return null; + const view = objectOf(value); + return { + name: stringOf(view.name), + intent: { outcome: stringOf(objectOf(view.intent).outcome) }, + stages: arrayOf(view.stages).map(parseStage), + }; +} + +function parseDiagnostic(value: unknown): DiagnosticView { + const diagnostic = objectOf(value); + const line = diagnostic.line; + return { + message: stringOf(diagnostic.message), + line: typeof line === 'number' ? line : 0, + }; +} + +/** Reads a `SessionResponse`; unknown fields are ignored. */ +export function parseSession(body: unknown): Session { + const session = objectOf(body); + const version = session.api_version; + return { + token: stringOf(session.token), + root: stringOf(session.root), + harness: stringOf(session.harness), + apiVersion: typeof version === 'number' ? version : fail(), + }; +} + +/** Reads a `WorkflowDocument`; unknown fields are ignored. */ +export function parseWorkflowDocument(body: unknown): WorkflowDocument { + const document = objectOf(body); + const text = document.text; + return { + name: stringOf(document.name), + text: text === null ? null : stringOf(text), + view: parseView(document.view), + diagnostics: arrayOf(document.diagnostics).map(parseDiagnostic), + }; +} + +/** The sentence of an `Error` body, or `undefined` when it has none. */ +export function errorMessageOf(body: unknown): string | undefined { + return isObject(body) && typeof body.error === 'string' + ? body.error + : undefined; +} + +/** The `code` of an `Error` body, or `undefined` when it has none. */ +export function errorCodeOf(body: unknown): string | undefined { + return isObject(body) && typeof body.code === 'string' + ? body.code + : undefined; +} diff --git a/workflow-canvas/src/api/types.ts b/workflow-canvas/src/api/types.ts new file mode 100644 index 000000000..0b08c307f --- /dev/null +++ b/workflow-canvas/src/api/types.ts @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: Apache-2.0 +// The parts of api/openapi.yaml that the page reads. + +/** What `POST /api/session` returns. */ +export interface Session { + token: string; + root: string; + harness: string; + apiVersion: number; +} + +/** One unit of work in a workflow view. */ +export interface StageView { + name: string; + agent: string; +} + +/** The parsed workflow, ready to draw. */ +export interface WorkflowView { + name: string; + intent: { outcome: string }; + stages: StageView[]; +} + +/** A problem the server found in the workflow text. */ +export interface DiagnosticView { + message: string; + line: number; +} + +/** What `GET /api/workflow` returns. */ +export interface WorkflowDocument { + name: string; + /** `null` when the file does not exist yet. */ + text: string | null; + /** `null` when there is no text or the text has errors. */ + view: WorkflowView | null; + diagnostics: DiagnosticView[]; +} diff --git a/workflow-canvas/src/canvas/WorkflowCanvas.tsx b/workflow-canvas/src/canvas/WorkflowCanvas.tsx new file mode 100644 index 000000000..f33dd8116 --- /dev/null +++ b/workflow-canvas/src/canvas/WorkflowCanvas.tsx @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: Apache-2.0 +import { + Background, + BackgroundVariant, + Controls, + MarkerType, + ReactFlow, + type DefaultEdgeOptions, + type NodeTypes, +} from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; +import type { CanvasGraph } from '../graph'; +import './canvas.css'; +import { EndNode, IntentNode, StageNode } from './nodes'; + +const NODE_TYPES: NodeTypes = { + intent: IntentNode, + stage: StageNode, + end: EndNode, +}; + +const EDGE_COLOR = '#5f6b7a'; + +const EDGE_OPTIONS: DefaultEdgeOptions = { + type: 'straight', + style: { stroke: EDGE_COLOR, strokeWidth: 2.5 }, + markerEnd: { + type: MarkerType.ArrowClosed, + color: EDGE_COLOR, + width: 16, + height: 16, + }, +}; + +const DOT_COLOR = '#c9c1b3'; + +/** + * Draws a workflow graph, read-only. The view can be panned and zoomed, but + * nodes cannot be moved, selected, connected or edited. + */ +export function WorkflowCanvas({ graph }: { graph: CanvasGraph }) { + return ( +
+ + + + +
+ ); +} diff --git a/workflow-canvas/src/canvas/canvas.css b/workflow-canvas/src/canvas/canvas.css new file mode 100644 index 000000000..bc0556ef5 --- /dev/null +++ b/workflow-canvas/src/canvas/canvas.css @@ -0,0 +1,95 @@ +/* SPDX-License-Identifier: Apache-2.0 */ + +/* Colours of the light theme for the parts that the canvas draws itself. */ +.wf-canvas { + --wf-canvas-bg: #f5f3ee; + --wf-canvas-dots: #c9c1b3; + --wf-node-bg: #ffffff; + --wf-node-border: #7a7490; + --wf-text: #16131f; + --wf-text-muted: #56516a; + --wf-port-bg: #414d5c; + --wf-port-text: #ffffff; + --wf-port-ring: #16131f; + + height: 560px; + border-radius: 12px; + background: var(--wf-canvas-bg); +} + +.wf-node { + box-sizing: border-box; + width: 100%; + height: 100%; + padding: 10px 12px; + border: 2px solid var(--wf-node-border); + border-radius: 12px; + background: var(--wf-node-bg); + color: var(--wf-text); + font-size: 12px; + line-height: 1.35; +} + +.wf-node-title { + overflow: hidden; + font-size: 14px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wf-node-sub { + overflow: hidden; + color: var(--wf-text-muted); + text-overflow: ellipsis; + white-space: nowrap; +} + +/* The outcome of the intent wraps onto up to three lines. */ +.wf-node-intent-outcome { + display: -webkit-box; + overflow: hidden; + color: var(--wf-text-muted); + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + line-clamp: 3; +} + +.wf-node-end { + display: flex; + align-items: center; + justify-content: center; +} + +/* A run order port: a small square with an S, at the right of the node. */ +.react-flow .wf-port { + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + min-width: 0; + min-height: 0; + border: 2px solid var(--wf-port-ring); + border-radius: 4px; + background: var(--wf-port-bg); + color: var(--wf-port-text); + font: + 700 10px/1 Arial, + sans-serif; + pointer-events: none; +} + +.react-flow .wf-port-in { + top: 0; + right: 16px; + left: auto; + transform: translate(50%, -50%); +} + +.react-flow .wf-port-out { + right: 16px; + bottom: 0; + left: auto; + transform: translate(50%, 50%); +} diff --git a/workflow-canvas/src/canvas/nodes.tsx b/workflow-canvas/src/canvas/nodes.tsx new file mode 100644 index 000000000..e9bbefd3b --- /dev/null +++ b/workflow-canvas/src/canvas/nodes.tsx @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: Apache-2.0 +import { Handle, Position, type Node, type NodeProps } from '@xyflow/react'; +import { + SEQUENCE_IN, + SEQUENCE_OUT, + type IntentGraphNode, + type StageGraphNode, +} from '../graph'; + +type FlowNode }> = Node< + N['data'], + N['type'] +>; + +/** The port where run order arrives, at the top right of a node. */ +function SequenceIn() { + return ( + + + + ); +} + +/** The port where run order leaves, at the bottom right of a node. */ +function SequenceOut() { + return ( + + + + ); +} + +/** The start of the workflow: the outcome it is for, with a run order out. */ +export function IntentNode({ data }: NodeProps>) { + return ( +
+
intent
+
{data.outcome}
+ +
+ ); +} + +/** One stage: its name, then its agent and harness, with run order in and out. */ +export function StageNode({ data }: NodeProps>) { + return ( +
+ +
{data.name}
+
+ {data.agent} · {data.harness} +
+ +
+ ); +} + +/** The finish of the workflow, with a run order in. */ +export function EndNode() { + return ( +
+ +
end
+
+ ); +} diff --git a/workflow-canvas/src/designer/Designer.test.tsx b/workflow-canvas/src/designer/Designer.test.tsx new file mode 100644 index 000000000..340e42c5d --- /dev/null +++ b/workflow-canvas/src/designer/Designer.test.tsx @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: Apache-2.0 +import { render, screen, within } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { HELLO_DOCUMENT, failed, loaded } from '../test-support/fixtures'; +import { Designer } from './Designer'; + +describe('Designer', () => { + it('shows that the workflow is loading while there is no result', () => { + render(); + + expect(screen.getByText('Loading the workflow')).toBeTruthy(); + expect(screen.queryByRole('region', { name: 'Workflow graph' })).toBeNull(); + }); + + it('draws the intent, both stages and the end', () => { + render(); + + const graph = screen.getByRole('region', { + name: 'Workflow graph', + }); + const intent = within(graph).getByTestId('node-intent'); + expect(intent.textContent).toContain('Add a greeting to the README'); + expect(within(graph).getByTestId('node-stage-draft').textContent).toContain( + 'k-developer · kiro-cli', + ); + expect( + within(graph).getByTestId('node-stage-review').textContent, + ).toContain('k-quality-assurance · kiro-cli'); + expect(within(graph).getByTestId('node-end')).toBeTruthy(); + }); + + it('names the workflow in the header of the canvas', () => { + render(); + + expect( + screen.getByRole('heading', { name: 'Workflow: hello' }), + ).toBeTruthy(); + }); + + it('shows the message when the load failed', () => { + render(); + + expect(screen.getByText('The workflow did not load')).toBeTruthy(); + expect(screen.getByText('The launch code is not valid.')).toBeTruthy(); + expect(screen.queryByRole('region', { name: 'Workflow graph' })).toBeNull(); + }); + + it('says there is no file when the workflow has no text', () => { + const none = { ...HELLO_DOCUMENT, text: null, view: null }; + + render(); + + expect(screen.getByText('Nothing to draw')).toBeTruthy(); + expect(screen.getByText('The workflow has no file yet.')).toBeTruthy(); + }); + + it('lists the problems when the file has errors', () => { + const broken = { + ...HELLO_DOCUMENT, + view: null, + diagnostics: [{ message: 'Missing key: intent', line: 2 }], + }; + + render(); + + expect( + screen.getByText('The workflow file has errors, so it cannot be drawn.'), + ).toBeTruthy(); + expect( + within( + screen.getByRole('list', { name: 'Problems in the workflow file' }), + ).getByText('Line 2: Missing key: intent'), + ).toBeTruthy(); + }); +}); diff --git a/workflow-canvas/src/designer/Designer.tsx b/workflow-canvas/src/designer/Designer.tsx new file mode 100644 index 000000000..cf939af1a --- /dev/null +++ b/workflow-canvas/src/designer/Designer.tsx @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: Apache-2.0 +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 { LoadedWorkflow, LoadResult } from '../workflow/load-workflow'; +import { EmptyState, ErrorState, LoadingState } from './states'; + +function DrawnWorkflow({ workflow }: { workflow: LoadedWorkflow }) { + const { document, harness } = workflow; + const { view } = document; + const graph = useMemo( + () => (view ? buildGraph(view, harness) : undefined), + [view, harness], + ); + if (!view || !graph) { + return ( + + ); + } + return ( + + Workflow: {view.name} + + } + > + + + ); +} + +/** + * The Designer tab: the workflow drawn as a graph, or a message while it + * loads (`result` is `undefined`), when it failed to load, or when there is + * nothing to draw. + */ +export function Designer({ result }: { result: LoadResult | undefined }) { + if (!result) return ; + if (!result.ok) return ; + return ; +} diff --git a/workflow-canvas/src/designer/states.tsx b/workflow-canvas/src/designer/states.tsx new file mode 100644 index 000000000..f40880d46 --- /dev/null +++ b/workflow-canvas/src/designer/states.tsx @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: Apache-2.0 +import Alert from '@cloudscape-design/components/alert'; +import Box from '@cloudscape-design/components/box'; +import StatusIndicator from '@cloudscape-design/components/status-indicator'; +import type { DiagnosticView } from '../api/types'; + +/** Shown while the session opens and the workflow loads. */ +export function LoadingState() { + return Loading the workflow; +} + +/** Shown when the workflow could not be loaded. */ +export function ErrorState({ message }: { message: string }) { + return ( + + {message} + + ); +} + +/** Shown when there is nothing to draw: no file yet, or a file with errors. */ +export function EmptyState({ + hasFile, + diagnostics, +}: { + hasFile: boolean; + diagnostics: readonly DiagnosticView[]; +}) { + return ( + + Nothing to draw + + {hasFile + ? 'The workflow file has errors, so it cannot be drawn.' + : 'The workflow has no file yet.'} + + {diagnostics.length > 0 && ( +
    + {diagnostics.map((diagnostic, index) => ( +
  • + Line {diagnostic.line}: {diagnostic.message} +
  • + ))} +
+ )} +
+ ); +} diff --git a/workflow-canvas/src/designer/use-settled.test.ts b/workflow-canvas/src/designer/use-settled.test.ts new file mode 100644 index 000000000..ec958feee --- /dev/null +++ b/workflow-canvas/src/designer/use-settled.test.ts @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 +import { renderHook, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { useSettled } from './use-settled'; + +function deferred() { + let resolve: (value: T) => void = () => {}; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +describe('useSettled', () => { + it('is undefined until the promise settles, then its value', async () => { + const { promise, resolve } = deferred(); + const { result } = renderHook(() => useSettled(promise)); + expect(result.current).toBeUndefined(); + + resolve('done'); + + await waitFor(() => expect(result.current).toBe('done')); + }); + + it('goes back to undefined while a new promise is pending', async () => { + const first = Promise.resolve('first'); + const next = deferred(); + const { result, rerender } = renderHook( + ({ promise }) => useSettled(promise), + { initialProps: { promise: first } }, + ); + await waitFor(() => expect(result.current).toBe('first')); + + rerender({ promise: next.promise }); + expect(result.current).toBeUndefined(); + next.resolve('second'); + + await waitFor(() => expect(result.current).toBe('second')); + }); + + it('ignores a promise that settles after the hook was unmounted', async () => { + const { promise, resolve } = deferred(); + const { result, unmount } = renderHook(() => useSettled(promise)); + + unmount(); + resolve('late'); + await promise; + + expect(result.current).toBeUndefined(); + }); +}); diff --git a/workflow-canvas/src/designer/use-settled.ts b/workflow-canvas/src/designer/use-settled.ts new file mode 100644 index 000000000..e0f0a9535 --- /dev/null +++ b/workflow-canvas/src/designer/use-settled.ts @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Apache-2.0 +import { useEffect, useState } from 'react'; + +/** + * The value a promise settles with, or `undefined` until it does. The promise + * is made outside React, so a repeated effect (as in strict mode) does not + * start the work twice. The promise must not reject. + */ +export function useSettled(promise: Promise): T | undefined { + const [settled, setSettled] = useState<{ of: Promise; value: T }>(); + + useEffect(() => { + let current = true; + void promise.then((value) => { + if (current) setSettled({ of: promise, value }); + }); + return () => { + current = false; + }; + }, [promise]); + + return settled?.of === promise ? settled.value : undefined; +} diff --git a/workflow-canvas/src/graph/build-graph.test.ts b/workflow-canvas/src/graph/build-graph.test.ts new file mode 100644 index 000000000..3ca215ca9 --- /dev/null +++ b/workflow-canvas/src/graph/build-graph.test.ts @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from 'vitest'; +import { buildGraph, stageNodeId } from './build-graph'; +import { NODE_GAP, NODE_HEIGHT, NODE_WIDTH } from './layout'; +import type { WorkflowSpec } from './model'; + +const HELLO: WorkflowSpec = { + name: 'hello', + intent: { outcome: 'Add a greeting to the README' }, + stages: [ + { name: 'draft', agent: 'k-developer' }, + { name: 'review', agent: 'k-quality-assurance' }, + ], +}; + +describe('buildGraph', () => { + it('draws the intent, each stage in list order, then the end', () => { + const { nodes } = buildGraph(HELLO, 'kiro-cli'); + + expect(nodes.map((node) => [node.id, node.type])).toEqual([ + ['intent', 'intent'], + ['stage-draft', 'stage'], + ['stage-review', 'stage'], + ['end', 'end'], + ]); + }); + + it('keeps the order of the stages as the workflow lists them', () => { + const reversed = { ...HELLO, stages: [...HELLO.stages].reverse() }; + + const ids = buildGraph(reversed, 'kiro-cli').nodes.map((node) => node.id); + + expect(ids).toEqual(['intent', 'stage-review', 'stage-draft', 'end']); + }); + + it('gives the intent its outcome', () => { + const [intent] = buildGraph(HELLO, 'kiro-cli').nodes; + + expect(intent?.data).toEqual({ outcome: 'Add a greeting to the README' }); + }); + + it('gives each stage its name, its agent and the harness', () => { + const stages = buildGraph(HELLO, 'kiro-cli').nodes.filter( + (node) => node.type === 'stage', + ); + + expect(stages.map((node) => node.data)).toEqual([ + { name: 'draft', agent: 'k-developer', harness: 'kiro-cli' }, + { name: 'review', agent: 'k-quality-assurance', harness: 'kiro-cli' }, + ]); + }); + + it('joins each node to the next with an edge, in list order', () => { + const { edges } = buildGraph(HELLO, 'kiro-cli'); + + expect(edges.map((edge) => [edge.source, edge.target])).toEqual([ + ['intent', 'stage-draft'], + ['stage-draft', 'stage-review'], + ['stage-review', 'end'], + ]); + }); + + it('runs every edge from a sequence-out port to a sequence-in port', () => { + const { edges } = buildGraph(HELLO, 'kiro-cli'); + + for (const edge of edges) { + expect(edge.sourceHandle).toBe('sequence-out'); + expect(edge.targetHandle).toBe('sequence-in'); + } + }); + + it('gives each edge an id that is not another edge’s id', () => { + const ids = buildGraph(HELLO, 'kiro-cli').edges.map((edge) => edge.id); + + expect(new Set(ids).size).toBe(ids.length); + }); + + it('points every edge at nodes that exist', () => { + const { nodes, edges } = buildGraph(HELLO, 'kiro-cli'); + const ids = new Set(nodes.map((node) => node.id)); + + for (const edge of edges) { + expect(ids.has(edge.source)).toBe(true); + expect(ids.has(edge.target)).toBe(true); + } + }); + + it('stacks the nodes down one centred column with a gap between them', () => { + const { nodes } = buildGraph(HELLO, 'kiro-cli'); + + expect(nodes.map((node) => node.position)).toEqual([ + { x: -NODE_WIDTH / 2, y: 0 }, + { x: -NODE_WIDTH / 2, y: NODE_HEIGHT.intent + NODE_GAP }, + { + x: -NODE_WIDTH / 2, + y: NODE_HEIGHT.intent + NODE_HEIGHT.stage + 2 * NODE_GAP, + }, + { + x: -NODE_WIDTH / 2, + y: NODE_HEIGHT.intent + 2 * NODE_HEIGHT.stage + 3 * NODE_GAP, + }, + ]); + }); + + it('sizes each node by its kind', () => { + const { nodes } = buildGraph(HELLO, 'kiro-cli'); + + expect(nodes.map((node) => [node.width, node.height])).toEqual([ + [NODE_WIDTH, NODE_HEIGHT.intent], + [NODE_WIDTH, NODE_HEIGHT.stage], + [NODE_WIDTH, NODE_HEIGHT.stage], + [NODE_WIDTH, NODE_HEIGHT.end], + ]); + }); + + it('draws a workflow with no stages as the intent joined to the end', () => { + const { nodes, edges } = buildGraph({ ...HELLO, stages: [] }, 'kiro-cli'); + + expect(nodes.map((node) => node.id)).toEqual(['intent', 'end']); + expect(edges.map((edge) => edge.id)).toEqual(['intent->end']); + }); + + it('does not change the workflow it is given', () => { + const copy = structuredClone(HELLO); + + buildGraph(HELLO, 'kiro-cli'); + + expect(HELLO).toEqual(copy); + }); + + it('gives a stage the same id every time', () => { + expect(stageNodeId('draft')).toBe('stage-draft'); + }); +}); diff --git a/workflow-canvas/src/graph/build-graph.ts b/workflow-canvas/src/graph/build-graph.ts new file mode 100644 index 000000000..9d49b9852 --- /dev/null +++ b/workflow-canvas/src/graph/build-graph.ts @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: Apache-2.0 +import { NODE_HEIGHT, stackDown } from './layout'; +import { + SEQUENCE_IN, + SEQUENCE_OUT, + type CanvasGraph, + type GraphEdge, + type EndGraphNode, + type IntentGraphNode, + type StageGraphNode, + type Unplaced, + type UnplacedNode, + type WorkflowSpec, +} from './model'; + +const INTENT_ID = 'intent'; +const END_ID = 'end'; + +/** The id of the node that draws the stage called `name`. */ +export function stageNodeId(name: string): string { + return `stage-${name}`; +} + +function intentNode(spec: WorkflowSpec): Unplaced { + return { + id: INTENT_ID, + type: 'intent', + height: NODE_HEIGHT.intent, + data: { outcome: spec.intent.outcome }, + }; +} + +function stageNode( + stage: WorkflowSpec['stages'][number], + harness: string, +): Unplaced { + return { + id: stageNodeId(stage.name), + type: 'stage', + height: NODE_HEIGHT.stage, + data: { name: stage.name, agent: stage.agent, harness }, + }; +} + +function endNode(): Unplaced { + return { id: END_ID, type: 'end', height: NODE_HEIGHT.end, data: {} }; +} + +/** One edge from each node to the next. */ +function chain(ids: readonly string[]): GraphEdge[] { + return ids.slice(1).map((target, index) => { + const source = ids[index] ?? ''; + return { + id: `${source}->${target}`, + source, + target, + sourceHandle: SEQUENCE_OUT, + targetHandle: SEQUENCE_IN, + }; + }); +} + +/** + * Turns a workflow into what the canvas draws: the intent, the stages in list + * order, and the end, in one column, joined by run-order edges. + */ +export function buildGraph(spec: WorkflowSpec, harness: string): CanvasGraph { + const nodes = stackDown([ + intentNode(spec), + ...spec.stages.map((stage) => stageNode(stage, harness)), + endNode(), + ]); + return { nodes, edges: chain(nodes.map((node) => node.id)) }; +} diff --git a/workflow-canvas/src/graph/index.ts b/workflow-canvas/src/graph/index.ts new file mode 100644 index 000000000..fae7da231 --- /dev/null +++ b/workflow-canvas/src/graph/index.ts @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: Apache-2.0 +export { buildGraph, stageNodeId } from './build-graph'; +export { NODE_HEIGHT, NODE_WIDTH } from './layout'; +export { SEQUENCE_IN, SEQUENCE_OUT } from './model'; +export type { + CanvasGraph, + EndGraphNode, + GraphEdge, + GraphNode, + IntentGraphNode, + StageGraphNode, + WorkflowSpec, +} from './model'; diff --git a/workflow-canvas/src/graph/layout.ts b/workflow-canvas/src/graph/layout.ts new file mode 100644 index 000000000..73793a839 --- /dev/null +++ b/workflow-canvas/src/graph/layout.ts @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** Every node is as wide as this, so the order edges run straight down. */ +export const NODE_WIDTH = 280; +/** The height of each kind of node, as the node components draw them. */ +export const NODE_HEIGHT = { intent: 96, stage: 76, end: 44 } as const; +/** The empty space between one node and the next. */ +export const NODE_GAP = 56; + +/** + * Places the items in one column, in order, centred on x = 0, with `NODE_GAP` + * between them. Each item gains its width and position. + */ +export function stackDown(items: readonly T[]) { + let top = 0; + return items.map((item) => { + const placed = { + ...item, + width: NODE_WIDTH, + position: { x: -NODE_WIDTH / 2, y: top }, + }; + top += item.height + NODE_GAP; + return placed; + }); +} diff --git a/workflow-canvas/src/graph/model.ts b/workflow-canvas/src/graph/model.ts new file mode 100644 index 000000000..59497d03a --- /dev/null +++ b/workflow-canvas/src/graph/model.ts @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** The part of a workflow that can be drawn. The API view satisfies it. */ +export interface WorkflowSpec { + name: string; + intent: { outcome: string }; + stages: readonly { name: string; agent: string }[]; +} + +interface Placed { + id: string; + position: { x: number; y: number }; + width: number; + height: number; +} + +/** The start of the workflow: what it is meant to achieve. */ +export type IntentGraphNode = Placed & { + type: 'intent'; + data: { outcome: string }; +}; + +/** One unit of work, done by one agent on one harness. */ +export type StageGraphNode = Placed & { + type: 'stage'; + data: { name: string; agent: string; harness: string }; +}; + +/** The finish of the workflow. */ +export type EndGraphNode = Placed & { + type: 'end'; + data: Record; +}; + +export type GraphNode = IntentGraphNode | StageGraphNode | EndGraphNode; + +/** A node that has its content and height but is not placed yet. */ +export type Unplaced = Omit; + +/** Any kind of node, not placed yet. */ +export type UnplacedNode = + Unplaced | Unplaced | Unplaced; + +/** Run order from the sequence-out port of one node to the next one's in. */ +export interface GraphEdge { + id: string; + source: string; + target: string; + sourceHandle: typeof SEQUENCE_OUT; + targetHandle: typeof SEQUENCE_IN; +} + +/** What to draw: nodes from the intent down to the end, and the order edges. */ +export interface CanvasGraph { + nodes: GraphNode[]; + edges: GraphEdge[]; +} + +/** The id of the port where a node's run order leaves it. */ +export const SEQUENCE_OUT = 'sequence-out'; +/** The id of the port where the previous node's run order arrives. */ +export const SEQUENCE_IN = 'sequence-in'; diff --git a/workflow-canvas/src/launch/launch-code.test.ts b/workflow-canvas/src/launch/launch-code.test.ts new file mode 100644 index 000000000..29ce2a620 --- /dev/null +++ b/workflow-canvas/src/launch/launch-code.test.ts @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it, vi } from 'vitest'; +import { takeLaunchCode, type LaunchEnvironment } from './launch-code'; + +function environment(hash: string, search = ''): LaunchEnvironment { + return { + location: { hash, pathname: '/app/', search }, + history: { replaceState: vi.fn() }, + }; +} + +describe('takeLaunchCode', () => { + it('returns the code that follows the hash', () => { + expect(takeLaunchCode(environment('#abc-123'))).toBe('abc-123'); + }); + + it('removes the fragment from the address and keeps path and query', () => { + const env = environment('#abc', '?x=1'); + + takeLaunchCode(env); + + expect(env.history.replaceState).toHaveBeenCalledWith( + null, + '', + '/app/?x=1', + ); + }); + + it('returns undefined and leaves the address alone without a fragment', () => { + const env = environment(''); + + expect(takeLaunchCode(env)).toBeUndefined(); + expect(env.history.replaceState).not.toHaveBeenCalled(); + }); + + it('returns undefined for an empty fragment and still clears it', () => { + const env = environment('#'); + + expect(takeLaunchCode(env)).toBeUndefined(); + expect(env.history.replaceState).toHaveBeenCalledOnce(); + }); + + it('decodes a percent-encoded code', () => { + expect(takeLaunchCode(environment('#a%20b'))).toBe('a b'); + }); + + it('keeps a code that is not valid percent-encoding as it is', () => { + expect(takeLaunchCode(environment('#100%'))).toBe('100%'); + }); +}); diff --git a/workflow-canvas/src/launch/launch-code.ts b/workflow-canvas/src/launch/launch-code.ts new file mode 100644 index 000000000..5bf58d57b --- /dev/null +++ b/workflow-canvas/src/launch/launch-code.ts @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** The parts of `window` that the launch code needs. */ +export interface LaunchEnvironment { + location: Pick; + history: Pick; +} + +/** + * Reads the launch code from the fragment of the address and removes the + * fragment from the address bar. Returns `undefined` when there is no code. + */ +export function takeLaunchCode(env: LaunchEnvironment): string | undefined { + const code = decodeFragment(env.location.hash); + if (env.location.hash !== '') { + const { pathname, search } = env.location; + env.history.replaceState(null, '', `${pathname}${search}`); + } + return code === '' ? undefined : code; +} + +function decodeFragment(hash: string): string { + const raw = hash.replace(/^#/, ''); + try { + return decodeURIComponent(raw); + } catch { + return raw; + } +} diff --git a/workflow-canvas/src/main.tsx b/workflow-canvas/src/main.tsx index f77683f5f..45412fd24 100644 --- a/workflow-canvas/src/main.tsx +++ b/workflow-canvas/src/main.tsx @@ -3,12 +3,19 @@ import '@cloudscape-design/global-styles/index.css'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { App } from './App'; +import { createHttpApi } from './api/http-api'; +import { takeLaunchCode } from './launch/launch-code'; +import { loadWorkflow } from './workflow/load-workflow'; const root = document.getElementById('root'); if (!root) throw new Error('Missing #root element'); +// Started once, outside React: the launch code works only once, and strict mode +// runs effects twice. +const load = loadWorkflow(createHttpApi(), takeLaunchCode(window)); + createRoot(root).render( - + , ); diff --git a/workflow-canvas/src/test-support/fixtures.ts b/workflow-canvas/src/test-support/fixtures.ts new file mode 100644 index 000000000..31821d04f --- /dev/null +++ b/workflow-canvas/src/test-support/fixtures.ts @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: Apache-2.0 +import type { Session, WorkflowDocument, WorkflowView } from '../api/types'; +import type { LoadResult } from '../workflow/load-workflow'; + +/** The workflow that the stub serves: a draft stage, then a review stage. */ +export const HELLO_VIEW: WorkflowView = { + name: 'hello', + intent: { outcome: 'Add a greeting to the README' }, + stages: [ + { name: 'draft', agent: 'k-developer' }, + { name: 'review', agent: 'k-quality-assurance' }, + ], +}; + +/** The session that the stub opens for the launch code. */ +export const HELLO_SESSION: Session = { + token: 'token-1', + root: 'hello', + harness: 'kiro-cli', + apiVersion: 1, +}; + +/** The document that `GET /api/workflow` returns for the workflow above. */ +export const HELLO_DOCUMENT: WorkflowDocument = { + name: 'hello', + text: 'name: hello\n', + view: HELLO_VIEW, + diagnostics: [], +}; + +/** A load that succeeded with the document. */ +export function loaded(document: WorkflowDocument): LoadResult { + return { ok: true, workflow: { harness: 'kiro-cli', document } }; +} + +/** A load that failed with the message. */ +export function failed(message: string): LoadResult { + return { ok: false, message }; +} diff --git a/workflow-canvas/src/test-support/setup.ts b/workflow-canvas/src/test-support/setup.ts index c79c54ef5..8c5c47d7f 100644 --- a/workflow-canvas/src/test-support/setup.ts +++ b/workflow-canvas/src/test-support/setup.ts @@ -1,11 +1,26 @@ // SPDX-License-Identifier: Apache-2.0 /** * Vitest setup file. Unmounts rendered trees after each test, because Testing - * Library only does this on its own when test globals are enabled. + * Library only does this on its own when test globals are enabled, and adds + * the two browser APIs that the graph library needs and jsdom lacks. */ import { cleanup } from '@testing-library/react'; import { afterEach } from 'vitest'; +class NoopResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +class IdentityMatrix { + m22 = 1; +} + +globalThis.ResizeObserver ??= NoopResizeObserver; +globalThis.DOMMatrixReadOnly ??= + IdentityMatrix as unknown as typeof DOMMatrixReadOnly; + afterEach(() => { cleanup(); }); diff --git a/workflow-canvas/src/workflow/load-workflow.test.ts b/workflow-canvas/src/workflow/load-workflow.test.ts new file mode 100644 index 000000000..8edefd35f --- /dev/null +++ b/workflow-canvas/src/workflow/load-workflow.test.ts @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it, vi } from 'vitest'; +import { ApiError, type WorkflowApi } from '../api/api'; +import { HELLO_DOCUMENT, HELLO_SESSION } from '../test-support/fixtures'; +import { loadWorkflow } from './load-workflow'; + +function fakeApi(overrides: Partial = {}): WorkflowApi { + return { + createSession: vi.fn().mockResolvedValue(HELLO_SESSION), + getWorkflow: vi.fn().mockResolvedValue(HELLO_DOCUMENT), + ...overrides, + }; +} + +describe('loadWorkflow', () => { + it('opens a session with the code, then loads with its token', async () => { + const api = fakeApi(); + + const result = await loadWorkflow(api, 'code-1'); + + expect(api.createSession).toHaveBeenCalledWith('code-1'); + expect(api.getWorkflow).toHaveBeenCalledWith('token-1'); + expect(result).toEqual({ + ok: true, + workflow: { harness: 'kiro-cli', document: HELLO_DOCUMENT }, + }); + }); + + it('fails without calling the API when there is no launch code', async () => { + const api = fakeApi(); + + const result = await loadWorkflow(api, undefined); + + expect(result).toMatchObject({ ok: false }); + expect(result.ok === false && result.message).toContain('launch code'); + expect(api.createSession).not.toHaveBeenCalled(); + }); + + it('fails with the message of a refused session', async () => { + const api = fakeApi({ + createSession: vi + .fn() + .mockRejectedValue(new ApiError('The launch code is not valid.', 401)), + }); + + expect(await loadWorkflow(api, 'bad')).toEqual({ + ok: false, + message: 'The launch code is not valid.', + }); + expect(api.getWorkflow).not.toHaveBeenCalled(); + }); + + it('fails with the message of a refused load', async () => { + const api = fakeApi({ + getWorkflow: vi.fn().mockRejectedValue(new ApiError('Gone.', 404)), + }); + + expect(await loadWorkflow(api, 'code-1')).toEqual({ + ok: false, + message: 'Gone.', + }); + }); + + it('fails with a plain message for something that is not an Error', async () => { + const api = fakeApi({ + getWorkflow: vi.fn().mockRejectedValue('boom'), + }); + + expect(await loadWorkflow(api, 'code-1')).toEqual({ + ok: false, + message: 'The workflow did not load.', + }); + }); +}); diff --git a/workflow-canvas/src/workflow/load-workflow.ts b/workflow-canvas/src/workflow/load-workflow.ts new file mode 100644 index 000000000..80bc25798 --- /dev/null +++ b/workflow-canvas/src/workflow/load-workflow.ts @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: Apache-2.0 +import type { WorkflowApi } from '../api/api'; +import type { WorkflowDocument } from '../api/types'; + +/** A workflow loaded from the server, with the harness that runs its stages. */ +export interface LoadedWorkflow { + harness: string; + document: WorkflowDocument; +} + +/** The result of a load. It never rejects, so a caller cannot miss a failure. */ +export type LoadResult = + { ok: true; workflow: LoadedWorkflow } | { ok: false; message: string }; + +const NO_LAUNCH_CODE = + 'There is no launch code in the address. Open the launch address that the server printed; it ends in #.'; + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : 'The workflow did not load.'; +} + +/** + * Opens a session with the launch code, then loads the workflow of the + * session. The token stays in this function and is not kept. + */ +export async function loadWorkflow( + api: WorkflowApi, + launchCode: string | undefined, +): Promise { + if (launchCode === undefined) return { ok: false, message: NO_LAUNCH_CODE }; + try { + const session = await api.createSession(launchCode); + const document = await api.getWorkflow(session.token); + return { ok: true, workflow: { harness: session.harness, document } }; + } catch (error) { + return { ok: false, message: messageOf(error) }; + } +} diff --git a/workflow-canvas/test-support/graph-purity.test.ts b/workflow-canvas/test-support/graph-purity.test.ts new file mode 100644 index 000000000..16fbf5756 --- /dev/null +++ b/workflow-canvas/test-support/graph-purity.test.ts @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Checks that the graph core does no IO and imports no UI, so it can be + * tested and reused without a browser. It may import only its own modules. + */ +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { + problemsIn, + problemsUnder, + sourcesUnder, + type PurityRules, +} from './purity.ts'; + +const RULES: PurityRules = { + dir: fileURLToPath(new URL('../src/graph/', import.meta.url)), + allowedPackages: new Set(), + allowedNames: {}, +}; + +describe('the graph core', () => { + it('has sources to check', () => { + expect(sourcesUnder(RULES.dir).length).toBeGreaterThanOrEqual(3); + }); + + it('imports only its own modules and reads no clock, randomness, environment or network', () => { + expect(problemsUnder(RULES)).toEqual([]); + }); + + it.each([ + ['react', "import { useState } from 'react';"], + ['the flow library', "import { Position } from '@xyflow/react';"], + ['a module above the core', "import { x } from '../api/client';"], + ['a Node module', "import { readFileSync } from 'node:fs';"], + ])('rejects an import of %s', (_, line) => { + expect(problemsIn('x.ts', line, RULES)).not.toEqual([]); + }); +}); From 9bb6f6b88fc57ef80fb14eb41269416ffe43abcf Mon Sep 17 00:00:00 2001 From: Nihit <24500178+knihit@users.noreply.github.com> Date: Sun, 25 Oct 2026 04:56:05 +0000 Subject: [PATCH 03/41] Cover the canvas in the e2e tests, screenshots and docs Open one shared session in the Playwright global setup, because a launch code works once, and answer the page's session call with it. Add canvas tests, an axe check of the error state, and regenerate the Designer screenshot. --- workflow-canvas/README.md | 63 ++++++++-- .../e2e/capture/screenshots.spec.ts | 1 + workflow-canvas/e2e/global-setup.ts | 7 ++ .../e2e/screenshots/designer-tab.png | Bin 14772 -> 41263 bytes workflow-canvas/e2e/support/fixtures.ts | 16 ++- workflow-canvas/e2e/support/session.ts | 39 ++++++ .../e2e/support/workflow-canvas-page.ts | 48 +++++++- .../e2e/tests/accessibility.spec.ts | 13 +- workflow-canvas/e2e/tests/canvas.spec.ts | 114 ++++++++++++++++++ workflow-canvas/e2e/tests/stub-api.spec.ts | 43 ++++--- workflow-canvas/playwright.shared.ts | 2 + 11 files changed, 307 insertions(+), 39 deletions(-) create mode 100644 workflow-canvas/e2e/global-setup.ts create mode 100644 workflow-canvas/e2e/support/session.ts create mode 100644 workflow-canvas/e2e/tests/canvas.spec.ts diff --git a/workflow-canvas/README.md b/workflow-canvas/README.md index 84181fecb..5cb0a3e6e 100644 --- a/workflow-canvas/README.md +++ b/workflow-canvas/README.md @@ -4,13 +4,18 @@ A browser UI for designing and running Konductor workflows. This folder is an independent npm project (Vite, React, TypeScript, Cloudscape) and is not part of the agent package or the CLI. -The page is a shell with Designer and Execution tabs. It does not call the -API; the contract, the stub that serves it and the tests around both are in -this folder. +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. ## Layout -- `src/`: the UI. +- `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. - `api/`: the HTTP API contract (`openapi.yaml`) and its test. - `stub-api/`: a TypeScript stub server for the contract, for local development and tests. @@ -131,8 +136,11 @@ origin. The stub sends no CORS headers and accepts only its own `Origin`, so the proxy swaps the page's `Origin` (`http://127.0.0.1:5173` or `http://localhost:5173`) for the stub's. Any other `Origin`, or none, goes through unchanged and the stub refuses a POST or PUT with a 403, so a page on -another origin cannot use the dev server to call the API. The page does not -call the API, so try it with `curl`, and send the `Origin` of the page: +another origin cannot use the dev server to call the API. The page reads the +launch code from the address (`#`), removes it from the address +bar, trades it for a token with `POST /api/session` and loads the workflow with +`GET /api/workflow`. The token is kept in memory only. To try the calls by +hand, send the `Origin` of the page: ```bash curl -s -X POST http://127.0.0.1:5173/api/session \ @@ -171,10 +179,10 @@ The proxy sends the `Origin` of the target in place of the page's own, and forwards any other `Origin` unchanged. A real server must pass the same Origin check as the stub (refuse a POST or PUT from any other `Origin`), and the same `POST /api/session` step from `api/openapi.yaml`, where the launch code is -traded for the token that the other calls send. The e2e and contract tests then show whether the real server -answers as the stub does. +traded for the token that the other calls send. The e2e and contract tests then +show whether the real server answers as the stub does. -### Code +### Code of the stub The code is in two parts, so the rules can be tested without a server: @@ -196,6 +204,33 @@ launch code and the server list), with `--with-dev-server` so that a test can open the page again with the same code. So an end-to-end test can call it. With `WC_API_TARGET` set it does not, and the same tests call that server instead. +## The canvas + +`src/graph/` is the core of the canvas. `buildGraph(workflow, harness)` turns +the workflow view into nodes (the intent, one per stage, the end) and edges +(one from each node to the next, from its `S` out port at the bottom right to +the `S` in port at the top right of the next), placed in one column. It imports +only its own modules. A test (`test-support/graph-purity.test.ts`) fails if it +imports anything else, or reads a clock, randomness, the environment or the +network, and the stub core is held to the same rule by the same scan +(`test-support/purity.ts`). + +The rest wraps the core, so the rules can be tested without a browser: + +- `api/`: `WorkflowApi` is the port with the two calls the page makes; + `createHttpApi` is its adapter over `fetch`. It ignores fields it does not + know and turns a failed call into an `ApiError` with a message fit to show. +- `launch/launch-code.ts` and `workflow/load-workflow.ts` take the launch code + from the address and load the workflow. `loadWorkflow` never rejects: it + returns `{ ok: true, workflow }` or `{ ok: false, message }`. It is started + once, before React renders, because a launch code works once and strict mode + runs effects twice. +- `designer/` shows the result: loading, error, empty (no file yet, or a file + with errors, with their messages), or the canvas. +- `canvas/` draws the graph with [React Flow](https://reactflow.dev) + (`@xyflow/react`). Nodes cannot be moved, selected or connected; the view + can be panned, and zoomed with the controls. + ## End-to-end tests The tests in `e2e/tests/` drive the production build in headless Chromium. @@ -203,6 +238,12 @@ The tests in `e2e/tests/` drive the production build in headless Chromium. http://127.0.0.1:4173, so no server needs to be running first. Its `webServer` list is where a backing service starts when the app needs one. +A launch code works once, so `e2e/global-setup.ts` trades the code of the +config for a session once per run, and the `canvas` fixture answers the page's +own `POST /api/session` with that session. Every other call, such as +`GET /api/workflow`, reaches the API. A test that needs the error state opens +the page with a wrong code or none. + Port 4173 must be free. The config never reuses a server, so a `vite preview` you left running there makes the run fail at startup; stop it first. @@ -215,7 +256,7 @@ 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. +axe-core on each tab and on the error state. Shared page objects and helpers live in `e2e/support/`. New tests should extend them rather than repeat selectors: import `test` and `expect` from @@ -225,7 +266,7 @@ the app. ## Screenshots `npm run screenshots` saves a full-page PNG of each tab to -`e2e/screenshots/`, in the light theme at 1280x800 with animations frozen. +`e2e/screenshots/`, with the Designer tab drawn from the stub, in the light theme at 1280x800 with animations frozen. Pull request descriptions embed these files, so regenerate and commit them when the UI changes. diff --git a/workflow-canvas/e2e/capture/screenshots.spec.ts b/workflow-canvas/e2e/capture/screenshots.spec.ts index bac8514b5..29115b390 100644 --- a/workflow-canvas/e2e/capture/screenshots.spec.ts +++ b/workflow-canvas/e2e/capture/screenshots.spec.ts @@ -8,6 +8,7 @@ import { TAB_NAMES } from '../support/workflow-canvas-page'; for (const name of TAB_NAMES) { test(`capture the ${name} tab`, async ({ canvas }) => { await canvas.selectTab(name); + await canvas.expectTabLoaded(name); await saveFullPageScreenshot(canvas.page, `${name.toLowerCase()}-tab`); }); diff --git a/workflow-canvas/e2e/global-setup.ts b/workflow-canvas/e2e/global-setup.ts new file mode 100644 index 000000000..363a14da3 --- /dev/null +++ b/workflow-canvas/e2e/global-setup.ts @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: Apache-2.0 +import { openSharedSession } from './support/session'; + +/** Opens the one session that the tests share; see `openSharedSession`. */ +export default async function globalSetup(): Promise { + await openSharedSession(); +} diff --git a/workflow-canvas/e2e/screenshots/designer-tab.png b/workflow-canvas/e2e/screenshots/designer-tab.png index ddb4687946425b5fe30f27dee69213e4396f0a07..89cf3922e9526f35642f9cf5a66348625110c459 100644 GIT binary patch literal 41263 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 literal 14772 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 a$o$11@cZk&ZNMiQ=N(;tt^DQ6?f(T!|6Nl6 diff --git a/workflow-canvas/e2e/support/fixtures.ts b/workflow-canvas/e2e/support/fixtures.ts index ab9976781..3366cf2b8 100644 --- a/workflow-canvas/e2e/support/fixtures.ts +++ b/workflow-canvas/e2e/support/fixtures.ts @@ -1,14 +1,26 @@ // SPDX-License-Identifier: Apache-2.0 import { test as base } from '@playwright/test'; +import { STUB_LAUNCH_CODE } from '../../playwright.shared'; +import { sharedSession } from './session'; import { WorkflowCanvasPage } from './workflow-canvas-page'; export { expect } from '@playwright/test'; -/** `test` extended with a `canvas` page object that has already opened the app. */ +/** + * `test` extended with a `canvas` page object that has already opened the + * app with the launch code of the config. + * + * A launch code works once, and the global setup has used it. So the page's + * own `POST /api/session` is answered with that session; every other call + * goes to the API for real. + */ export const test = base.extend<{ canvas: WorkflowCanvasPage }>({ canvas: async ({ page }, use) => { + await page.route('**/api/session', (route) => + route.fulfill({ json: sharedSession() }), + ); const canvas = new WorkflowCanvasPage(page); - await canvas.open(); + await canvas.open(STUB_LAUNCH_CODE); await use(canvas); }, }); diff --git a/workflow-canvas/e2e/support/session.ts b/workflow-canvas/e2e/support/session.ts new file mode 100644 index 000000000..9b9c1c631 --- /dev/null +++ b/workflow-canvas/e2e/support/session.ts @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: Apache-2.0 +import { API_URL, STUB_LAUNCH_CODE } from '../../playwright.shared'; + +const SESSION_ENV = 'WC_E2E_SESSION'; + +/** The reply of `POST /api/session`. */ +export interface SessionReply { + token: string; + root: string; + harness: string; + api_version: number; +} + +/** + * Trades the launch code of the config for a session. The code works once, so + * this runs once per run, in the global setup, and every test shares the + * result through `sharedSession`. + */ +export async function openSharedSession(): Promise { + const reply = await fetch(`${API_URL}/api/session`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: API_URL }, + body: JSON.stringify({ code: STUB_LAUNCH_CODE }), + }); + if (!reply.ok) { + throw new Error( + `The API refused the launch code of the config (${reply.status}). ` + + 'A launch code works once and for 60 seconds: restart the server.', + ); + } + process.env[SESSION_ENV] = JSON.stringify(await reply.json()); +} + +/** The session that the global setup opened. */ +export function sharedSession(): SessionReply { + const stored = process.env[SESSION_ENV]; + if (!stored) throw new Error('The global setup did not open a session.'); + return JSON.parse(stored) as SessionReply; +} diff --git a/workflow-canvas/e2e/support/workflow-canvas-page.ts b/workflow-canvas/e2e/support/workflow-canvas-page.ts index 99163ef9c..cf6c71321 100644 --- a/workflow-canvas/e2e/support/workflow-canvas-page.ts +++ b/workflow-canvas/e2e/support/workflow-canvas-page.ts @@ -6,22 +6,39 @@ export const TAB_NAMES = ['Designer', 'Execution'] as const; /** The label of one tab. */ export type TabName = (typeof TAB_NAMES)[number]; -/** Text each tab shows until it has real content. */ +/** Text the Execution tab shows until it has real content. */ export const PLACEHOLDER_TEXT = 'Nothing here yet.'; -/** Page object for the workflow canvas shell: title, landmark and tabs. */ +/** The nodes of the workflow canvas, top to bottom, by test id. */ +export const NODE_IDS = [ + 'node-intent', + 'node-stage-draft', + 'node-stage-review', + 'node-end', +] as const; +/** The test id of one node. */ +export type NodeId = (typeof NODE_IDS)[number]; + +/** Page object for the workflow canvas: title, landmark, tabs and the graph. */ export class WorkflowCanvasPage { readonly main: Locator; readonly title: Locator; + readonly graph: Locator; + readonly edges: Locator; constructor(readonly page: Page) { this.main = page.getByRole('main'); this.title = this.main.getByRole('heading', { level: 1 }); + this.graph = page.getByRole('region', { name: 'Workflow graph' }); + this.edges = this.graph.locator('.react-flow__edge'); } - /** Loads the app and waits for the title so tests start on a real page. */ - async open(): Promise { - await this.page.goto('/'); + /** + * Loads the app, with the launch code in the address as the server prints + * it when `launchCode` is given, and waits for the title. + */ + async open(launchCode?: string): Promise { + await this.page.goto(launchCode ? `/#${launchCode}` : '/'); await this.title.waitFor(); } @@ -33,6 +50,10 @@ export class WorkflowCanvasPage { return this.page.getByRole('tabpanel', { name }); } + node(id: NodeId): Locator { + return this.graph.getByTestId(id); + } + async selectTab(name: TabName): Promise { await this.tab(name).click(); await this.tabPanel(name).waitFor(); @@ -46,6 +67,21 @@ export class WorkflowCanvasPage { String(tabName === name), ); } - await expect(this.tabPanel(name)).toContainText(PLACEHOLDER_TEXT); + 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. */ + async expectTabLoaded(name: TabName): Promise { + await this.expectTabShown(name); + if (name === 'Designer') await this.expectWorkflowDrawn(); + } + + /** Asserts the whole graph is on screen: every node and every edge. */ + async expectWorkflowDrawn(): Promise { + for (const id of NODE_IDS) await expect(this.node(id)).toBeVisible(); + await expect(this.edges).toHaveCount(NODE_IDS.length - 1); } } diff --git a/workflow-canvas/e2e/tests/accessibility.spec.ts b/workflow-canvas/e2e/tests/accessibility.spec.ts index bd487e93e..da8f15dc5 100644 --- a/workflow-canvas/e2e/tests/accessibility.spec.ts +++ b/workflow-canvas/e2e/tests/accessibility.spec.ts @@ -1,12 +1,23 @@ // SPDX-License-Identifier: Apache-2.0 import { expectNoAccessibilityViolations } from '../support/accessibility'; import { test } from '../support/fixtures'; -import { TAB_NAMES } from '../support/workflow-canvas-page'; +import { TAB_NAMES, WorkflowCanvasPage } from '../support/workflow-canvas-page'; for (const name of TAB_NAMES) { test(`${name} tab has no axe violations`, async ({ canvas }) => { await canvas.selectTab(name); + await canvas.expectTabLoaded(name); await expectNoAccessibilityViolations(canvas.page); }); } + +test('Designer tab has no axe violations when the workflow did not load', async ({ + page, +}) => { + const canvas = new WorkflowCanvasPage(page); + await canvas.open('not-the-launch-code'); + await page.getByText('The workflow did not load').waitFor(); + + await expectNoAccessibilityViolations(page); +}); diff --git a/workflow-canvas/e2e/tests/canvas.spec.ts b/workflow-canvas/e2e/tests/canvas.spec.ts new file mode 100644 index 000000000..91c329fe0 --- /dev/null +++ b/workflow-canvas/e2e/tests/canvas.spec.ts @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: Apache-2.0 +import type { Locator } from '@playwright/test'; +import { expect, test } from '../support/fixtures'; +import { WorkflowCanvasPage } from '../support/workflow-canvas-page'; + +/** Where `to` is, relative to `from`, in whole pixels. */ +async function offsetBetween(from: Locator, to: Locator) { + const [a, b] = [await from.boundingBox(), await to.boundingBox()]; + return { + x: Math.round((b?.x ?? 0) - (a?.x ?? 0)), + y: Math.round((b?.y ?? 0) - (a?.y ?? 0)), + }; +} + +test('draws the workflow of the API on the Designer tab', async ({ + canvas, +}) => { + await canvas.expectWorkflowDrawn(); + await expect( + canvas.page.getByRole('heading', { name: 'Workflow: hello' }), + ).toBeVisible(); +}); + +test('shows the intent, then each stage with its agent and harness, then the end', async ({ + canvas, +}) => { + await expect(canvas.node('node-intent')).toContainText( + 'Add a greeting to the README', + ); + await expect(canvas.node('node-stage-draft')).toContainText('draft'); + await expect(canvas.node('node-stage-draft')).toContainText( + 'k-developer · kiro-cli', + ); + await expect(canvas.node('node-stage-review')).toContainText('review'); + await expect(canvas.node('node-stage-review')).toContainText( + 'k-quality-assurance · kiro-cli', + ); + await expect(canvas.node('node-end')).toContainText('end'); +}); + +test('stacks the nodes in the order of the stages', async ({ canvas }) => { + const tops: number[] = []; + for (const id of [ + 'node-intent', + 'node-stage-draft', + 'node-stage-review', + 'node-end', + ] as const) { + const box = await canvas.node(id).boundingBox(); + tops.push(box?.y ?? Number.NaN); + } + + expect(tops).toEqual([...tops].sort((a, b) => a - b)); + expect(new Set(tops).size).toBe(tops.length); +}); + +test('removes the launch code from the address', async ({ canvas }) => { + await canvas.expectWorkflowDrawn(); + + expect(new URL(canvas.page.url()).hash).toBe(''); +}); + +test('keeps the graph when the tabs are switched', async ({ canvas }) => { + await canvas.expectWorkflowDrawn(); + + await canvas.selectTab('Execution'); + await canvas.selectTab('Designer'); + + await canvas.expectWorkflowDrawn(); +}); + +test('does not let a node be dragged', async ({ canvas }) => { + await canvas.expectWorkflowDrawn(); + const draft = canvas.node('node-stage-draft'); + const gapBefore = await offsetBetween(draft, canvas.node('node-end')); + const { + x = 0, + y = 0, + width = 0, + height = 0, + } = (await draft.boundingBox()) ?? {}; + const { mouse } = canvas.page; + + await mouse.move(x + width / 2, y + height / 2); + await mouse.down(); + await mouse.move(x + width / 2 + 80, y + height / 2 + 40, { steps: 5 }); + await mouse.up(); + + // Dragging the pane pans every node alike; dragging a node would not. + expect(await offsetBetween(draft, canvas.node('node-end'))).toEqual( + gapBefore, + ); +}); + +test('shows an error when the launch code is wrong', async ({ page }) => { + const canvas = new WorkflowCanvasPage(page); + + await canvas.open('not-the-launch-code'); + + await expect(page.getByText('The workflow did not load')).toBeVisible(); + await expect(page.getByText('The launch code is not valid.')).toBeVisible(); + await expect(canvas.graph).toHaveCount(0); +}); + +test('explains what to do when the address has no launch code', async ({ + page, +}) => { + const canvas = new WorkflowCanvasPage(page); + + await canvas.open(); + + await expect(page.getByText('The workflow did not load')).toBeVisible(); + await expect(page.getByText(/no launch code in the address/)).toBeVisible(); +}); diff --git a/workflow-canvas/e2e/tests/stub-api.spec.ts b/workflow-canvas/e2e/tests/stub-api.spec.ts index a87fc3e70..df58759b0 100644 --- a/workflow-canvas/e2e/tests/stub-api.spec.ts +++ b/workflow-canvas/e2e/tests/stub-api.spec.ts @@ -1,18 +1,13 @@ // SPDX-License-Identifier: Apache-2.0 import { expect, test } from '@playwright/test'; import { API_TARGET, API_URL, STUB_LAUNCH_CODE } from '../../playwright.shared'; +import { sharedSession } from '../support/session'; // Shows that a test can reach the API: the stub that the Playwright config -// starts, or the server named by `WC_API_TARGET`. No page calls it. -test('the API gives a session for the launch code of the config', async ({ - request, -}) => { - const reply = await request.post(`${API_URL}/api/session`, { - data: { code: STUB_LAUNCH_CODE }, - headers: { Origin: API_URL }, - }); - expect(reply.status()).toBe(200); - expect(await reply.json()).toMatchObject({ +// starts, or the server named by `WC_API_TARGET`. The launch code works once, +// so the global setup traded it for the session that these tests read. +test('the API gave a session for the launch code of the config', () => { + expect(sharedSession()).toMatchObject({ root: 'hello', harness: 'kiro-cli', api_version: 1, @@ -27,16 +22,26 @@ if (API_TARGET.isStub) { test('the stub takes the launch code again and gives the same session', async ({ request, }) => { - const trade = async () => - request.post(`${API_URL}/api/session`, { - data: { code: STUB_LAUNCH_CODE }, - headers: { Origin: API_URL }, - }); - - const first = await trade(); - const again = await trade(); + const again = await request.post(`${API_URL}/api/session`, { + data: { code: STUB_LAUNCH_CODE }, + headers: { Origin: API_URL }, + }); expect(again.status()).toBe(200); - expect(await again.json()).toEqual(await first.json()); + expect(await again.json()).toEqual(sharedSession()); }); } + +test('the token of that session loads the root workflow', async ({ + request, +}) => { + const reply = await request.get(`${API_URL}/api/workflow`, { + headers: { Authorization: `Bearer ${sharedSession().token}` }, + }); + + expect(reply.status()).toBe(200); + expect(await reply.json()).toMatchObject({ + name: 'hello', + view: { stages: [{ name: 'draft' }, { name: 'review' }] }, + }); +}); diff --git a/workflow-canvas/playwright.shared.ts b/workflow-canvas/playwright.shared.ts index c588b2691..b2d2fc281 100644 --- a/workflow-canvas/playwright.shared.ts +++ b/workflow-canvas/playwright.shared.ts @@ -42,6 +42,8 @@ export const stableRendering: NonNullable = { /** Settings shared by the functional run and the screenshot capture. */ export const sharedConfig: PlaywrightTestConfig = { outputDir: 'test-results', + // Opens the session that the tests share, once the servers are up. + globalSetup: './e2e/global-setup.ts', forbidOnly: Boolean(process.env.CI), // A flaky test must fail the run, not pass on a retry. retries: 0, From 0f507bdfb2903a51a3aecaa6477332d6c66a0077 Mon Sep 17 00:00:00 2001 From: Nihit <24500178+knihit@users.noreply.github.com> Date: Sun, 25 Oct 2026 05:04:17 +0000 Subject: [PATCH 04/41] Name canvas nodes for screen readers and tighten the API reading Give each node and edge a readable aria-label built in the graph core, and replace the edit instructions of React Flow with read-only wording. Zoom out far enough to fit the 40 stages the API allows. Leave out a diagnostic line that is not a whole number from 1, refuse an API version other than 1, and keep the canvas colours in canvas.css only. Read the real replies of the stub with the page's parsers in a test. --- workflow-canvas/README.md | 12 ++- .../e2e/support/workflow-canvas-page.ts | 28 ++++++ workflow-canvas/e2e/tests/canvas.spec.ts | 33 ++++++- workflow-canvas/src/api/api.ts | 15 ++-- workflow-canvas/src/api/http-api.test.ts | 18 +++- workflow-canvas/src/api/parse.ts | 11 ++- workflow-canvas/src/api/types.ts | 4 +- workflow-canvas/src/canvas/WorkflowCanvas.tsx | 30 ++++--- workflow-canvas/src/canvas/aria-labels.ts | 16 ++++ workflow-canvas/src/canvas/canvas.css | 10 ++- workflow-canvas/src/canvas/nodes.tsx | 4 +- workflow-canvas/src/canvas/view.test.ts | 26 ++++++ workflow-canvas/src/canvas/view.ts | 11 +++ .../src/designer/Designer.test.tsx | 30 +++++++ workflow-canvas/src/designer/Designer.tsx | 38 ++++---- workflow-canvas/src/designer/states.tsx | 6 +- workflow-canvas/src/graph/build-graph.test.ts | 21 +++++ workflow-canvas/src/graph/build-graph.ts | 40 +++++++-- workflow-canvas/src/graph/model.ts | 4 + .../src/workflow/load-workflow.test.ts | 17 ++++ workflow-canvas/src/workflow/load-workflow.ts | 9 ++ workflow-canvas/stub-api/http/ui-wire.test.ts | 88 +++++++++++++++++++ 22 files changed, 414 insertions(+), 57 deletions(-) create mode 100644 workflow-canvas/src/canvas/aria-labels.ts create mode 100644 workflow-canvas/src/canvas/view.test.ts create mode 100644 workflow-canvas/src/canvas/view.ts create mode 100644 workflow-canvas/stub-api/http/ui-wire.test.ts diff --git a/workflow-canvas/README.md b/workflow-canvas/README.md index 5cb0a3e6e..dd9e4ce75 100644 --- a/workflow-canvas/README.md +++ b/workflow-canvas/README.md @@ -229,7 +229,11 @@ The rest wraps the core, so the rules can be tested without a browser: with errors, with their messages), or the canvas. - `canvas/` draws the graph with [React Flow](https://reactflow.dev) (`@xyflow/react`). Nodes cannot be moved, selected or connected; the view - can be panned, and zoomed with the controls. + can be panned, and zoomed with the controls. Each node can be reached with + the Tab key and has a name for screen readers. The view starts zoomed out + far enough to show the longest workflow the API allows (40 stages), where the + text is small; zoom in with the controls, or Tab to a node to bring it into + view. ## End-to-end tests @@ -266,9 +270,9 @@ 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, in the light theme at 1280x800 with animations frozen. -Pull request descriptions embed these files, so regenerate and commit them -when the UI changes. +`e2e/screenshots/`, with the Designer tab drawn from the stub, in the light +theme at 1280x800 with animations frozen. 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/support/workflow-canvas-page.ts b/workflow-canvas/e2e/support/workflow-canvas-page.ts index cf6c71321..7dbdeeb6d 100644 --- a/workflow-canvas/e2e/support/workflow-canvas-page.ts +++ b/workflow-canvas/e2e/support/workflow-canvas-page.ts @@ -19,6 +19,17 @@ export const NODE_IDS = [ /** The test id of one node. */ export type NodeId = (typeof NODE_IDS)[number]; +/** What a screen reader says for each node, top to bottom. */ +export const NODE_NAMES = [ + 'Intent: Add a greeting to the README', + 'Stage draft, agent k-developer, harness kiro-cli', + 'Stage review, agent k-quality-assurance, harness kiro-cli', + 'End', +] as const; + +/** The most Tab presses between one node and the next one to take focus. */ +const MAX_TABS_TO_NEXT_NODE = 10; + /** Page object for the workflow canvas: title, landmark, tabs and the graph. */ export class WorkflowCanvasPage { readonly main: Locator; @@ -54,6 +65,23 @@ export class WorkflowCanvasPage { return this.graph.getByTestId(id); } + /** The node that a screen reader names `name`. */ + nodeNamed(name: string): Locator { + return this.graph.getByRole('group', { name, exact: true }); + } + + /** Presses Tab until a node of the graph has focus. */ + async tabToNextNode(): Promise { + for (let press = 0; press < MAX_TABS_TO_NEXT_NODE; press += 1) { + await this.page.keyboard.press('Tab'); + const onNode = await this.page.evaluate(() => + document.activeElement?.classList.contains('react-flow__node'), + ); + if (onNode) return; + } + throw new Error('Tab did not reach a node of the graph.'); + } + async selectTab(name: TabName): Promise { await this.tab(name).click(); await this.tabPanel(name).waitFor(); diff --git a/workflow-canvas/e2e/tests/canvas.spec.ts b/workflow-canvas/e2e/tests/canvas.spec.ts index 91c329fe0..28235908b 100644 --- a/workflow-canvas/e2e/tests/canvas.spec.ts +++ b/workflow-canvas/e2e/tests/canvas.spec.ts @@ -1,7 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 import type { Locator } from '@playwright/test'; import { expect, test } from '../support/fixtures'; -import { WorkflowCanvasPage } from '../support/workflow-canvas-page'; +import { + NODE_NAMES, + WorkflowCanvasPage, +} from '../support/workflow-canvas-page'; /** Where `to` is, relative to `from`, in whole pixels. */ async function offsetBetween(from: Locator, to: Locator) { @@ -92,6 +95,34 @@ test('does not let a node be dragged', async ({ canvas }) => { ); }); +test('reaches each node with the Tab key and names it', async ({ canvas }) => { + await canvas.expectWorkflowDrawn(); + await canvas.tab('Designer').focus(); + + for (const name of NODE_NAMES) { + await canvas.tabToNextNode(); + const node = canvas.nodeNamed(name); + + await expect(node).toBeFocused(); + await expect(node).toHaveAccessibleName(name); + await expect(node).toHaveAccessibleDescription(/^Read-only workflow graph/); + await expect(node).toHaveCSS('outline-style', 'solid'); + await expect(node).toHaveCSS('outline-width', '3px'); + } +}); + +test('names the edges by the nodes they join, not by ids', async ({ + canvas, +}) => { + await canvas.expectWorkflowDrawn(); + + await expect(canvas.edges.first()).toHaveAttribute( + 'aria-label', + 'Run order from intent to stage draft', + ); + await expect(canvas.graph.locator('[aria-label*="stage-"]')).toHaveCount(0); +}); + test('shows an error when the launch code is wrong', async ({ page }) => { const canvas = new WorkflowCanvasPage(page); diff --git a/workflow-canvas/src/api/api.ts b/workflow-canvas/src/api/api.ts index 448ab190a..740cddfd8 100644 --- a/workflow-canvas/src/api/api.ts +++ b/workflow-canvas/src/api/api.ts @@ -14,14 +14,15 @@ export interface WorkflowApi { /** A call that failed, with a message that is fit to show to a person. */ export class ApiError extends Error { - constructor( - message: string, - /** The HTTP status, or 0 when the server did not answer. */ - readonly status: number, - /** The `code` of the error body, when there was one. */ - readonly code?: string, - ) { + /** The HTTP status, or 0 when the server did not answer. */ + readonly status: number; + /** The `code` of the error body, when there was one. */ + readonly code?: string; + + constructor(message: string, status: number, code?: string) { super(message); this.name = 'ApiError'; + this.status = status; + this.code = code; } } diff --git a/workflow-canvas/src/api/http-api.test.ts b/workflow-canvas/src/api/http-api.test.ts index 4bd2760a5..a931526d9 100644 --- a/workflow-canvas/src/api/http-api.test.ts +++ b/workflow-canvas/src/api/http-api.test.ts @@ -111,10 +111,26 @@ describe('createHttpApi', () => { expect((await api.getWorkflow('t')).diagnostics).toEqual([ { message: 'Missing key', line: 3 }, - { message: 'Wrong type', line: 0 }, + { message: 'Wrong type', line: undefined }, ]); }); + it.each([0, -1, 2.5, '3', null])( + 'leaves out a line that is not a whole number from 1: %j', + async (line) => { + const body = { + ...DOCUMENT_BODY, + view: null, + diagnostics: [{ code: 'X', message: 'Odd line', line }], + }; + const { api } = apiFor(reply(body)); + + expect((await api.getWorkflow('t')).diagnostics).toEqual([ + { message: 'Odd line', line: undefined }, + ]); + }, + ); + it('fails with the sentence and code of an error body', async () => { const { api } = apiFor( reply( diff --git a/workflow-canvas/src/api/parse.ts b/workflow-canvas/src/api/parse.ts index a3df2d405..c061c2c05 100644 --- a/workflow-canvas/src/api/parse.ts +++ b/workflow-canvas/src/api/parse.ts @@ -47,12 +47,19 @@ function parseView(value: unknown): WorkflowView | null { }; } +// The contract requires a line of 1 or more; anything else is left out +// rather than shown as a line that does not exist. +function lineOf(value: unknown): number | undefined { + return typeof value === 'number' && Number.isInteger(value) && value >= 1 + ? value + : undefined; +} + function parseDiagnostic(value: unknown): DiagnosticView { const diagnostic = objectOf(value); - const line = diagnostic.line; return { message: stringOf(diagnostic.message), - line: typeof line === 'number' ? line : 0, + line: lineOf(diagnostic.line), }; } diff --git a/workflow-canvas/src/api/types.ts b/workflow-canvas/src/api/types.ts index 0b08c307f..368e5ff28 100644 --- a/workflow-canvas/src/api/types.ts +++ b/workflow-canvas/src/api/types.ts @@ -6,6 +6,7 @@ export interface Session { token: string; root: string; harness: string; + /** The major version of the API. */ apiVersion: number; } @@ -25,7 +26,8 @@ export interface WorkflowView { /** A problem the server found in the workflow text. */ export interface DiagnosticView { message: string; - line: number; + /** The line in the text, counted from 1; `undefined` when the reply has none. */ + line: number | undefined; } /** What `GET /api/workflow` returns. */ diff --git a/workflow-canvas/src/canvas/WorkflowCanvas.tsx b/workflow-canvas/src/canvas/WorkflowCanvas.tsx index f33dd8116..a2753a736 100644 --- a/workflow-canvas/src/canvas/WorkflowCanvas.tsx +++ b/workflow-canvas/src/canvas/WorkflowCanvas.tsx @@ -11,7 +11,9 @@ import { import '@xyflow/react/dist/style.css'; import type { CanvasGraph } from '../graph'; import './canvas.css'; +import { READ_ONLY_ARIA_LABELS } from './aria-labels'; import { EndNode, IntentNode, StageNode } from './nodes'; +import { CANVAS_HEIGHT, FIT_PADDING, MIN_ZOOM } from './view'; const NODE_TYPES: NodeTypes = { intent: IntentNode, @@ -19,28 +21,30 @@ const NODE_TYPES: NodeTypes = { end: EndNode, }; -const EDGE_COLOR = '#5f6b7a'; - const EDGE_OPTIONS: DefaultEdgeOptions = { type: 'straight', - style: { stroke: EDGE_COLOR, strokeWidth: 2.5 }, + style: { stroke: 'var(--wf-edge)', strokeWidth: 2.5 }, markerEnd: { type: MarkerType.ArrowClosed, - color: EDGE_COLOR, + color: 'var(--wf-edge)', width: 16, height: 16, }, }; -const DOT_COLOR = '#c9c1b3'; - /** * Draws a workflow graph, read-only. The view can be panned and zoomed, but - * nodes cannot be moved, selected, connected or edited. + * nodes cannot be moved, selected, connected or edited. Each node can still + * be reached with the Tab key, which also brings it into view. */ export function WorkflowCanvas({ graph }: { graph: CanvasGraph }) { return ( -
+
- +
diff --git a/workflow-canvas/src/canvas/aria-labels.ts b/workflow-canvas/src/canvas/aria-labels.ts new file mode 100644 index 000000000..f747901f1 --- /dev/null +++ b/workflow-canvas/src/canvas/aria-labels.ts @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Apache-2.0 +import type { AriaLabelConfig } from '@xyflow/react'; + +const READ_ONLY = + 'Read-only workflow graph. Nodes cannot be moved, selected or edited. Use the zoom controls to change the view.'; + +/** + * Replaces the instructions of React Flow, which describe an editable graph, + * with ones that are true on a read-only canvas. + */ +export const READ_ONLY_ARIA_LABELS: Partial = { + 'node.a11yDescription.default': READ_ONLY, + 'node.a11yDescription.keyboardDisabled': READ_ONLY, + 'node.a11yDescription.ariaLiveMessage': () => '', + 'edge.a11yDescription.default': READ_ONLY, +}; diff --git a/workflow-canvas/src/canvas/canvas.css b/workflow-canvas/src/canvas/canvas.css index bc0556ef5..b17664454 100644 --- a/workflow-canvas/src/canvas/canvas.css +++ b/workflow-canvas/src/canvas/canvas.css @@ -4,6 +4,8 @@ .wf-canvas { --wf-canvas-bg: #f5f3ee; --wf-canvas-dots: #c9c1b3; + --wf-edge: #5f6b7a; + --wf-focus: #0b57d0; --wf-node-bg: #ffffff; --wf-node-border: #7a7490; --wf-text: #16131f; @@ -12,11 +14,17 @@ --wf-port-text: #ffffff; --wf-port-ring: #16131f; - height: 560px; border-radius: 12px; background: var(--wf-canvas-bg); } +/* The ring that shows which node the keyboard is on. */ +.wf-canvas .react-flow__node:focus-visible { + border-radius: 12px; + outline: 3px solid var(--wf-focus); + outline-offset: 3px; +} + .wf-node { box-sizing: border-box; width: 100%; diff --git a/workflow-canvas/src/canvas/nodes.tsx b/workflow-canvas/src/canvas/nodes.tsx index e9bbefd3b..ad32c4610 100644 --- a/workflow-canvas/src/canvas/nodes.tsx +++ b/workflow-canvas/src/canvas/nodes.tsx @@ -12,7 +12,7 @@ type FlowNode }> = Node< N['type'] >; -/** The port where run order arrives, at the top right of a node. */ +// The port where run order arrives, at the top right of a node. function SequenceIn() { return ( { + it('can zoom out far enough to fit a workflow of the most stages', () => { + const stages = Array.from({ length: MAX_STAGES }, (_, index) => ({ + name: `stage-${index}`, + agent: 'k-developer', + })); + const { nodes } = buildGraph( + { name: 'long', intent: { outcome: 'Do it' }, stages }, + 'kiro-cli', + ); + const last = nodes[nodes.length - 1]; + const graphHeight = (last?.position.y ?? 0) + (last?.height ?? 0); + + const zoomToFit = CANVAS_HEIGHT / (graphHeight * (1 + 2 * FIT_PADDING)); + + expect(zoomToFit).toBeGreaterThanOrEqual(MIN_ZOOM); + }); +}); diff --git a/workflow-canvas/src/canvas/view.ts b/workflow-canvas/src/canvas/view.ts new file mode 100644 index 000000000..a267966d1 --- /dev/null +++ b/workflow-canvas/src/canvas/view.ts @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** The height of the canvas, in pixels. */ +export const CANVAS_HEIGHT = 560; +/** The share of the canvas left empty on each side when the graph is fitted. */ +export const FIT_PADDING = 0.12; +/** + * The smallest zoom. It is low enough to fit a workflow of the most stages + * that the API allows (40) in the canvas. + */ +export const MIN_ZOOM = 0.05; diff --git a/workflow-canvas/src/designer/Designer.test.tsx b/workflow-canvas/src/designer/Designer.test.tsx index 340e42c5d..11afcb69d 100644 --- a/workflow-canvas/src/designer/Designer.test.tsx +++ b/workflow-canvas/src/designer/Designer.test.tsx @@ -72,4 +72,34 @@ describe('Designer', () => { ).getByText('Line 2: Missing key: intent'), ).toBeTruthy(); }); + + it('shows a problem that has no line without a line number', () => { + const broken = { + ...HELLO_DOCUMENT, + view: null, + diagnostics: [{ message: 'The file is empty', line: undefined }], + }; + + render(); + + const list = screen.getByRole('list', { + name: 'Problems in the workflow file', + }); + expect(within(list).getByText('The file is empty')).toBeTruthy(); + expect(list.textContent).not.toContain('Line'); + }); + + it('gives each node a name for a screen reader', () => { + render(); + + const graph = screen.getByRole('region', { name: 'Workflow graph' }); + for (const name of [ + 'Intent: Add a greeting to the README', + 'Stage draft, agent k-developer, harness kiro-cli', + 'Stage review, agent k-quality-assurance, harness kiro-cli', + 'End', + ]) { + expect(within(graph).getByRole('group', { name })).toBeTruthy(); + } + }); }); diff --git a/workflow-canvas/src/designer/Designer.tsx b/workflow-canvas/src/designer/Designer.tsx index cf939af1a..09af1b1a2 100644 --- a/workflow-canvas/src/designer/Designer.tsx +++ b/workflow-canvas/src/designer/Designer.tsx @@ -4,24 +4,18 @@ 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 { EmptyState, ErrorState, LoadingState } from './states'; -function DrawnWorkflow({ workflow }: { workflow: LoadedWorkflow }) { - const { document, harness } = workflow; - const { view } = document; - const graph = useMemo( - () => (view ? buildGraph(view, harness) : undefined), - [view, harness], - ); - if (!view || !graph) { - return ( - - ); - } +function DrawnGraph({ + view, + harness, +}: { + view: WorkflowView; + harness: string; +}) { + const graph = useMemo(() => buildGraph(view, harness), [view, harness]); return ( + ); + } + return ; +} + /** * The Designer tab: the workflow drawn as a graph, or a message while it * loads (`result` is `undefined`), when it failed to load, or when there is diff --git a/workflow-canvas/src/designer/states.tsx b/workflow-canvas/src/designer/states.tsx index f40880d46..18fcee994 100644 --- a/workflow-canvas/src/designer/states.tsx +++ b/workflow-canvas/src/designer/states.tsx @@ -36,9 +36,9 @@ export function EmptyState({ {diagnostics.length > 0 && (
    - {diagnostics.map((diagnostic, index) => ( -
  • - Line {diagnostic.line}: {diagnostic.message} + {diagnostics.map(({ line, message }) => ( +
  • + {line === undefined ? message : `Line ${line}: ${message}`}
  • ))}
diff --git a/workflow-canvas/src/graph/build-graph.test.ts b/workflow-canvas/src/graph/build-graph.test.ts index 3ca215ca9..d53a8c983 100644 --- a/workflow-canvas/src/graph/build-graph.test.ts +++ b/workflow-canvas/src/graph/build-graph.test.ts @@ -50,6 +50,27 @@ describe('buildGraph', () => { ]); }); + it('names each node for a screen reader', () => { + const { nodes } = buildGraph(HELLO, 'kiro-cli'); + + expect(nodes.map((node) => node.ariaLabel)).toEqual([ + 'Intent: Add a greeting to the README', + 'Stage draft, agent k-developer, harness kiro-cli', + 'Stage review, agent k-quality-assurance, harness kiro-cli', + 'End', + ]); + }); + + it('names each edge by the nodes it joins, not by their ids', () => { + const { edges } = buildGraph(HELLO, 'kiro-cli'); + + expect(edges.map((edge) => edge.ariaLabel)).toEqual([ + 'Run order from intent to stage draft', + 'Run order from stage draft to stage review', + 'Run order from stage review to end', + ]); + }); + it('joins each node to the next with an edge, in list order', () => { const { edges } = buildGraph(HELLO, 'kiro-cli'); diff --git a/workflow-canvas/src/graph/build-graph.ts b/workflow-canvas/src/graph/build-graph.ts index 9d49b9852..19b901685 100644 --- a/workflow-canvas/src/graph/build-graph.ts +++ b/workflow-canvas/src/graph/build-graph.ts @@ -5,6 +5,7 @@ import { SEQUENCE_OUT, type CanvasGraph, type GraphEdge, + type GraphNode, type EndGraphNode, type IntentGraphNode, type StageGraphNode, @@ -24,6 +25,7 @@ export function stageNodeId(name: string): string { function intentNode(spec: WorkflowSpec): Unplaced { return { id: INTENT_ID, + ariaLabel: `Intent: ${spec.intent.outcome}`, type: 'intent', height: NODE_HEIGHT.intent, data: { outcome: spec.intent.outcome }, @@ -36,6 +38,7 @@ function stageNode( ): Unplaced { return { id: stageNodeId(stage.name), + ariaLabel: `Stage ${stage.name}, agent ${stage.agent}, harness ${harness}`, type: 'stage', height: NODE_HEIGHT.stage, data: { name: stage.name, agent: stage.agent, harness }, @@ -43,17 +46,36 @@ function stageNode( } function endNode(): Unplaced { - return { id: END_ID, type: 'end', height: NODE_HEIGHT.end, data: {} }; + return { + id: END_ID, + ariaLabel: 'End', + type: 'end', + height: NODE_HEIGHT.end, + data: {}, + }; +} + +/** A short name for a node, to say which nodes an edge joins. */ +function titleOf(node: GraphNode): string { + switch (node.type) { + case 'intent': + return 'intent'; + case 'stage': + return `stage ${node.data.name}`; + case 'end': + return 'end'; + } } -/** One edge from each node to the next. */ -function chain(ids: readonly string[]): GraphEdge[] { - return ids.slice(1).map((target, index) => { - const source = ids[index] ?? ''; +// One edge from each node to the next. +function chain(nodes: readonly GraphNode[]): GraphEdge[] { + return nodes.slice(1).map((target, index) => { + const source = nodes[index] ?? target; return { - id: `${source}->${target}`, - source, - target, + id: `${source.id}->${target.id}`, + ariaLabel: `Run order from ${titleOf(source)} to ${titleOf(target)}`, + source: source.id, + target: target.id, sourceHandle: SEQUENCE_OUT, targetHandle: SEQUENCE_IN, }; @@ -70,5 +92,5 @@ export function buildGraph(spec: WorkflowSpec, harness: string): CanvasGraph { ...spec.stages.map((stage) => stageNode(stage, harness)), endNode(), ]); - return { nodes, edges: chain(nodes.map((node) => node.id)) }; + return { nodes, edges: chain(nodes) }; } diff --git a/workflow-canvas/src/graph/model.ts b/workflow-canvas/src/graph/model.ts index 59497d03a..99546241a 100644 --- a/workflow-canvas/src/graph/model.ts +++ b/workflow-canvas/src/graph/model.ts @@ -9,6 +9,8 @@ export interface WorkflowSpec { interface Placed { id: string; + /** What a screen reader says for the node. */ + ariaLabel: string; position: { x: number; y: number }; width: number; height: number; @@ -44,6 +46,8 @@ export type UnplacedNode = /** Run order from the sequence-out port of one node to the next one's in. */ export interface GraphEdge { id: string; + /** What a screen reader says for the edge. */ + ariaLabel: string; source: string; target: string; sourceHandle: typeof SEQUENCE_OUT; diff --git a/workflow-canvas/src/workflow/load-workflow.test.ts b/workflow-canvas/src/workflow/load-workflow.test.ts index 8edefd35f..f11b8b23d 100644 --- a/workflow-canvas/src/workflow/load-workflow.test.ts +++ b/workflow-canvas/src/workflow/load-workflow.test.ts @@ -50,6 +50,23 @@ describe('loadWorkflow', () => { expect(api.getWorkflow).not.toHaveBeenCalled(); }); + it('fails without loading when the server speaks another API version', async () => { + const api = fakeApi({ + createSession: vi + .fn() + .mockResolvedValue({ ...HELLO_SESSION, apiVersion: 2 }), + }); + + const result = await loadWorkflow(api, 'code-1'); + + expect(result).toEqual({ + ok: false, + message: + 'The server speaks a version of the API that this page does not understand.', + }); + expect(api.getWorkflow).not.toHaveBeenCalled(); + }); + it('fails with the message of a refused load', async () => { const api = fakeApi({ getWorkflow: vi.fn().mockRejectedValue(new ApiError('Gone.', 404)), diff --git a/workflow-canvas/src/workflow/load-workflow.ts b/workflow-canvas/src/workflow/load-workflow.ts index 80bc25798..16a746fe1 100644 --- a/workflow-canvas/src/workflow/load-workflow.ts +++ b/workflow-canvas/src/workflow/load-workflow.ts @@ -15,6 +15,12 @@ export type LoadResult = const NO_LAUNCH_CODE = 'There is no launch code in the address. Open the launch address that the server printed; it ends in #.'; +/** The major version of the API that this page reads. */ +export const SUPPORTED_API_VERSION = 1; + +const UNSUPPORTED_API = + 'The server speaks a version of the API that this page does not understand.'; + function messageOf(error: unknown): string { return error instanceof Error ? error.message : 'The workflow did not load.'; } @@ -30,6 +36,9 @@ export async function loadWorkflow( if (launchCode === undefined) return { ok: false, message: NO_LAUNCH_CODE }; try { const session = await api.createSession(launchCode); + if (session.apiVersion !== SUPPORTED_API_VERSION) { + return { ok: false, message: UNSUPPORTED_API }; + } const document = await api.getWorkflow(session.token); return { ok: true, workflow: { harness: session.harness, document } }; } catch (error) { diff --git a/workflow-canvas/stub-api/http/ui-wire.test.ts b/workflow-canvas/stub-api/http/ui-wire.test.ts new file mode 100644 index 000000000..4700039df --- /dev/null +++ b/workflow-canvas/stub-api/http/ui-wire.test.ts @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: Apache-2.0 +/** Reads the real replies of the stub with the parsers of the page. */ +import { describe, expect, it } from 'vitest'; +import { parseSession, parseWorkflowDocument } from '../../src/api/parse.ts'; +import { LAUNCH_CODE, useStub } from '../test-support/harness.ts'; +import { oneStageWorkflow } from '../test-support/workflows.ts'; + +const PATH = '/api/workflow'; + +describe('the wire types of the page read POST /api/session', () => { + const stub = useStub({}, false); + + it('with the reply of the stub', async () => { + const reply = await stub().client.post('/api/session', { + code: LAUNCH_CODE, + }); + + expect(parseSession(reply.body)).toMatchObject({ + harness: 'kiro-cli', + apiVersion: 1, + }); + }); +}); + +describe('the wire types of the page read GET /api/workflow', () => { + const stub = useStub(); + + it('with the sample workflow', async () => { + const reply = await stub().client.get(PATH); + + expect(parseWorkflowDocument(reply.body)).toEqual({ + name: 'hello', + text: expect.stringContaining('name: hello'), + view: { + name: 'hello', + intent: { outcome: 'Add a greeting to the README' }, + stages: [ + { name: 'draft', agent: 'k-developer' }, + { name: 'review', agent: 'k-quality-assurance' }, + ], + }, + diagnostics: [], + }); + }); +}); + +describe('the wire types of the page read GET /api/workflow with errors', () => { + const stub = useStub({ files: { hello: 'name: hello\nowner: me\n' } }); + + it('with a null view and diagnostics that have a message and a line', async () => { + const reply = await stub().client.get(PATH); + const document = parseWorkflowDocument(reply.body); + + expect(document.view).toBeNull(); + expect(document.diagnostics.length).toBeGreaterThan(0); + for (const { message, line } of document.diagnostics) { + expect(message).not.toBe(''); + expect(line).toBeGreaterThanOrEqual(1); + } + }); +}); + +describe('the wire types of the page read GET /api/workflow without a file', () => { + const stub = useStub({ files: {} }); + + it('with null text and a null view', async () => { + const reply = await stub().client.get(PATH); + + expect(parseWorkflowDocument(reply.body)).toMatchObject({ + text: null, + view: null, + }); + }); +}); + +describe('the wire types of the page read GET /api/workflow with another workflow', () => { + const stub = useStub({ + files: { hello: oneStageWorkflow('solo', 'k-developer') }, + }); + + it('with the stages of that workflow', async () => { + const reply = await stub().client.get(PATH); + + expect(parseWorkflowDocument(reply.body).view?.stages).toEqual([ + { name: 'solo', agent: 'k-developer' }, + ]); + }); +}); From 810e413ba4dd38ad8e7a561182d826bd51b37cdf Mon Sep 17 00:00:00 2001 From: Nihit <24500178+knihit@users.noreply.github.com> Date: Sun, 25 Oct 2026 05:10:55 +0000 Subject: [PATCH 05/41] Render the port badge letter in the pinned page font The badge font shorthand named Arial, which each host resolves to a different face, so the Designer screenshot differed between a local run and CI. Inherit the page font so the capture is the same everywhere, and refresh the screenshot. --- .../e2e/screenshots/designer-tab.png | Bin 41263 -> 41157 bytes workflow-canvas/src/canvas/canvas.css | 9 ++++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/workflow-canvas/e2e/screenshots/designer-tab.png b/workflow-canvas/e2e/screenshots/designer-tab.png index 89cf3922e9526f35642f9cf5a66348625110c459..4de06aeb6198eea3f611c269f92f697de9bf2a26 100644 GIT binary patch literal 41157 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 z�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
n=ybDs`0<)FP2RZfU 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 literal 41263 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 diff --git a/workflow-canvas/src/canvas/canvas.css b/workflow-canvas/src/canvas/canvas.css index b17664454..9f1fa66c6 100644 --- a/workflow-canvas/src/canvas/canvas.css +++ b/workflow-canvas/src/canvas/canvas.css @@ -82,9 +82,12 @@ border-radius: 4px; background: var(--wf-port-bg); color: var(--wf-port-text); - font: - 700 10px/1 Arial, - sans-serif; + /* Inherit the page font. Naming a system family here (Arial) would resolve + to a different face per host and make the screenshots differ. */ + font-family: inherit; + font-size: 10px; + font-weight: 700; + line-height: 1; pointer-events: none; } From e19b5e4d4f4d919d0cd09b1163a5a3a2969d36f3 Mon Sep 17 00:00:00 2001 From: Nihit <24500178+knihit@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:48:06 +0000 Subject: [PATCH 06/41] Key each listed problem by its position as well as its text The server may repeat an identical diagnostic, which gave two list items the same key. Add the index to the key and test a repeated problem. --- .../src/designer/Designer.test.tsx | 23 ++++++++++++++++++- workflow-canvas/src/designer/states.tsx | 6 +++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/workflow-canvas/src/designer/Designer.test.tsx b/workflow-canvas/src/designer/Designer.test.tsx index 11afcb69d..c85078c8d 100644 --- a/workflow-canvas/src/designer/Designer.test.tsx +++ b/workflow-canvas/src/designer/Designer.test.tsx @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 import { render, screen, within } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { HELLO_DOCUMENT, failed, loaded } from '../test-support/fixtures'; import { Designer } from './Designer'; @@ -89,6 +89,27 @@ describe('Designer', () => { expect(list.textContent).not.toContain('Line'); }); + it('lists every problem when the server repeats an identical one', () => { + const repeated = { message: 'Unknown key: owner', line: 3 }; + const broken = { + ...HELLO_DOCUMENT, + view: null, + diagnostics: [repeated, repeated], + }; + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + + render(); + + const list = screen.getByRole('list', { + name: 'Problems in the workflow file', + }); + expect( + within(list).getAllByText('Line 3: Unknown key: owner'), + ).toHaveLength(2); + expect(logged).not.toHaveBeenCalled(); + logged.mockRestore(); + }); + it('gives each node a name for a screen reader', () => { render(); diff --git a/workflow-canvas/src/designer/states.tsx b/workflow-canvas/src/designer/states.tsx index 18fcee994..5f67b5525 100644 --- a/workflow-canvas/src/designer/states.tsx +++ b/workflow-canvas/src/designer/states.tsx @@ -36,8 +36,10 @@ export function EmptyState({ {diagnostics.length > 0 && (