diff --git a/.github/workflows/frontend-ci.yaml b/.github/workflows/frontend-ci.yaml index 7349e25a77..f14ec7c11a 100644 --- a/.github/workflows/frontend-ci.yaml +++ b/.github/workflows/frontend-ci.yaml @@ -7,6 +7,11 @@ on: paths: - "ui/**" - ".github/workflows/frontend-ci.yaml" + # The golden round-trip test of the UI reads these YAML corpora. + - "examples/**" + - "internal/**/*.y*ml" + - "conformance/**/*.y*ml" + - "specs/**" pull_request: # Keep this workflow directly rerunnable from PR-only maintenance commits. types: @@ -17,6 +22,11 @@ on: paths: - "ui/**" - ".github/workflows/frontend-ci.yaml" + # The golden round-trip test of the UI reads these YAML corpora. + - "examples/**" + - "internal/**/*.y*ml" + - "conformance/**/*.y*ml" + - "specs/**" workflow_dispatch: # Restrict to read-only: this workflow only runs tests and builds the frontend, @@ -25,6 +35,14 @@ on: permissions: contents: read +concurrency: + # A new commit to a pull request cancels the run of the commit it + # replaces, so superseded runs stop holding the org's runners. Runs for + # main and manual dispatches each get a group of their own and always + # finish. + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + jobs: build: name: Build diff --git a/ui/AGENTS.md b/ui/AGENTS.md index 5695f164e8..ffa9af76dd 100644 --- a/ui/AGENTS.md +++ b/ui/AGENTS.md @@ -300,12 +300,12 @@ This document outlines the design principles and guidelines for the Dagu UI base ### Color & Styling - **Consistent Metadata Styling**: Use uniform backgrounds for similar information - - Example: `bg-slate-200 dark:bg-slate-700` for metadata blocks + - Example: `bg-muted` for metadata blocks - **Subtle Borders**: Use `border` class consistently, avoid thick borders - **Text Hierarchy**: Use size and weight, not excessive color variation - - Primary text: `text-slate-800 dark:text-slate-200` - - Secondary text: `text-slate-600 dark:text-slate-400` - - Muted text: `text-slate-500 dark:text-slate-500` + - Primary text: `text-foreground` + - Secondary and muted text: `text-muted-foreground` +- **Theme Tokens**: Use semantic tokens, not palette classes such as `text-slate-*`; they follow dark mode without `dark:` variants ### Layout Principles - **Flexbox First**: Use flexbox for dynamic layouts that fill available space diff --git a/ui/e2e/auth-flows.spec.ts b/ui/e2e/auth-flows.spec.ts index 82814ef975..4ad538a624 100644 --- a/ui/e2e/auth-flows.spec.ts +++ b/ui/e2e/auth-flows.spec.ts @@ -42,7 +42,11 @@ test.describe('auth flows', () => { await loginViaUI(page, username, oldPassword); // Open user menu and start password change - await page.locator('aside').getByRole('button').filter({ hasText: username }).click(); + await page + .locator('aside') + .getByRole('button') + .filter({ hasText: username }) + .click(); await page.getByRole('menuitem', { name: 'Change Password' }).click(); const dialog = page.getByRole('dialog'); @@ -52,7 +56,9 @@ test.describe('auth flows', () => { await dialog.getByLabel('Confirm New Password').fill(newPassword); await dialog.getByRole('button', { name: 'Change Password' }).click(); - await expect(dialog.getByText('Password changed successfully')).toBeVisible(); + await expect( + dialog.getByText('Password changed successfully') + ).toBeVisible(); // Clear session and login with new password await clearSession(page); @@ -66,7 +72,10 @@ test.describe('auth flows', () => { expect(failResponse.status()).toBe(401); }); - test('manager can create DAGs but cannot manage users', async ({ page, request }) => { + test('manager can create DAGs but cannot manage users', async ({ + page, + request, + }) => { const stack = await loadStack(); const adminToken = await loginViaAPI( request, @@ -94,7 +103,9 @@ test.describe('auth flows', () => { await expect(dialog).toBeVisible(); await dialog.getByLabel('Workflow name').fill(dagName); await dialog.getByRole('button', { name: 'Create' }).click(); - await expect(page).toHaveURL(new RegExp(`/dags/${dagName}/spec$`)); + await expect + .poll(() => new URL(page.url()).pathname) + .toMatch(new RegExp(`^/dags/${dagName}(/spec)?$`)); await expect( page.getByRole('heading', { level: 1, name: dagName, exact: true }) ).toBeVisible(); @@ -104,7 +115,10 @@ test.describe('auth flows', () => { await expect(page).not.toHaveURL(/\/users$/); }); - test('operator cannot write DAGs but can execute them', async ({ page, request }) => { + test('operator cannot write DAGs but can execute them', async ({ + page, + request, + }) => { const stack = await loadStack(); const adminToken = await loginViaAPI( request, @@ -137,22 +151,32 @@ steps: // Operator should not see Create button await useDefaultWorkspaceScope(page); await page.goto('/dags/'); - await expect(page.getByRole('button', { name: 'Create new DAG' })).toBeHidden(); + await expect( + page.getByRole('button', { name: 'Create new DAG' }) + ).toBeHidden(); - // Operator should not see Rename/Delete on spec tab - await page.goto(`/dags/${encodeURIComponent(fileName)}/spec`); + // Operator should not see Rename/Delete in the workflow bar + await page.goto(`/dags/${encodeURIComponent(fileName)}`); await expect( page.getByRole('heading', { level: 1, name: dagName, exact: true }) ).toBeVisible(); + const bar = page.getByTestId('ww-bar'); await expect( - page.getByRole('button', { name: 'Rename', exact: true }) - ).toBeHidden(); + bar.getByRole('button', { name: 'Refresh', exact: true }) + ).toBeVisible(); await expect( - page.getByRole('button', { name: 'Delete', exact: true }) - ).toBeHidden(); + bar.getByRole('button', { name: 'Rename', exact: true }) + ).toHaveCount(0); + await expect( + bar.getByRole('button', { name: 'Delete', exact: true }) + ).toHaveCount(0); // Operator can still execute via API - const operatorToken = await loginViaAPI(request, operatorUser, operatorPass); + const operatorToken = await loginViaAPI( + request, + operatorUser, + operatorPass + ); const startResponse = await request.post( `/api/v1/dags/${encodeURIComponent(fileName)}/start?remoteNode=local`, { @@ -166,7 +190,10 @@ steps: expect(startResponse.ok()).toBeTruthy(); }); - test('admin resets user password from management page', async ({ page, request }) => { + test('admin resets user password from management page', async ({ + page, + request, + }) => { const stack = await loadStack(); const adminToken = await loginViaAPI( request, diff --git a/ui/e2e/fixtures/dags/visual-http-jq.yaml b/ui/e2e/fixtures/dags/visual-http-jq.yaml new file mode 100644 index 0000000000..58ee4ed57e --- /dev/null +++ b/ui/e2e/fixtures/dags/visual-http-jq.yaml @@ -0,0 +1,35 @@ +# Demo: fetch JSON over HTTP, filter it with jq, log the result. +description: Fetch a JSON document, extract its title with jq and log it. +steps: + - id: fetch + name: fetch + action: http.request + with: + method: GET + url: https://httpbin.org/json + format: json + silent: true + output: + body: + from: stdout + decode: json + select: .body + + - id: title + name: extract title + depends: [fetch] + action: jq.filter + with: + filter: .slideshow.title + data: ${steps.fetch.outputs.body} + raw: true + output: + text: + from: stdout + decode: text # plain text, no JSON quotes + + - name: show title + depends: [title] + action: log.write + with: + message: "Title: ${steps.title.outputs.text}" diff --git a/ui/e2e/fixtures/dags/visual-params.yaml b/ui/e2e/fixtures/dags/visual-params.yaml new file mode 100644 index 0000000000..f631ec6916 --- /dev/null +++ b/ui/e2e/fixtures/dags/visual-params.yaml @@ -0,0 +1,23 @@ +# Demo: params, consts, declared outputs and continue_on. +description: Use params and consts, tolerate one failing step, then read a param and an output. +params: + - GREETING: hello + - TARGET: world +consts: + - SUFFIX: "!" +steps: + - id: prepare + name: prepare + run: printf 'token=abc123\n' >> "$DAGU_OUTPUT_FILE" + outputs: + - name: token + + - name: flaky + depends: [prepare] + run: exit 1 + continue_on: + failure: true + + - name: report + depends: [flaky] + run: echo "${params.GREETING} ${params.TARGET}${consts.SUFFIX} token=${steps.prepare.outputs.token}" diff --git a/ui/e2e/fixtures/dags/visual-router.yaml b/ui/e2e/fixtures/dags/visual-router.yaml new file mode 100644 index 0000000000..21dcfea01d --- /dev/null +++ b/ui/e2e/fixtures/dags/visual-router.yaml @@ -0,0 +1,32 @@ +# Demo: route on a value to one of two handler steps. +description: Produce a status code, route it to a handler, and run lifecycle handlers. +type: graph +steps: + - id: produce + name: produce code + run: echo 200 + output: + code: + from: stdout + decode: text + + - name: route code + depends: [produce] + action: router.route + with: + value: "${steps.produce.outputs.code}" + routes: + "re:^2\\d\\d$": [handle_ok] + "re:^[^2]\\d\\d$": [handle_other] + + - name: handle_ok + run: echo "ok branch" + + - name: handle_other + run: echo "other branch" + +handler_on: + success: + run: echo "dag succeeded" + failure: + run: echo "dag failed" diff --git a/ui/e2e/helpers/e2e.ts b/ui/e2e/helpers/e2e.ts index dfae40fc3c..56662caf9f 100644 --- a/ui/e2e/helpers/e2e.ts +++ b/ui/e2e/helpers/e2e.ts @@ -1,7 +1,12 @@ // Copyright (C) 2026 Yota Hamada // SPDX-License-Identifier: GPL-3.0-or-later -import { expect, type APIRequestContext, type Page } from '@playwright/test'; +import { + expect, + type APIRequestContext, + type Locator, + type Page, +} from '@playwright/test'; import { execFileSync } from 'node:child_process'; import fs from 'node:fs/promises'; import path from 'node:path'; @@ -30,7 +35,12 @@ export type StackConfig = { workers: string[]; }; -export type UserRole = 'admin' | 'manager' | 'developer' | 'operator' | 'viewer'; +export type UserRole = + | 'admin' + | 'manager' + | 'developer' + | 'operator' + | 'viewer'; type WorkspaceAccessPayload = { all: boolean; grants: Array<{ @@ -122,7 +132,8 @@ const stackScriptPath = path.resolve(repoRoot, 'scripts/e2e/start-stack.sh'); const stackFilePath = process.env.DAGU_E2E_STACK_FILE ?? path.resolve( - process.env.DAGU_E2E_STATE_DIR ?? path.resolve(repoRoot, 'ui/test-results/e2e-stack'), + process.env.DAGU_E2E_STATE_DIR ?? + path.resolve(repoRoot, 'ui/test-results/e2e-stack'), 'stack.json' ); @@ -153,17 +164,31 @@ export function uniqueName(prefix: string): string { return `${prefix}-${timestamp}-${random}`; } -export async function writeLocalDAG(name: string, spec: string): Promise { +export async function writeLocalDAG( + name: string, + spec: string +): Promise { const stack = await loadStack(); const fileName = `${name}.yaml`; - await fs.writeFile(path.join(stack.local.dagsDir, fileName), `${spec.trim()}\n`, 'utf8'); + await fs.writeFile( + path.join(stack.local.dagsDir, fileName), + `${spec.trim()}\n`, + 'utf8' + ); return fileName; } -export async function writeRemoteDAG(name: string, spec: string): Promise { +export async function writeRemoteDAG( + name: string, + spec: string +): Promise { const stack = await loadStack(); const fileName = `${name}.yaml`; - await fs.writeFile(path.join(stack.remote.dagsDir, fileName), `${spec.trim()}\n`, 'utf8'); + await fs.writeFile( + path.join(stack.remote.dagsDir, fileName), + `${spec.trim()}\n`, + 'utf8' + ); return fileName; } @@ -275,7 +300,10 @@ export async function waitForSchedulerDAGRegistered( timeout: number = 15_000 ): Promise { const stack = await loadStack(); - const schedulerStatePath = path.join(stack.stateDir, 'local/runtime/data/scheduler/state.json'); + const schedulerStatePath = path.join( + stack.stateDir, + 'local/runtime/data/scheduler/state.json' + ); await expect .poll( @@ -360,7 +388,9 @@ export async function getDAGRun( ); expect(response.ok()).toBeTruthy(); - return normalizeDAGRunDetails(((await response.json()) as DAGRunDetailsResponse).dagRunDetails); + return normalizeDAGRunDetails( + ((await response.json()) as DAGRunDetailsResponse).dagRunDetails + ); } export async function listDAGRuns( @@ -368,7 +398,14 @@ export async function listDAGRuns( token: string, name: string, remoteNode: string = 'local' -): Promise> { +): Promise< + Array<{ + dagRunId: string; + status: RunStatusLabel; + statusCode: number; + workerId?: string; + }> +> { const response = await request.get( `/api/v1/dag-runs/${encodeURIComponent(name)}?remoteNode=${encodeURIComponent( remoteNode @@ -380,7 +417,12 @@ export async function listDAGRuns( expect(response.ok()).toBeTruthy(); const body = (await response.json()) as { - dagRuns?: Array<{ dagRunId: string; status: number; statusLabel?: string; workerId?: string }>; + dagRuns?: Array<{ + dagRunId: string; + status: number; + statusLabel?: string; + workerId?: string; + }>; }; return (body.dagRuns ?? []).map((run) => ({ dagRunId: run.dagRunId, @@ -457,9 +499,12 @@ export async function getQueue( token: string, queueName: string ): Promise { - const response = await request.get(`/api/v1/queues/${encodeURIComponent(queueName)}?remoteNode=local`, { - headers: authHeaders(token), - }); + const response = await request.get( + `/api/v1/queues/${encodeURIComponent(queueName)}?remoteNode=local`, + { + headers: authHeaders(token), + } + ); expect(response.ok()).toBeTruthy(); return (await response.json()) as QueueDetails; } @@ -512,11 +557,16 @@ export async function getStepStdout( return ((await response.json()) as LogResponse).content; } -export async function enqueueRunFromUI(page: Page, fileName: string): Promise { +export async function enqueueRunFromUI( + page: Page, + fileName: string +): Promise { const responsePromise = page.waitForResponse( (response) => response.request().method() === 'POST' && - response.url().includes(`/api/v1/dags/${encodeURIComponent(fileName)}/enqueue`) + response + .url() + .includes(`/api/v1/dags/${encodeURIComponent(fileName)}/enqueue`) ); await page.getByRole('button', { name: 'Start' }).first().click(); @@ -541,7 +591,10 @@ export async function enqueueRunFromUI(page: Page, fileName: string): Promise { +export async function selectRemoteNode( + page: Page, + nodeName: string +): Promise { const trigger = page .locator('aside') .getByRole('combobox', { name: 'Remote node' }); @@ -601,7 +654,11 @@ export async function getLatestLocalRunStatusRecord( dagRunId: string ): Promise { const stack = await loadStack(); - const dagRunsDir = path.join(stack.stateDir, 'local/runtime/data/dag-runs', dagName); + const dagRunsDir = path.join( + stack.stateDir, + 'local/runtime/data/dag-runs', + dagName + ); let output = ''; try { @@ -654,7 +711,11 @@ function isNoSuchProcessError(error: unknown): boolean { const { status, stderr } = error as ExecFileSyncError; const stderrText = - typeof stderr === 'string' ? stderr : Buffer.isBuffer(stderr) ? stderr.toString('utf8') : ''; + typeof stderr === 'string' + ? stderr + : Buffer.isBuffer(stderr) + ? stderr.toString('utf8') + : ''; return status === 1 && /no such process/i.test(stderrText); } @@ -672,7 +733,10 @@ function normalizeDAGRunDetails(run: RawDAGRunDetails): DAGRunDetails { }; } -function normalizeRunStatus(statusCode: number, statusLabel?: string): RunStatusLabel { +function normalizeRunStatus( + statusCode: number, + statusLabel?: string +): RunStatusLabel { if (statusLabel) { return statusLabel as RunStatusLabel; } @@ -700,3 +764,31 @@ function normalizeRunStatus(statusCode: number, statusLabel?: string): RunStatus throw new Error(`unknown DAG run status code: ${statusCode}`); } } + +/** + * Switches the open step view from its YAML to its form. A step that opens + * with a double click, Enter or the Open command shows its YAML first; the + * YAML | Form bar of the node view, or of the drawer on the classic page, + * has the Form button. The hidden node views of other panes have no role. + */ +export async function openStepForm(page: Page): Promise { + await page.getByRole('button', { name: 'Form', exact: true }).click(); +} + +/** + * Makes the OUTPUT part of the open node view visible. The expanded view + * shows INPUT, the center and OUTPUT side by side when its frame is wide + * enough; below that width it shows one of INPUT and OUTPUT under an + * INPUT | OUTPUT tablist. The tablist opens on OUTPUT when the host shows run + * facts (Executions) and on INPUT in the editor, so the click is a no-op on + * Executions. + */ +export async function showStepOutput(root: Page | Locator): Promise { + // The tab of the frame, not the Output tab of the run view inside OUTPUT. + const tab = root + .getByRole('tab', { name: 'Output', exact: true }) + .and(root.locator('[aria-controls="ndv-output"]')); + const region = root.getByRole('region', { name: 'Output', exact: true }); + await expect(tab.or(region)).toBeVisible({ timeout: 30_000 }); + if (await tab.isVisible()) await tab.click(); +} diff --git a/ui/e2e/visual-editor.spec.ts b/ui/e2e/visual-editor.spec.ts new file mode 100644 index 0000000000..40d0b2bc6f --- /dev/null +++ b/ui/e2e/visual-editor.spec.ts @@ -0,0 +1,381 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import { + expect, + test, + type APIRequestContext, + type Locator, + type Page, +} from '@playwright/test'; +import { readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { + loadStack, + loginViaAPI, + loginViaUI, + openStepForm, + uniqueName, + useDefaultWorkspaceScope, + waitForDAGAvailable, + writeLocalDAG, +} from './helpers/e2e'; + +const FIXTURE_DIR = path.resolve(__dirname, 'fixtures/dags'); +const fixtureFiles = readdirSync(FIXTURE_DIR) + .filter((file) => file.endsWith('.yaml')) + .sort(); + +function fixtureText(file: string): string { + return readFileSync(path.join(FIXTURE_DIR, file), 'utf8'); +} + +function apiHeaders(token: string): Record { + return { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }; +} + +function specURL(fileName: string): string { + return `/api/v1/dags/${encodeURIComponent(fileName)}/spec?remoteNode=local`; +} + +async function readSpec( + request: APIRequestContext, + token: string, + fileName: string +): Promise { + const response = await request.get(specURL(fileName), { + headers: apiHeaders(token), + }); + expect(response.ok()).toBeTruthy(); + return ((await response.json()) as { spec: string }).spec; +} + +// Writes without expectedSha256, like an editor on disk would. +async function writeSpec( + request: APIRequestContext, + token: string, + fileName: string, + spec: string +): Promise { + const response = await request.put(specURL(fileName), { + headers: apiHeaders(token), + data: { spec }, + }); + const failureBody = response.ok() ? '' : await response.text(); + expect( + response.ok(), + `PUT spec failed with ${response.status()}: ${failureBody}` + ).toBeTruthy(); +} + +function canvasNode(page: Page, key: string): Locator { + return page.getByTestId(`ve-canvas-node-${key}`); +} + +function saveButton(page: Page): Locator { + return page.locator('#save-config'); +} + +// Opens the workflow page, whose editor is the visual editor, and waits for +// the canvas to show steps. +async function openVisual(page: Page, fileName: string): Promise { + await page.goto(`/dags/${encodeURIComponent(fileName)}`); + await expect(page.getByTestId('ve-canvas')).toBeVisible({ timeout: 30_000 }); + await expect(canvasNode(page, 'steps[0]')).toBeVisible({ timeout: 30_000 }); +} + +// Adds a step after the selected step. The new step becomes the selection. +async function addStepFromPalette(page: Page, action: string): Promise { + // The n shortcut of the focused canvas opens the list of steps in the + // side drawer. + await page.getByTestId('ve-canvas').press('n'); + const panel = page.getByTestId('ve-add-step'); + const search = panel.getByRole('combobox', { name: 'Search actions' }); + await expect(search).toBeVisible({ timeout: 30_000 }); + await search.fill(action); + await expect(panel.getByRole('option').first()).toBeVisible({ + timeout: 30_000, + }); + await search.press('Enter'); + await expect(search).toBeHidden({ timeout: 30_000 }); +} + +// parity:test-e2e-visual-editor +test.describe('visual editor', () => { + const createdFiles: string[] = []; + + async function createDAG( + request: APIRequestContext, + token: string, + prefix: string, + spec: string + ): Promise<{ dagName: string; fileName: string; written: string }> { + const dagName = uniqueName(prefix); + const fileName = await writeLocalDAG(dagName, spec); + createdFiles.push(fileName); + await waitForDAGAvailable(request, token, fileName); + return { dagName, fileName, written: `${spec.trim()}\n` }; + } + + test.beforeEach(async ({ page }) => { + const stack = await loadStack(); + await loginViaUI(page, stack.auth.adminUsername, stack.auth.adminPassword); + await useDefaultWorkspaceScope(page); + await page.setViewportSize({ width: 1600, height: 900 }); + }); + + test.afterEach(async ({ request }) => { + const stack = await loadStack(); + const token = await loginViaAPI( + request, + stack.auth.adminUsername, + stack.auth.adminPassword + ); + const files = createdFiles.splice(0); + for (const fileName of files) { + const response = await request.delete( + `/api/v1/dags/${encodeURIComponent(fileName)}?remoteNode=local`, + { headers: apiHeaders(token) } + ); + expect(response.status(), `delete ${fileName}`).toBe(204); + } + }); + + for (const file of fixtureFiles) { + // Opening a DAG in the Visual view and selecting a step must not change + // the file, and nothing may enable Save. + test(`golden: ${file} stays byte-identical`, async ({ page, request }) => { + test.setTimeout(120_000); + const stack = await loadStack(); + const token = await loginViaAPI( + request, + stack.auth.adminUsername, + stack.auth.adminPassword + ); + const { fileName, written } = await createDAG( + request, + token, + 'e2e-ve-golden', + fixtureText(file) + ); + + await openVisual(page, fileName); + await expect(saveButton(page)).toBeDisabled(); + + // The inspector must not write to the buffer when it opens. + await canvasNode(page, 'steps[0]').dblclick(); + await openStepForm(page); + await expect(page.getByTestId('ve-inspector')).toBeVisible({ + timeout: 30_000, + }); + // Give validation and form mount effects time to run. + await page.waitForTimeout(2_000); + await expect(saveButton(page)).toBeDisabled(); + + expect(await readSpec(request, token, fileName)).toBe(written); + }); + } + + test('adds a step and saves it without touching the other lines', async ({ + page, + request, + }) => { + test.setTimeout(180_000); + const stack = await loadStack(); + const token = await loginViaAPI( + request, + stack.auth.adminUsername, + stack.auth.adminPassword + ); + const { fileName, written } = await createDAG( + request, + token, + 'e2e-ve-add', + fixtureText('visual-params.yaml') + ); + + await openVisual(page, fileName); + + // The node view of a step covers the canvas toolbar, so the step is + // added after a selection and its view opens afterwards. + await canvasNode(page, 'steps[0]').click(); + await addStepFromPalette(page, 'http.request'); + await expect(canvasNode(page, 'steps[1]')).toBeVisible({ timeout: 30_000 }); + await canvasNode(page, 'steps[1]').dblclick(); + await openStepForm(page); + const inspector = page.getByTestId('ve-inspector'); + await expect(inspector).toBeVisible({ timeout: 30_000 }); + + const url = inspector.getByLabel(/^url$/i); + await expect(url).toBeVisible({ timeout: 30_000 }); + await url.fill('https://example.com/'); + await expect(url).toHaveValue('https://example.com/'); + + // The candidates come from the validation of the edited text, so the + // picker is opened again until the parameter is listed. + const picker = page.getByRole('listbox', { name: 'References' }); + const param = picker + .getByRole('option') + .filter({ hasText: 'params.GREETING' }) + .first(); + await expect(async () => { + if (await picker.isVisible()) { + await page.keyboard.press('Escape'); + } + await url + .locator('..') + .getByRole('button', { name: 'Insert reference' }) + .click({ timeout: 5_000 }); + await expect(param).toBeVisible({ timeout: 3_000 }); + }).toPass({ timeout: 60_000 }); + await param.click(); + await expect(url).toHaveValue('https://example.com/${params.GREETING}'); + + await expect(saveButton(page)).toBeEnabled(); + // The node view covers the Save button, so the shortcut saves. + await page.keyboard.press('ControlOrMeta+s'); + const review = page.getByRole('dialog', { name: 'Review changes' }); + await expect(review).toBeVisible({ timeout: 30_000 }); + await expect(review).toContainText('example.com'); + + const saved = page.waitForResponse( + (response) => + response.request().method() === 'PUT' && + new URL(response.url()).pathname.endsWith('/spec') + ); + await review.getByRole('button', { name: 'Save', exact: true }).click(); + expect((await saved).ok()).toBeTruthy(); + await expect(review).toBeHidden({ timeout: 30_000 }); + await expect(saveButton(page)).toBeDisabled({ timeout: 30_000 }); + + const spec = await readSpec(request, token, fileName); + // The save inserts one block: the lines before it and after it are the + // lines of the file. + const before = written.split('\n'); + const after = spec.split('\n'); + let head = 0; + while (head < before.length && before[head] === after[head]) head++; + let tail = 0; + while ( + tail < before.length - head && + before[before.length - 1 - tail] === after[after.length - 1 - tail] + ) + tail++; + expect(head + tail).toBe(before.length); + expect(spec).toContain('action: http.request'); + expect(spec).toMatch( + /url: ["']?https:\/\/example\.com\/\$\{params\.GREETING\}["']?/ + ); + + // The node view of the new step is still open on its form. The YAML + // button of its mode switch shows the text of the step. + const nodeView = page.getByTestId('ndv'); + await nodeView.getByRole('button', { name: 'YAML', exact: true }).click(); + const editor = nodeView.locator('.monaco-editor').first(); + await expect(editor).toBeVisible({ timeout: 30_000 }); + await expect(editor.locator('.view-lines')).toContainText( + /example\.com\/\$\{params\.GREETING\}/, + { timeout: 30_000 } + ); + }); + + // Run step from the canvas: e2e/workspace/run-actions.spec.ts. The editor of + // the workspace shows the definition only, with no run status on its nodes. + + // An edited page learns of a change on disk from the live feed, and its + // edits wait for a decision. + test('shows a change on disk while the edits are unsaved', async ({ + page, + request, + }) => { + test.setTimeout(180_000); + const stack = await loadStack(); + const token = await loginViaAPI( + request, + stack.auth.adminUsername, + stack.auth.adminPassword + ); + const { fileName, written } = await createDAG( + request, + token, + 'e2e-ve-live', + fixtureText('visual-params.yaml') + ); + + await openVisual(page, fileName); + await canvasNode(page, 'steps[0]').click(); + await addStepFromPalette(page, 'http.request'); + await expect(canvasNode(page, 'steps[1]')).toBeVisible({ timeout: 30_000 }); + await expect(saveButton(page)).toBeEnabled({ timeout: 30_000 }); + + // Another writer changes the file after the page loaded it. + const external = `${written}# changed on disk by another writer\n`; + await writeSpec(request, token, fileName, external); + + await expect( + page.getByRole('dialog', { name: 'External Changes Detected' }) + ).toBeVisible({ timeout: 30_000 }); + expect(await readSpec(request, token, fileName)).toBe(external); + }); + + // A save that reaches the server after a change on disk is refused. + test('does not overwrite a file that changed on disk', async ({ + page, + request, + }) => { + test.setTimeout(180_000); + const stack = await loadStack(); + const token = await loginViaAPI( + request, + stack.auth.adminUsername, + stack.auth.adminPassword + ); + const { fileName, written } = await createDAG( + request, + token, + 'e2e-ve-conflict', + fixtureText('visual-params.yaml') + ); + + await openVisual(page, fileName); + await canvasNode(page, 'steps[0]').click(); + await addStepFromPalette(page, 'http.request'); + await expect(canvasNode(page, 'steps[1]')).toBeVisible({ timeout: 30_000 }); + await expect(saveButton(page)).toBeEnabled({ timeout: 30_000 }); + + // Another writer changes the file while the save is on its way, so the + // page cannot learn of the change before the server checks the save. + const external = `${written}# changed on disk by another writer\n`; + await page.route( + (url) => url.pathname.endsWith('/spec'), + async (route) => { + if (route.request().method() !== 'PUT') { + await route.fallback(); + return; + } + await writeSpec(request, token, fileName, external); + await route.continue(); + } + ); + + const saved = page.waitForResponse( + (response) => + response.request().method() === 'PUT' && + new URL(response.url()).pathname.endsWith('/spec') + ); + await saveButton(page).click(); + const review = page.getByRole('dialog', { name: 'Review changes' }); + await expect(review).toBeVisible({ timeout: 30_000 }); + await review.getByRole('button', { name: 'Save', exact: true }).click(); + expect((await saved).status()).toBe(409); + await expect(page.getByText('The file changed on disk')).toBeVisible({ + timeout: 30_000, + }); + + // No silent overwrite: the server keeps the external text. + expect(await readSpec(request, token, fileName)).toBe(external); + }); +}); diff --git a/ui/e2e/workflow-list-open.spec.ts b/ui/e2e/workflow-list-open.spec.ts new file mode 100644 index 0000000000..dc3a18586c --- /dev/null +++ b/ui/e2e/workflow-list-open.spec.ts @@ -0,0 +1,65 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import { expect, test } from '@playwright/test'; +import { + loadStack, + loginViaAPI, + loginViaUI, + uniqueName, + useDefaultWorkspaceScope, + waitForDAGAvailable, + writeLocalDAG, +} from './helpers/e2e'; + +// A row of the Workflows list opens the workflow page, in the same tab or, with +// Ctrl or Cmd, in a new tab. No right panel opens over the list. +test('opens a workflow from its row in the Workflows list', async ({ + page, + request, +}) => { + const stack = await loadStack(); + const token = await loginViaAPI( + request, + stack.auth.adminUsername, + stack.auth.adminPassword + ); + const dagName = uniqueName('list-open'); + const fileName = await writeLocalDAG( + dagName, + ` +name: ${dagName} +steps: + - name: hello + run: echo hello +` + ); + await waitForDAGAvailable(request, token, fileName); + await loginViaUI(page, stack.auth.adminUsername, stack.auth.adminPassword); + await useDefaultWorkspaceScope(page); + + await page.goto('/dags/'); + await page.getByPlaceholder('Filter by workflow name...').fill(dagName); + const row = page.getByRole('row').filter({ hasText: dagName }).first(); + await expect(row).toBeVisible({ timeout: 30_000 }); + await expect(row.getByRole('link', { name: dagName })).toHaveAttribute( + 'href', + new RegExp(`^/dags/${dagName}\\?remoteNode=local`) + ); + + const [popup] = await Promise.all([ + page.context().waitForEvent('page'), + row.click({ modifiers: ['ControlOrMeta'] }), + ]); + await popup.waitForLoadState(); + expect(new URL(popup.url()).pathname).toBe(`/dags/${dagName}`); + await popup.close(); + expect(new URL(page.url()).pathname).toBe('/dags'); + + await row.click(); + await expect + .poll(() => new URL(page.url()).pathname) + .toBe(`/dags/${dagName}`); + await expect(page.getByTestId('ww-pane')).toBeVisible(); + await expect(page.locator('.dag-modal-content')).toHaveCount(0); +}); diff --git a/ui/e2e/workspace/fixtures/index.ts b/ui/e2e/workspace/fixtures/index.ts new file mode 100644 index 0000000000..8e52bf3be5 --- /dev/null +++ b/ui/e2e/workspace/fixtures/index.ts @@ -0,0 +1,78 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import { test as base, expect, type Page } from '@playwright/test'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { + loadStack, + loginViaAPI, + loginViaUI, + uniqueName, + useDefaultWorkspaceScope, + waitForDAGAvailable, + writeLocalDAG, +} from '../../helpers/e2e'; + +export { expect }; + +const FIXTURE_DAG_DIR = path.resolve(__dirname, '../../fixtures/dags'); + +/** Opens an app path. The workflow page is the workspace. */ +export async function gotoWorkspace(page: Page, target: string): Promise { + await page.goto(target); +} + +/** Signs in as the stack admin and selects the default workspace scope. */ +export async function signIn(page: Page): Promise { + const stack = await loadStack(); + await loginViaUI(page, stack.auth.adminUsername, stack.auth.adminPassword); + await useDefaultWorkspaceScope(page); +} + +export type FixtureDAG = { dagName: string; fileName: string }; + +type WorkspaceFixtures = { + gotoWorkspace: (page: Page, target: string) => Promise; + /** + * Writes a copy of e2e/fixtures/dags/ under a unique name. The copy + * is deleted after the test. + */ + fixtureDAG: (file: string, prefix?: string) => Promise; +}; + +export const test = base.extend({ + // eslint-disable-next-line no-empty-pattern + gotoWorkspace: async ({}, use) => { + await use(gotoWorkspace); + }, + fixtureDAG: async ({ request }, use) => { + const stack = await loadStack(); + const token = await loginViaAPI( + request, + stack.auth.adminUsername, + stack.auth.adminPassword + ); + const created: string[] = []; + await use(async (file, prefix = 'e2e-ws') => { + const spec = readFileSync(path.join(FIXTURE_DAG_DIR, file), 'utf8'); + const dagName = uniqueName(prefix); + const fileName = await writeLocalDAG(dagName, spec); + created.push(fileName); + await waitForDAGAvailable(request, token, fileName); + return { dagName, fileName }; + }); + const leftBehind: string[] = []; + for (const fileName of created) { + const response = await request.delete( + `/api/v1/dags/${encodeURIComponent(fileName)}?remoteNode=local`, + { headers: { Authorization: `Bearer ${token}` } } + ); + // 404: the test renamed or deleted the workflow itself. + if (![204, 404].includes(response.status())) { + leftBehind.push(`${fileName}: HTTP ${response.status()}`); + } + } + expect(leftBehind, 'fixture DAGs not deleted').toEqual([]); + }, +}); diff --git a/ui/eslint.config.mjs b/ui/eslint.config.mjs index b9d5ba0e02..fa225c7a2b 100644 --- a/ui/eslint.config.mjs +++ b/ui/eslint.config.mjs @@ -14,6 +14,107 @@ const compat = new FlatCompat({ allConfig: js.configs.all, }); +const componentLibraryImports = { + group: [ + '@/ui/*', + '../ui/*', + '../../ui/*', + '../../../ui/*', + '../../../../ui/*', + './components/ui/*', + '../components/ui/*', + '../../components/ui/*', + '../../../components/ui/*', + '../../../../components/ui/*', + ], + message: + 'Use the canonical component library import style: @/components/ui/*.', +}; + +// The workspace must not load Mermaid or the classic run view. The barrels +// re-export them, so only leaf modules are allowed. The step-details barrel +// re-exports StepDetailsDrawer. +const dagBarrelImports = { + regex: + '(^|/)dags/components(/(common|dag-execution|dag-details|visualization|step-details))?(/index(\\.tsx?)?)?$', + message: + 'Import the leaf module: the features/dags barrels load the classic run view and Mermaid.', +}; + +const classicRunViewImports = { + regex: + '(^|/)dags/components/(DAGStatus|dag-details/(DAGStatusOverview|NodeStatusTable\\w*|DAGDetailsContent|DAGHeader)|dag-execution/(DAGExecutionHistory|LogSideModal)|visualization/(DAGGraph|Graph)|step-details/StepDetailsDrawer)(\\.tsx?)?$', + message: + 'The workspace does not mount the classic run view or the Mermaid graph.', +}; + +// Imports that the workspace and the editor code that it hosts must not use. +// The banned function names are in sharedSyntaxRules: a name pattern here +// would also flag namespace imports of unrelated modules. +const sharedPrimitiveImports = [ + { + regex: '(^|/)ui/reload-button(\\.tsx?)?$', + message: 'Use RefreshButton: it has the loading state and the label.', + }, + { + regex: '(^|/)common/StatusDot(\\.tsx?)?$', + message: 'Use StatusDot from @/components/ui/status-dot.', + }, +]; + +// Near pairs come back when a surface re-implements what a shared module +// owns. Each message names the module to use. A flat config block replaces +// the rule options, so this list is shared by the blocks below. +const NON_ARRAY_FROM = + '(Array|Buffer|Object|Iterator|YAMLMap|YAMLSeq|Int8Array|Uint8Array|Uint8ClampedArray|Int16Array|Uint16Array|Int32Array|Uint32Array|Float32Array|Float64Array|BigInt64Array|BigUint64Array)'; +const SHOW_MESSAGE_CALL = + ':matches(CallExpression[callee.name=/^show(Error|Toast)$/], CallExpression[callee.property.name=/^show(Error|Toast)$/])'; +const sharedSyntaxRules = [ + { + selector: + 'ImportSpecifier[imported.name=/^(formatDurationMs|formatStepTime)$/]', + message: 'Use formatElapsed or formatAbsoluteTime from @/lib/dagRunTiming.', + }, + { + selector: 'CallExpression[callee.property.name=/^(fromNow|toNow)$/]', + message: 'Use RelativeTime.', + }, + { + selector: `CallExpression[callee.property.name='from']:not([callee.object.name=/^${NON_ARRAY_FROM}$/])`, + message: 'Use RelativeTime.', + }, + { + selector: + ":matches(MemberExpression[object.name='navigator'], MemberExpression[object.property.name='navigator']):matches([property.name='clipboard'], [property.value='clipboard'])", + message: 'Use copyText or readClipboardText from @/lib/clipboard.', + }, + { + selector: + "VariableDeclarator:matches([init.name='navigator'], [init.property.name='navigator']) > ObjectPattern > Property[key.name='clipboard']", + message: 'Use copyText or readClipboardText from @/lib/clipboard.', + }, + { + selector: 'MemberExpression[property.name=/^toLocale(Date|Time)?String$/]', + message: 'Use formatAbsoluteTime from @/lib/dagRunTiming.', + }, + { + selector: `${SHOW_MESSAGE_CALL} > :matches(Literal, TemplateLiteral).arguments`, + message: 'Pass ts() with the text.', + }, + { + selector: `${SHOW_MESSAGE_CALL} > ConditionalExpression.arguments > :matches(Literal[value=/./], TemplateLiteral):matches(.consequent, .alternate)`, + message: 'Pass ts() with the text.', + }, + { + selector: 'Literal[value=/\\bfocus-visible:ring-primary\\b/]', + message: 'Use the shared focus ring: focus-visible:ring-ring.', + }, + { + selector: 'TemplateElement[value.raw=/\\bfocus-visible:ring-primary\\b/]', + message: 'Use the shared focus ring: focus-visible:ring-ring.', + }, +]; + export default defineConfig([ globalIgnores(['**/node_modules/', '**/*.js']), { @@ -32,30 +133,53 @@ export default defineConfig([ rules: { '@typescript-eslint/no-non-null-assertion': 'off', + 'no-restricted-imports': [ + 'error', + { patterns: [componentLibraryImports] }, + ], + }, + ignores: ['node_modules', 'dist', 'build', 'coverage', '**/*.js'], + }, + { + // A flat config block replaces the rule options, so the shared pattern + // is repeated here. + files: ['src/features/workflow-workspace/**'], + rules: { 'no-restricted-imports': [ 'error', { patterns: [ - { - group: [ - '@/ui/*', - '../ui/*', - '../../ui/*', - '../../../ui/*', - '../../../../ui/*', - './components/ui/*', - '../components/ui/*', - '../../components/ui/*', - '../../../components/ui/*', - '../../../../components/ui/*', - ], - message: - 'Use the canonical component library import style: @/components/ui/*.', - }, + componentLibraryImports, + dagBarrelImports, + classicRunViewImports, + ...sharedPrimitiveImports, ], }, ], }, - ignores: ['node_modules', 'dist', 'build', 'coverage', '**/*.js'], + }, + { + files: [ + 'src/features/visual-editor/**', + 'src/features/dags/components/dag-editor/**', + ], + rules: { + 'no-restricted-imports': [ + 'error', + { patterns: [componentLibraryImports, ...sharedPrimitiveImports] }, + ], + }, + }, + { + // Tests mock the clipboard and write the banned text as probe input. + files: [ + 'src/features/workflow-workspace/**', + 'src/features/visual-editor/**', + 'src/features/dags/components/dag-editor/**', + ], + ignores: ['**/__tests__/**', '**/*.test.{ts,tsx}'], + rules: { + 'no-restricted-syntax': ['error', ...sharedSyntaxRules], + }, }, ]); diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts index a86c93b25b..ea0b4b93b9 100644 --- a/ui/playwright.config.ts +++ b/ui/playwright.config.ts @@ -62,6 +62,16 @@ export default defineConfig({ use: { ...devices['Desktop Chrome'], browserName: 'chromium', + viewport: { width: 1440, height: 900 }, + }, + }, + { + name: 'workspace-mobile', + testDir: './e2e/workspace', + use: { + ...devices['Desktop Chrome'], + browserName: 'chromium', + viewport: { width: 390, height: 844 }, }, }, ], diff --git a/ui/src/features/visual-editor/doc/__tests__/clipboard.test.ts b/ui/src/features/visual-editor/doc/__tests__/clipboard.test.ts new file mode 100644 index 0000000000..c75ae44574 --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/clipboard.test.ts @@ -0,0 +1,491 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import { renderHook, act } from '@testing-library/react'; +import { useState } from 'react'; +import { describe, expect, it } from 'vitest'; +import type { EditResult } from '../../contracts'; +import { + yamlArraySteps, + yamlChain, + yamlHandlers, + yamlMapSteps, + yamlNestedParallel, + yamlShorthandSteps, +} from '../../testing/fixtures'; +import { useEditSession } from '../../useEditSession'; +import { + copyStepsYaml, + duplicateStepsOp, + pasteStepsOp, +} from '../ops/clipboard'; +import { parseDagDoc } from '../parse'; +import { applyTextEdits } from '../patch'; + +type Done = Extract; + +/** Checks that the edits give the text in one hunk. */ +function done(result: EditResult, input: string): Done { + if (!result.ok) throw new Error(`${result.code}: ${result.message}`); + expect(applyTextEdits(input, result.edits)).toBe(result.text); + expect(result.edits).toHaveLength(1); + return result; +} + +/** A step with an id that another step reads, a comment and a dangling depends. */ +const REFS = `name: refs +steps: + - name: fetch + id: fetch + run: curl x + output: BODY + + # Parse the body. + - name: parse # inline + depends: [fetch, gone] + run: echo \${steps.fetch.outputs.body} \${fetch.stdout} +`; + +// parity:test-ve-doc-model parity:minimal-text-edits +describe('copyStepsYaml', () => { + it('gives the selected entries as a YAML list, in file order', () => { + const doc = parseDagDoc(yamlArraySteps); + expect(copyStepsYaml(doc, ['steps[2]', 'steps[0]'])).toBe( + `- name: fetch + run: curl -sSf https://example.com/data.csv -o data.csv +# Last step. +- name: publish + depends: + - transform + run: ./publish.sh sorted.csv +` + ); + expect(copyStepsYaml(doc, ['steps[1]'])).toBe( + `- name: transform # keep the header row + depends: [fetch] + run: | + head -n 1 data.csv + sort data.csv > sorted.csv +` + ); + }); + + it('names a map entry by its key and writes a shorthand as run', () => { + expect( + copyStepsYaml(parseDagDoc(yamlMapSteps), ['steps[0]', 'steps[1]']) + ).toBe( + `- name: fetch + run: curl -sSf https://example.com/data.csv -o data.csv +- name: parse + depends: [fetch] + run: wc -l data.csv +` + ); + expect(copyStepsYaml(parseDagDoc(yamlShorthandSteps), ['steps[0]'])).toBe( + '- run: echo one\n' + ); + }); + + it('keeps the file order of a map whose keys are not sorted', () => { + const text = 'steps:\n zeta:\n run: z\n alpha:\n run: a\n'; + // The server sorts map steps by name: alpha is steps[0]. + expect(copyStepsYaml(parseDagDoc(text), ['steps[0]', 'steps[1]'])).toBe( + '- name: zeta\n run: z\n- name: alpha\n run: a\n' + ); + }); + + it('resolves aliases and drops anchors', () => { + const text = `name: a +steps: + - name: one + env: &common + - A=1 + - name: two + env: *common +`; + expect(copyStepsYaml(parseDagDoc(text), ['steps[1]'])).toBe( + '- name: two\n env:\n - A=1\n' + ); + expect(copyStepsYaml(parseDagDoc(text), ['steps[0]'])).toBe( + '- name: one\n env:\n - A=1\n' + ); + }); + + // The server reads integers exactly, beyond the float precision. + it('keeps an integer beyond the float precision', () => { + const text = + 'steps:\n - name: a\n env:\n CHANNEL: 9007199254740993\n'; + expect(copyStepsYaml(parseDagDoc(text), ['steps[0]'])).toBe( + '- name: a\n env:\n CHANNEL: 9007199254740993\n' + ); + }); + + it('gives nothing without a step or with broken YAML', () => { + expect(copyStepsYaml(parseDagDoc(yamlArraySteps), [])).toBe(''); + expect(copyStepsYaml(parseDagDoc(yamlArraySteps), ['steps[9]'])).toBe(''); + expect(copyStepsYaml(parseDagDoc('steps: [a'), ['steps[0]'])).toBe(''); + }); +}); + +describe('pasteStepsOp', () => { + it('inserts after the anchor as one hunk with unique names', () => { + const doc = parseDagDoc(yamlArraySteps); + const clip = copyStepsYaml(doc, ['steps[0]']); + const result = done(pasteStepsOp(doc, clip, 'steps[1]'), yamlArraySteps); + expect(result.text).toBe(`# Nightly import +name: nightly-import +steps: + # Download the source file. + - name: fetch + run: curl -sSf https://example.com/data.csv -o data.csv + + - name: transform # keep the header row + depends: [fetch] + run: | + head -n 1 data.csv + sort data.csv > sorted.csv + + - name: fetch_2 + run: curl -sSf https://example.com/data.csv -o data.csv + + # Last step. + - name: publish + depends: + - transform + run: ./publish.sh sorted.csv +`); + expect(result.select).toBe('steps[2]'); + }); + + it('appends at the end without an anchor', () => { + const doc = parseDagDoc(yamlChain); + const result = done( + pasteStepsOp(doc, '- name: build\n run: make again\n', null), + yamlChain + ); + expect(result.text).toBe(`${yamlChain} - name: build_2 + run: make again +`); + expect(result.select).toBe('steps[3]'); + }); + + it('drops dangling depends and rewrites the references of renamed steps', () => { + const doc = parseDagDoc(REFS); + const clip = copyStepsYaml(doc, ['steps[0]', 'steps[1]']); + const result = done(pasteStepsOp(doc, clip, 'steps[1]'), REFS); + expect(result.text).toBe(`${REFS} + - name: fetch_2 + id: fetch_2 + run: curl x + output: BODY + + # Parse the body. + - name: parse_2 # inline + depends: [fetch_2] + run: echo \${steps.fetch_2.outputs.body} \${fetch_2.stdout} +`); + }); + + it('keeps depends on a step of the target', () => { + const doc = parseDagDoc(REFS); + const result = done( + pasteStepsOp( + doc, + '- name: late\n depends: fetch\n run: echo ${steps.fetch.outputs.body}\n', + null + ), + REFS + ); + expect(result.text).toBe(`${REFS} + - name: late + depends: fetch + run: echo \${steps.fetch.outputs.body} +`); + }); + + it('keeps an integer beyond the float precision', () => { + const doc = parseDagDoc(yamlChain); + const result = done( + pasteStepsOp( + doc, + '- name: big\n env:\n ID: 9007199254740993\n', + null + ), + yamlChain + ); + expect(result.text).toContain(' env:\n ID: 9007199254740993\n'); + }); + + // Like the server, a step named by a number is named by its text. + it('renames a step named by a number and the depends on it', () => { + const text = 'steps:\n - name: 7\n run: x\n'; + const result = done( + pasteStepsOp( + parseDagDoc(text), + '- name: 7\n run: y\n- name: b\n depends: [7]\n run: z\n', + null + ), + text + ); + expect(result.text).toBe( + `${text} - name: 7_2\n run: y\n - name: b\n depends: [7_2]\n run: z\n` + ); + }); + + it('pastes into map form, keyed by the name', () => { + const doc = parseDagDoc(yamlMapSteps); + const result = done( + pasteStepsOp( + doc, + '# Again.\n- name: parse\n depends: [fetch]\n run: wc -c data.csv\n', + 'steps[0]' + ), + yamlMapSteps + ); + expect(result.text).toBe(`name: map-form +steps: + # Entry point. + fetch: + run: curl -sSf https://example.com/data.csv -o data.csv + + # Again. + parse_2: + depends: [fetch] + run: wc -c data.csv + + parse: + depends: [fetch] + run: wc -l data.csv +`); + // Map form: the server orders the steps by name. + expect(result.select).toBe('steps[2]'); + }); + + it('takes the steps of a whole DAG and creates the steps list', () => { + const input = 'name: empty\n'; + const result = done( + pasteStepsOp( + parseDagDoc(input), + 'name: other\nsteps:\n - name: a\n run: echo a\n', + null + ), + input + ); + expect(result.text).toBe( + 'name: empty\nsteps:\n - name: a\n run: echo a\n' + ); + const flow = 'name: x\nsteps: []\n'; + expect( + done(pasteStepsOp(parseDagDoc(flow), 'run: echo b\n', null), flow).text + ).toBe('name: x\nsteps:\n - name: step\n run: echo b\n'); + }); + + it('takes an exec step and a typed step with a config', () => { + const flow = 'name: x\nsteps: []\n'; + expect( + done( + pasteStepsOp(parseDagDoc(flow), 'exec:\n command: ls\n', null), + flow + ).text + ).toBe('name: x\nsteps:\n - name: step\n exec:\n command: ls\n'); + expect( + pasteStepsOp( + parseDagDoc(flow), + '- type: http\n config:\n url: https://example.com\n', + null + ).ok + ).toBe(true); + }); + + it('drops depends in a chain DAG', () => { + const result = done( + pasteStepsOp( + parseDagDoc(yamlChain), + '- name: smoke\n depends: [test]\n run: make smoke\n', + 'steps[1]' + ), + yamlChain + ); + expect(result.text).toBe(`# Chain DAG +type: chain +name: release +steps: + - name: build + run: make build + - name: test + run: make test + - name: smoke + run: make smoke + - name: deploy + run: make deploy +`); + }); + + it('goes after a parallel group that holds the anchor', () => { + const result = done( + pasteStepsOp( + parseDagDoc(yamlNestedParallel), + '- name: lint\n run: make lint\n', + 'steps[1]' + ), + yamlNestedParallel + ); + expect(result.text).toContain( + ' - name: unit\n run: make test\n - name: lint_2\n run: make lint\n - name: report\n' + ); + }); + + it.each([ + ['plain text', 'hello world'], + ['a broken list', '- [a'], + ['a mapping that is no step', 'foo: bar'], + ['a list of strings', '- a\n- b\n'], + ['an empty text', ''], + ['two documents', '- name: a\n---\n- name: b\n'], + ['a nested group', '- - name: a\n'], + ])('refuses %s and changes nothing', (_label, clip) => { + expect(pasteStepsOp(parseDagDoc(REFS), clip, null)).toEqual({ + ok: false, + code: 'invalidYaml', + message: expect.any(String), + }); + }); + + it('refuses an unknown anchor and broken YAML', () => { + expect( + pasteStepsOp(parseDagDoc(REFS), '- name: a\n', 'steps[9]') + ).toMatchObject({ ok: false, code: 'unknownStep' }); + expect( + pasteStepsOp(parseDagDoc('steps: [a'), '- name: a\n', null) + ).toMatchObject({ ok: false, code: 'parseError' }); + }); +}); + +describe('duplicateStepsOp', () => { + it('copies and pastes after the last selected step', () => { + const doc = parseDagDoc(yamlArraySteps); + const result = done( + duplicateStepsOp(doc, ['steps[1]', 'steps[0]']), + yamlArraySteps + ); + expect(result.text).toBe(`# Nightly import +name: nightly-import +steps: + # Download the source file. + - name: fetch + run: curl -sSf https://example.com/data.csv -o data.csv + + - name: transform # keep the header row + depends: [fetch] + run: | + head -n 1 data.csv + sort data.csv > sorted.csv + + - name: fetch_2 + run: curl -sSf https://example.com/data.csv -o data.csv + + - name: transform_2 # keep the header row + depends: [fetch_2] + run: | + head -n 1 data.csv + sort data.csv > sorted.csv + + # Last step. + - name: publish + depends: + - transform + run: ./publish.sh sorted.csv +`); + expect(result.select).toBe('steps[2]'); + }); + + it('keeps the upstream step of a single copy', () => { + const result = done( + duplicateStepsOp(parseDagDoc(REFS), ['steps[1]']), + REFS + ); + expect(result.text).toBe(`${REFS} + # Parse the body. + - name: parse_2 # inline + depends: [fetch] + run: echo \${steps.fetch.outputs.body} \${fetch.stdout} +`); + }); + + // The server trims names and ids: "task " and "task" are one step. + it('renames a copy whose name or id differs by outer spaces only', () => { + const text = 'steps:\n - name: "task "\n id: " job"\n run: x\n'; + const result = done( + duplicateStepsOp(parseDagDoc(text), ['steps[0]']), + text + ); + expect(result.text).toBe( + `${text} - name: "task_2"\n id: "job_2"\n run: x\n` + ); + }); + + it('refuses a handler', () => { + // A copy of a handler would run as a step on every run. + expect( + duplicateStepsOp(parseDagDoc(yamlHandlers), ['handler_on.failure']) + ).toMatchObject({ ok: false, code: 'noop' }); + }); + + it('refuses no step, an unknown step and broken YAML', () => { + const doc = parseDagDoc(yamlArraySteps); + expect(duplicateStepsOp(doc, [])).toMatchObject({ + ok: false, + code: 'noop', + }); + expect(duplicateStepsOp(doc, ['steps[7]'])).toMatchObject({ + ok: false, + code: 'unknownStep', + }); + expect( + duplicateStepsOp(parseDagDoc('steps: [a'), ['steps[0]']) + ).toMatchObject({ ok: false, code: 'parseError' }); + }); +}); + +// The editor applies a clipboard result as a whole new text; the edit +// session records it as one undo step (VisualEditor applyResult). +describe('clipboard edits in the edit session', () => { + function useHarness(initial: string) { + const [text, setText] = useState(initial); + const session = useEditSession({ + text, + setText, + names: { byKey: {}, byName: {} }, + }); + return { text, session }; + } + + it('reverts a duplicate and then a paste with one undo each', () => { + const { result } = renderHook(() => useHarness(yamlArraySteps)); + const duplicated = duplicateStepsOp(parseDagDoc(yamlArraySteps), [ + 'steps[0]', + ]); + if (!duplicated.ok) throw new Error(duplicated.message); + act(() => { + result.current.session.applyWith(() => duplicated); + }); + act(() => result.current.session.undo()); + expect(result.current.text).toBe(yamlArraySteps); + act(() => result.current.session.redo()); + expect(result.current.text).toBe(duplicated.text); + + const pasted = pasteStepsOp( + parseDagDoc(duplicated.text), + '- name: extra\n run: echo\n', + null + ); + if (!pasted.ok) throw new Error(pasted.message); + act(() => { + result.current.session.applyWith(() => pasted); + }); + act(() => result.current.session.undo()); + expect(result.current.text).toBe(duplicated.text); + act(() => result.current.session.undo()); + expect(result.current.text).toBe(yamlArraySteps); + }); +}); diff --git a/ui/src/features/visual-editor/doc/__tests__/convert.test.ts b/ui/src/features/visual-editor/doc/__tests__/convert.test.ts new file mode 100644 index 0000000000..605295738e --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/convert.test.ts @@ -0,0 +1,534 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import { describe, expect, it } from 'vitest'; +import type { EditResult, StepNameMap } from '../../contracts'; +import { + yamlChain, + yamlMapSteps, + yamlNestedParallel, + yamlMultiDoc, + yamlShorthandSteps, +} from '../../testing/fixtures'; +import { opConvertStepsToExplicitForm } from '../ops/convert'; +import { parseDagDoc } from '../parse'; + +const OP = { kind: 'convertStepsToExplicitForm' } as const; + +function convert(text: string, names?: StepNameMap): EditResult { + return opConvertStepsToExplicitForm(text, OP, names ? { names } : {}); +} + +/** The text of a successful result; fails the test otherwise. */ +function converted(text: string, names?: StepNameMap): string { + const result = convert(text, names); + if (!result.ok) throw new Error(`${result.code}: ${result.message}`); + return result.text; +} + +function namesOf(byKey: Record): StepNameMap { + const byName: Record = {}; + for (const [key, name] of Object.entries(byKey)) byName[name] = key; + return { byKey, byName }; +} + +describe('shorthand steps', () => { + it('becomes run mappings without names', () => { + expect(converted(yamlShorthandSteps)).toBe(`name: shorthand +steps: + - run: echo one + - run: echo two # shorthand step + - name: three + run: echo three +`); + }); + + it('writes the server name first', () => { + const names = namesOf({ + 'steps[0]': 'cmd_1', + 'steps[1]': 'cmd_2', + 'steps[2]': 'three', + }); + expect(converted(yamlShorthandSteps, names)).toBe(`name: shorthand +steps: + - name: cmd_1 + run: echo one + - name: cmd_2 + run: echo two # shorthand step + - name: three + run: echo three +`); + }); + + it('names only the steps that have a server name', () => { + const text = converted( + yamlShorthandSteps, + namesOf({ 'steps[1]': 'cmd_2' }) + ); + expect(text).toBe(`name: shorthand +steps: + - run: echo one + - name: cmd_2 + run: echo two # shorthand step + - name: three + run: echo three +`); + }); + + it('keeps comments and blank lines around the entries', () => { + const text = `steps: + # lead + + - "echo a" # tail + # mid + - echo b +`; + expect(converted(text)).toBe(`steps: + # lead + + - run: "echo a" # tail + # mid + - run: echo b +`); + }); + + it('keeps the other lines of the file as they are', () => { + const text = `# header +name: keep # spacing +steps: + - echo a + - name: b + run: x +`; + const result = convert(text); + expect(result.ok && result.edits).toEqual([ + { from: 41, to: 54, insert: ' - run: echo a\n' }, + ]); + }); + + it('keeps the string style', () => { + const text = `steps: + - 'it''s' + - | + echo a + echo b +`; + expect(converted(text)).toBe(`steps: + - run: 'it''s' + - run: | + echo a + echo b +`); + }); + + it('keeps the StepKeys and the number of steps', () => { + const text = converted(yamlShorthandSteps); + const before = parseDagDoc(yamlShorthandSteps).steps; + const after = parseDagDoc(text).steps; + expect(after.map((s) => s.key)).toEqual(before.map((s) => s.key)); + expect(after.map((s) => s.form)).toEqual(['block', 'block', 'block']); + expect(after.map((s) => s.value.run)).toEqual( + before.map((s) => s.value.run) + ); + }); + + it('does not set select', () => { + const result = convert(yamlShorthandSteps); + expect(result.ok && 'select' in result).toBe(false); + }); +}); + +describe('parallel groups', () => { + it('flattens the group in place', () => { + expect(converted(yamlNestedParallel)).toBe(`name: ci +steps: + - name: prepare + run: ./prepare.sh + # Parallel group. + - name: lint + run: make lint + - name: unit + run: make test + - name: report + run: ./report.sh +`); + }); + + it('keeps the StepKeys and the number of steps', () => { + const before = parseDagDoc(yamlNestedParallel).steps; + const after = parseDagDoc(converted(yamlNestedParallel)).steps; + expect(after.map((s) => s.key)).toEqual(before.map((s) => s.key)); + expect(after.map((s) => s.explicitName)).toEqual( + before.map((s) => s.explicitName) + ); + expect(after.map((s) => s.form)).toEqual([ + 'block', + 'block', + 'block', + 'block', + ]); + }); + + it('keeps the comments around a group', () => { + const text = `steps: + - name: a + run: x + + # Group comment. + - - name: b # b tail + run: y + # between + - name: c + run: z + # after group + - name: d + run: w +`; + expect(converted(text)).toBe(`steps: + - name: a + run: x + + # Group comment. + - name: b # b tail + run: y + # between + - name: c + run: z + # after group + - name: d + run: w +`); + }); + + it('converts a shorthand inside a group', () => { + const text = + 'steps:\n - - echo a\n - name: b\n run: y\n - echo c\n'; + const names = namesOf({ 'steps[0]': 'cmd_1', 'steps[2]': 'cmd_3' }); + expect(converted(text, names)).toBe(`steps: + - name: cmd_1 + run: echo a + - name: b + run: y + - name: cmd_3 + run: echo c +`); + }); + + it('flattens several groups and drops an empty one', () => { + const text = + 'steps:\n - - run: a\n - run: b\n - - run: c\n - []\n - run: d\n'; + expect(converted(text)).toBe(`steps: + - run: a + - run: b + - run: c + - run: d +`); + }); + + // The server names an unnamed step from its position, which flattening moves. + it('pins the server name of unnamed steps from the first group on', () => { + const text = `steps: + - run: a + - - run: b + - name: c + run: y + - run: d + - id: e + run: f +`; + const names = namesOf({ + 'steps[0]': 'step_1', + 'steps[1]': 'step_2', + 'steps[2]': 'c', + 'steps[3]': 'step_4', + 'steps[4]': 'e', + }); + expect(converted(text, names)).toBe(`steps: + - run: a + - name: step_2 + run: b + - name: c + run: y + - name: step_4 + run: d + - id: e + run: f +`); + }); + + it('does not pin a name in a file without groups', () => { + const names = namesOf({ 'steps[0]': 'step_1', 'steps[1]': 'cmd_2' }); + expect(converted('steps:\n - run: a\n - echo b\n', names)).toBe( + 'steps:\n - run: a\n - name: cmd_2\n run: echo b\n' + ); + }); + + it('changes only the lines of the group', () => { + const text = 'steps:\n - run: a\n - - run: b\n - run: c\n - run: d\n'; + const result = convert(text); + expect(result.ok && result.edits).toEqual([ + { from: 18, to: 44, insert: ' - run: b\n - run: c\n' }, + ]); + }); +}); + +describe('chain DAG', () => { + it('writes depends and sets the graph type', () => { + expect(converted(yamlChain)).toBe(`# Chain DAG +type: graph +name: release +steps: + - name: build + run: make build + - name: test + depends: [build] + run: make test + - name: deploy + depends: [test] + run: make deploy +`); + }); + + it('makes a group one position', () => { + const text = `type: chain +steps: + - name: a + run: x + # The two run after a. + - - name: b + run: y + - name: c + run: z + - name: d + run: w +`; + expect(converted(text)).toBe(`type: graph +steps: + - name: a + run: x + # The two run after a. + - name: b + depends: [a] + run: y + - name: c + depends: [a] + run: z + - name: d + depends: [b, c] + run: w +`); + }); + + it('keeps the StepKeys and the number of steps', () => { + const text = + 'type: chain\nsteps:\n - name: a\n run: x\n - - name: b\n run: y\n - name: c\n run: z\n - name: d\n run: w\n'; + const before = parseDagDoc(text).steps; + const after = parseDagDoc(converted(text)).steps; + expect(after.map((s) => s.key)).toEqual(before.map((s) => s.key)); + expect(after.map((s) => s.explicitName)).toEqual(['a', 'b', 'c', 'd']); + expect(after.map((s) => s.depends)).toEqual([[], ['a'], ['a'], ['b', 'c']]); + }); + + it('uses the server names of shorthand steps', () => { + const text = + 'type: chain\nsteps:\n - echo a\n - echo b\n - name: c\n run: x\n'; + const names = namesOf({ + 'steps[0]': 'cmd_1', + 'steps[1]': 'cmd_2', + 'steps[2]': 'c', + }); + expect(converted(text, names)).toBe(`type: graph +steps: + - name: cmd_1 + run: echo a + - name: cmd_2 + depends: [cmd_1] + run: echo b + - name: c + depends: [cmd_2] + run: x +`); + }); + + it('names an unnamed step from the server names when others depend on it', () => { + const text = 'type: chain\nsteps:\n - run: a\n - name: b\n run: x\n'; + const names = namesOf({ 'steps[0]': 'step_1', 'steps[1]': 'b' }); + expect(converted(text, names)).toBe(`type: graph +steps: + - name: step_1 + run: a + - name: b + depends: [step_1] + run: x +`); + }); + + it('returns invalidName for an unnamed step that others depend on', () => { + const text = 'type: chain\nsteps:\n - run: a\n - name: b\n run: x\n'; + expect(convert(text)).toMatchObject({ ok: false, code: 'invalidName' }); + expect( + convert('type: chain\nsteps:\n - echo a\n - echo b\n') + ).toMatchObject({ + ok: false, + code: 'invalidName', + }); + }); + + it('does not need a name for the last step', () => { + const text = 'type: chain\nsteps:\n - name: a\n run: x\n - run: y\n'; + expect(converted(text)).toBe( + 'type: graph\nsteps:\n - name: a\n run: x\n - depends: [a]\n run: y\n' + ); + }); + + it('refers to a step by its id when it has no name', () => { + const text = + 'type: chain\nsteps:\n - id: first\n run: x\n - id: second\n run: y\n'; + expect(converted(text)).toBe( + 'type: graph\nsteps:\n - id: first\n run: x\n - id: second\n depends: [first]\n run: y\n' + ); + }); + + it('keeps quotes and the comment of the type', () => { + const text = + 'type: "chain" # sequential\nsteps:\n - name: a\n run: x\n'; + expect(converted(text)).toBe( + 'type: "graph" # sequential\nsteps:\n - name: a\n run: x\n' + ); + }); + + it('gives the step after an empty group no dependency', () => { + const text = + 'type: chain\nsteps:\n - name: a\n run: x\n - []\n - name: b\n run: y\n'; + expect(converted(text)).toBe( + 'type: graph\nsteps:\n - name: a\n run: x\n - name: b\n run: y\n' + ); + }); + + it('only sets the type for map form', () => { + const text = 'type: chain\nsteps:\n b:\n run: y\n a:\n run: x\n'; + expect(converted(text)).toBe( + 'type: graph\nsteps:\n b:\n run: y\n a:\n run: x\n' + ); + }); + + it('sets the type when there are no steps', () => { + expect(converted('type: chain\nname: a\n')).toBe('type: graph\nname: a\n'); + }); + + it('pins the server names of unnamed steps after a group', () => { + const text = + 'type: chain\nsteps:\n - name: a\n run: x\n - - name: b\n run: y\n - name: c\n run: z\n - run: w\n'; + const names = namesOf({ + 'steps[0]': 'a', + 'steps[1]': 'b', + 'steps[2]': 'c', + 'steps[3]': 'step_3', + }); + expect(converted(text, names)).toBe(`type: graph +steps: + - name: a + run: x + - name: b + depends: [a] + run: y + - name: c + depends: [a] + run: z + - name: step_3 + depends: [b, c] + run: w +`); + }); + + it('keeps the bytes of document 1', () => { + const text = + 'type: chain\nsteps:\n - name: a\n run: x\n - name: b\n run: y\n---\ntype: chain\nsteps:\n - echo z\n'; + const out = converted(text); + expect(out.slice(out.indexOf('---'))).toBe( + '---\ntype: chain\nsteps:\n - echo z\n' + ); + expect(out.startsWith('type: graph\n')).toBe(true); + }); +}); + +describe('alias entries', () => { + const defs = 'defs: &s\n name: a\n run: x\n'; + + it('converts the other steps and leaves the alias', () => { + const text = `${defs}steps:\n - *s\n - echo b\n`; + expect(converted(text)).toBe(`${defs}steps:\n - *s\n - run: echo b\n`); + }); + + it('refuses a chain whose alias would need depends', () => { + const text = `${defs}type: chain\nsteps:\n - name: b\n run: y\n - *s\n`; + expect(convert(text)).toMatchObject({ + ok: false, + code: 'unsupportedForm', + }); + }); + + it('refuses a chain in which an alias must be referenced', () => { + const text = `${defs}type: chain\nsteps:\n - *s\n - name: b\n run: y\n`; + expect(convert(text)).toMatchObject({ + ok: false, + code: 'unsupportedForm', + }); + }); + + it('keeps the StepKeys when it flattens a group with an alias', () => { + const text = `${defs}steps:\n - - *s\n - name: b\n run: y\n - name: c\n run: z\n`; + const after = parseDagDoc(converted(text)).steps; + expect(after.map((s) => [s.key, s.explicitName])).toEqual([ + ['steps[0]', 'a'], + ['steps[1]', 'b'], + ['steps[2]', 'c'], + ]); + }); +}); + +describe('nothing to convert', () => { + it('returns noop for block steps', () => { + const result = convert('steps:\n - name: a\n run: x\n'); + expect(result).toMatchObject({ ok: false, code: 'noop' }); + }); + + it('returns noop for map form', () => { + expect(convert(yamlMapSteps)).toMatchObject({ ok: false, code: 'noop' }); + }); + + it('returns noop without steps', () => { + expect(convert('name: a\n')).toMatchObject({ ok: false, code: 'noop' }); + }); + + it('returns parseError for broken YAML', () => { + expect(convert('steps: [a\n')).toMatchObject({ + ok: false, + code: 'parseError', + }); + }); +}); + +describe('agent DAG', () => { + it('returns agentType', () => { + const text = 'type: agent\nsteps:\n - echo a\n'; + expect(convert(text)).toMatchObject({ ok: false, code: 'agentType' }); + }); +}); + +describe('later documents', () => { + it('keeps the bytes of document 1', () => { + const text = `${yamlMultiDoc.replace('- name: run-child', '- echo first\n - name: run-child')}`; + const out = converted(text); + const tail = text.slice(text.indexOf('\n---\n')); + expect(out.endsWith(tail)).toBe(true); + expect(out.startsWith('name: parent\nsteps:\n - run: echo first\n')).toBe( + true + ); + }); + + it('is not changed when only document 1 has shorthand', () => { + const text = 'steps:\n - name: a\n run: x\n---\nsteps:\n - echo b\n'; + expect(convert(text)).toMatchObject({ ok: false, code: 'noop' }); + }); +}); diff --git a/ui/src/features/visual-editor/doc/__tests__/depends.test.ts b/ui/src/features/visual-editor/doc/__tests__/depends.test.ts new file mode 100644 index 0000000000..49e366be50 --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/depends.test.ts @@ -0,0 +1,324 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import { describe, expect, it } from 'vitest'; +import { isMap, parseDocument } from 'yaml'; +import type { ApplyEditOptions, EditResult } from '../../contracts'; +import { + yamlArraySteps, + yamlMapSteps, + yamlShorthandSteps, +} from '../../testing/fixtures'; +import { + detectDependsStyle, + readDepends, + referenceNameFor, + writeDepends, +} from '../ops/depends'; +import type { DependsStyle } from '../ops/depends'; +import { applyTextEdits, editDocument } from '../patch'; +import { findStep } from '../stepIndex'; + +const FLOW: DependsStyle = { multi: 'flow', singleScalar: false }; +const BLOCK: DependsStyle = { multi: 'block', singleScalar: false }; +const SCALAR: DependsStyle = { multi: 'flow', singleScalar: true }; + +/** Writes `names` as the depends of the step `key` and returns the new text. */ +function write( + text: string, + key: string, + names: string[], + style: DependsStyle +): string { + const result = editDocument(text, (doc) => { + const step = findStep(doc, key); + if (!step || !isMap(step.node)) throw new Error('no step'); + writeDepends(doc, step.node, names, style); + }); + if (!result.ok) throw new Error(`${result.code}: ${result.message}`); + expect(result.text).toBe(applyTextEdits(text, result.edits)); + return result.text; +} + +function reference( + text: string, + key: string, + options: ApplyEditOptions = {} +): { result: EditResult; name?: string } { + let name: string | undefined; + const result = editDocument(text, (doc) => { + const step = findStep(doc, key); + if (!step) throw new Error('no step'); + const ref = referenceNameFor(doc, step, options); + if (!ref.ok) return ref; + name = ref.name; + }); + return { result, name }; +} + +describe('readDepends', () => { + it('reads a scalar', () => { + expect( + readDepends(parseDocument('depends: a').get('depends', true)) + ).toEqual(['a']); + }); + + it('reads a sequence of scalars', () => { + const doc = parseDocument('depends: [a, "b", 3]'); + expect(readDepends(doc.get('depends', true))).toEqual(['a', 'b', '3']); + }); + + it('reads plain values', () => { + expect(readDepends('a')).toEqual(['a']); + expect(readDepends(['a', 'b'])).toEqual(['a', 'b']); + }); + + it('gives an empty list for anything else', () => { + expect(readDepends(undefined)).toEqual([]); + expect(readDepends(null)).toEqual([]); + expect(readDepends(parseDocument('depends:').get('depends', true))).toEqual( + [] + ); + expect( + readDepends(parseDocument('depends: {a: 1}').get('depends', true)) + ).toEqual([]); + }); +}); + +describe('detectDependsStyle', () => { + const detect = (text: string) => detectDependsStyle(parseDocument(text)); + + it('defaults to a flow list', () => { + expect(detect('steps:\n - run: a\n')).toEqual(FLOW); + expect(detect('')).toEqual(FLOW); + }); + + it('reads a flow list in the array form', () => { + expect(detect(yamlArraySteps)).toEqual(FLOW); + }); + + it('reads a flow list in the map form', () => { + expect(detect(yamlMapSteps)).toEqual(FLOW); + }); + + it('prefers the first list of two entries', () => { + const text = `steps: + - name: a + depends: [x] + - name: b + depends: + - x + - y +`; + expect(detect(text)).toEqual(BLOCK); + }); + + it('reads a scalar', () => { + const text = + 'steps:\n - name: a\n run: x\n - name: b\n depends: a\n'; + expect(detect(text)).toEqual(SCALAR); + }); + + it('reads no scalar when a list has one entry', () => { + const text = 'steps:\n - name: b\n depends:\n - a\n'; + expect(detect(text)).toEqual({ multi: 'block', singleScalar: false }); + }); +}); + +describe('writeDepends', () => { + it('adds a flow list after the name', () => { + expect(write(yamlArraySteps, 'steps[0]', ['x', 'y'], FLOW)).toBe( + yamlArraySteps.replace( + ' - name: fetch\n', + ' - name: fetch\n depends: [x, y]\n' + ) + ); + }); + + it('adds a block list', () => { + expect(write(yamlArraySteps, 'steps[0]', ['x', 'y'], BLOCK)).toBe( + yamlArraySteps.replace( + ' - name: fetch\n', + ' - name: fetch\n depends:\n - x\n - y\n' + ) + ); + }); + + it('adds a scalar for one name', () => { + expect(write(yamlArraySteps, 'steps[0]', ['x'], SCALAR)).toBe( + yamlArraySteps.replace( + ' - name: fetch\n', + ' - name: fetch\n depends: x\n' + ) + ); + }); + + it('adds a one-entry list when scalars are not used', () => { + expect(write(yamlMapSteps, 'steps[0]', ['x'], FLOW)).toBe( + yamlMapSteps.replace(' fetch:\n', ' fetch:\n depends: [x]\n') + ); + }); + + it('puts the key after id and description', () => { + const text = + 'steps:\n - run: a\n description: d\n name: n\n id: i\n with: {}\n'; + expect(write(text, 'steps[0]', ['x'], FLOW)).toBe( + 'steps:\n - run: a\n description: d\n name: n\n id: i\n depends: [x]\n with: {}\n' + ); + }); + + it('puts the key first without a name', () => { + expect(write('steps:\n - run: a\n', 'steps[0]', ['x'], FLOW)).toBe( + 'steps:\n - depends: [x]\n run: a\n' + ); + }); + + it('keeps a flow list when it grows', () => { + expect(write(yamlArraySteps, 'steps[1]', ['fetch', 'extra'], BLOCK)).toBe( + yamlArraySteps.replace('depends: [fetch]', 'depends: [fetch, extra]') + ); + }); + + it('keeps a block list when it grows', () => { + expect( + write(yamlArraySteps, 'steps[2]', ['transform', 'fetch'], FLOW) + ).toBe( + yamlArraySteps.replace( + ' - transform\n', + ' - transform\n - fetch\n' + ) + ); + }); + + it('keeps the quotes of a name that stays', () => { + const text = 'steps:\n - name: a\n depends: ["x", y]\n'; + expect(write(text, 'steps[0]', ['y', 'x', 'z'], FLOW)).toBe( + 'steps:\n - name: a\n depends: [y, "x", z]\n' + ); + }); + + it('shrinks a list to one entry and keeps the list', () => { + const text = 'steps:\n - name: a\n depends: [x, y]\n'; + expect(write(text, 'steps[0]', ['y'], SCALAR)).toBe( + 'steps:\n - name: a\n depends: [y]\n' + ); + }); + + it('changes a scalar in place', () => { + const text = 'steps:\n - name: a\n depends: x # up\n'; + expect(write(text, 'steps[0]', ['y'], FLOW)).toBe( + 'steps:\n - name: a\n depends: y # up\n' + ); + }); + + it('turns a scalar into a list for two names', () => { + const text = 'steps:\n - name: a\n depends: x\n'; + expect(write(text, 'steps[0]', ['x', 'y'], FLOW)).toBe( + 'steps:\n - name: a\n depends: [x, y]\n' + ); + }); + + it('keeps the inline comment when a scalar becomes a list', () => { + const text = 'steps:\n - name: a\n depends: x # up\n'; + expect(write(text, 'steps[0]', ['x', 'y'], FLOW)).toBe( + 'steps:\n - name: a\n depends: [x, y] # up\n' + ); + }); + + it('fills an empty depends in place', () => { + const text = 'steps:\n - name: a\n depends:\n run: b\n'; + expect(write(text, 'steps[0]', ['x'], FLOW)).toBe( + 'steps:\n - name: a\n depends: [x]\n run: b\n' + ); + }); + + it('removes the key for an empty list', () => { + expect(write(yamlArraySteps, 'steps[1]', [], FLOW)).toBe( + yamlArraySteps.replace(' depends: [fetch]\n', '') + ); + }); + + it('removes a block list', () => { + expect(write(yamlArraySteps, 'steps[2]', [], FLOW)).toBe( + yamlArraySteps.replace(' depends:\n - transform\n', '') + ); + }); + + it('gives noop for an empty list without the key', () => { + const result = editDocument(yamlArraySteps, (doc) => { + const step = findStep(doc, 'steps[0]'); + if (step && isMap(step.node)) writeDepends(doc, step.node, [], FLOW); + }); + expect(result).toMatchObject({ ok: false, code: 'noop' }); + }); +}); + +describe('referenceNameFor', () => { + it('uses the explicit name', () => { + expect(reference(yamlArraySteps, 'steps[0]').name).toBe('fetch'); + }); + + it('uses the key in the map form', () => { + expect(reference(yamlMapSteps, 'steps[1]').name).toBe('parse'); + }); + + it('uses the id when there is no name', () => { + const text = 'steps:\n - id: first\n run: a\n'; + const { result, name } = reference(text, 'steps[0]'); + expect(name).toBe('first'); + expect(result).toMatchObject({ ok: false, code: 'noop' }); + }); + + it('writes the server name as first key', () => { + const text = 'steps:\n - run: a\n id: first\n - run: b\n'; + const options: ApplyEditOptions = { + names: { byKey: { 'steps[1]': 'cmd_2' }, byName: { cmd_2: 'steps[1]' } }, + }; + const { result, name } = reference(text, 'steps[1]', options); + expect(name).toBe('cmd_2'); + expect(result).toMatchObject({ + ok: true, + text: 'steps:\n - run: a\n id: first\n - name: cmd_2\n run: b\n', + }); + }); + + it('fills an empty name key', () => { + const text = 'steps:\n - name: ""\n run: a\n'; + const options: ApplyEditOptions = { + names: { byKey: { 'steps[0]': 'cmd_1' }, byName: { cmd_1: 'steps[0]' } }, + }; + expect(reference(text, 'steps[0]', options).result).toMatchObject({ + ok: true, + text: 'steps:\n - name: cmd_1\n run: a\n', + }); + }); + + it('refuses an unnamed step without server names', () => { + const text = 'steps:\n - run: a\n'; + expect(reference(text, 'steps[0]').result).toMatchObject({ + ok: false, + code: 'invalidName', + }); + expect( + reference(text, 'steps[0]', { names: { byKey: {}, byName: {} } }).result + ).toMatchObject({ + ok: false, + code: 'invalidName', + }); + }); + + it('refuses a shorthand step', () => { + expect(reference(yamlShorthandSteps, 'steps[0]').result).toMatchObject({ + ok: false, + code: 'unsupportedForm', + }); + }); + + it('refuses a handler', () => { + const text = 'handler_on:\n failure:\n run: a\nsteps:\n - run: b\n'; + expect(reference(text, 'handler_on.failure').result).toMatchObject({ + ok: false, + code: 'unsupportedForm', + }); + }); +}); diff --git a/ui/src/features/visual-editor/doc/__tests__/fields.test.ts b/ui/src/features/visual-editor/doc/__tests__/fields.test.ts new file mode 100644 index 0000000000..ad4939470c --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fields.test.ts @@ -0,0 +1,849 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import { describe, expect, it } from 'vitest'; +import type { DagEditOp, EditResult, StepNameMap } from '../../contracts'; +import { + yamlArraySteps, + yamlHttpJq, + yamlHandlers, + yamlMapSteps, + namesArraySteps, + yamlMultiDoc, + yamlNestedParallel, + yamlRouter, + yamlShorthandSteps, +} from '../../testing/fixtures'; +import { + opRenameStep, + opReplaceStepYaml, + opSetStepField, + opSetStepId, +} from '../ops/fields'; +import { applyTextEdits } from '../patch'; + +type SetField = Extract; + +/** Returns the new text of a successful result and checks that the edits give it. */ +function textOf(input: string, result: EditResult): string { + if (!result.ok) throw new Error(`${result.code}: ${result.message}`); + expect(applyTextEdits(input, result.edits)).toBe(result.text); + return result.text; +} + +function setField( + input: string, + step: string, + path: SetField['path'], + value: unknown +): EditResult { + return opSetStepField(input, { kind: 'setStepField', step, path, value }); +} + +function failure(result: EditResult): string { + if (result.ok) throw new Error('The edit did not fail.'); + return result.code; +} + +describe('opSetStepField', () => { + it('adds a key at the end of the step', () => { + const out = textOf( + yamlArraySteps, + setField(yamlArraySteps, 'steps[0]', ['timeout_sec'], 30) + ); + expect(out).toBe( + yamlArraySteps.replace( + '-o data.csv\n', + '-o data.csv\n timeout_sec: 30\n' + ) + ); + }); + + it('adds a key in map form', () => { + const out = textOf( + yamlMapSteps, + setField(yamlMapSteps, 'steps[0]', ['env', 'A'], 'x y') + ); + expect(out).toBe( + yamlMapSteps.replace( + '-o data.csv\n', + '-o data.csv\n env:\n A: x y\n' + ) + ); + }); + + it('creates the maps of a new path', () => { + const out = textOf( + yamlArraySteps, + setField(yamlArraySteps, 'steps[0]', ['retry_policy', 'limit'], 3) + ); + expect(out).toBe( + yamlArraySteps.replace( + '-o data.csv\n', + '-o data.csv\n retry_policy:\n limit: 3\n' + ) + ); + }); + + it('replaces a list and keeps the sibling order', () => { + const out = textOf( + yamlArraySteps, + setField(yamlArraySteps, 'steps[2]', ['depends'], ['transform', 'fetch']) + ); + expect(out).toBe( + yamlArraySteps.replace( + ' - transform\n', + ' - transform\n - fetch\n' + ) + ); + }); + + it('replaces a string with a block string', () => { + const out = textOf( + yamlArraySteps, + setField(yamlArraySteps, 'steps[0]', ['run'], 'a\nb\n') + ); + expect(out).toBe( + yamlArraySteps.replace( + ' run: curl -sSf https://example.com/data.csv -o data.csv\n', + ' run: |\n a\n b\n' + ) + ); + }); + + it('removes a key', () => { + const out = textOf( + yamlArraySteps, + setField(yamlArraySteps, 'steps[1]', ['depends'], undefined) + ); + expect(out).toBe(yamlArraySteps.replace(' depends: [fetch]\n', '')); + }); + + it('removes the maps that the removal leaves empty', () => { + const input = 'steps:\n - name: a\n run: x\n env:\n A: "1"\n'; + expect( + textOf(input, setField(input, 'steps[0]', ['env', 'A'], undefined)) + ).toBe('steps:\n - name: a\n run: x\n'); + }); + + it('keeps a list that the removal leaves empty', () => { + const input = 'steps:\n - name: a\n with:\n args: [x]\n'; + expect( + textOf(input, setField(input, 'steps[0]', ['with', 'args', 0], undefined)) + ).toBe('steps:\n - name: a\n with:\n args: []\n'); + }); + + it('edits a handler', () => { + const out = textOf( + yamlHandlers, + setField(yamlHandlers, 'handler_on.success', ['run'], 'echo done') + ); + expect(out).toBe(yamlHandlers.replace('run: echo ok', 'run: echo done')); + }); + + it('fills an empty entry', () => { + const input = 'steps:\n a:\n b:\n run: x\n'; + expect(textOf(input, setField(input, 'steps[0]', ['run'], 'echo a'))).toBe( + 'steps:\n a:\n run: echo a\n b:\n run: x\n' + ); + }); + + it('does not touch a later document', () => { + const out = textOf( + yamlMultiDoc, + setField(yamlMultiDoc, 'steps[0]', ['timeout_sec'], 5) + ); + const tail = yamlMultiDoc.slice(yamlMultiDoc.indexOf('---')); + expect(out.endsWith(tail)).toBe(true); + expect(out).toContain('timeout_sec: 5'); + }); + + it('gives noop for the same value', () => { + expect( + failure( + setField(yamlArraySteps, 'steps[2]', ['run'], './publish.sh sorted.csv') + ) + ).toBe('noop'); + }); + + it('gives noop when the key is absent', () => { + expect( + failure(setField(yamlArraySteps, 'steps[0]', ['env', 'A'], undefined)) + ).toBe('noop'); + expect( + failure(setField(yamlArraySteps, 'steps[0]', ['timeout_sec'], undefined)) + ).toBe('noop'); + }); + + it('gives unknownStep', () => { + expect(failure(setField(yamlArraySteps, 'steps[9]', ['run'], 'x'))).toBe( + 'unknownStep' + ); + }); + + it('gives unsupportedForm for a plain string entry', () => { + const result = setField(yamlShorthandSteps, 'steps[0]', ['run'], 'x'); + expect(failure(result)).toBe('unsupportedForm'); + expect(result.ok ? '' : result.message).toContain( + 'convertStepsToExplicitForm' + ); + }); + + it('gives unsupportedForm for an empty path', () => { + expect(failure(setField(yamlArraySteps, 'steps[0]', [], 'x'))).toBe( + 'unsupportedForm' + ); + }); + + it('gives invalidYaml when the path crosses a scalar', () => { + expect(failure(setField(yamlArraySteps, 'steps[0]', ['run', 'x'], 1))).toBe( + 'invalidYaml' + ); + }); + + it('gives parseError for broken YAML', () => { + expect(failure(setField('steps: [a', 'steps[0]', ['run'], 'x'))).toBe( + 'parseError' + ); + }); +}); + +describe('opSetStepField quote style', () => { + const styled = `steps: + - name: a + run: 'echo one' # single + env: + DQ: "two" + PLAIN: three + with: + script: | + line1 + line2 + folded: >- + text here +`; + + it('keeps single quotes', () => { + expect( + textOf(styled, setField(styled, 'steps[0]', ['run'], 'echo uno')) + ).toBe(styled.replace("'echo one'", "'echo uno'")); + }); + + it('keeps double quotes', () => { + expect( + textOf(styled, setField(styled, 'steps[0]', ['env', 'DQ'], 'zwei')) + ).toBe(styled.replace('"two"', '"zwei"')); + }); + + it('keeps plain style', () => { + expect( + textOf(styled, setField(styled, 'steps[0]', ['env', 'PLAIN'], 'drei')) + ).toBe(styled.replace('three', 'drei')); + }); + + it('keeps a literal block', () => { + expect( + textOf( + styled, + setField(styled, 'steps[0]', ['with', 'script'], 'a\nb\nc\n') + ) + ).toBe( + styled.replace( + ' line1\n line2\n', + ' a\n b\n c\n' + ) + ); + }); + + it('keeps a folded block', () => { + expect( + textOf(styled, setField(styled, 'steps[0]', ['with', 'folded'], 'other')) + ).toBe(styled.replace('text here', 'other')); + }); + + it('keeps single quotes when the text has a quote mark', () => { + expect( + textOf(styled, setField(styled, 'steps[0]', ['run'], "echo 'x'")) + ).toBe(styled.replace("'echo one'", "'echo ''x'''")); + }); + + it('keeps the quotes of a jq filter', () => { + expect( + textOf( + yamlHttpJq, + setField(yamlHttpJq, 'steps[1]', ['with', 'filter'], '[.[].id]') + ) + ).toBe(yamlHttpJq.replace("'[.[].name]'", "'[.[].id]'")); + }); + + it('replaces a number by a string', () => { + const input = 'steps:\n - name: a\n run: x\n timeout_sec: 30\n'; + expect( + textOf(input, setField(input, 'steps[0]', ['timeout_sec'], '${T}')) + ).toBe('steps:\n - name: a\n run: x\n timeout_sec: ${T}\n'); + }); + + it('keeps a trailing comment', () => { + const input = 'steps:\n - name: a\n run: old # why\n'; + expect(textOf(input, setField(input, 'steps[0]', ['run'], 'new'))).toBe( + 'steps:\n - name: a\n run: new # why\n' + ); + }); +}); + +describe('opReplaceStepYaml', () => { + type Replace = Extract; + const replace = (input: string, step: string, yaml: string): EditResult => + opReplaceStepYaml(input, { + kind: 'replaceStepYaml', + step, + yaml, + } as Replace); + const two = + 'name: fetch\nrun: curl -s https://x\nretry_policy:\n limit: 2\n'; + + it('replaces the first step and keeps the comment above', () => { + expect( + textOf(yamlArraySteps, replace(yamlArraySteps, 'steps[0]', two)) + ).toBe( + yamlArraySteps.replace( + ' run: curl -sSf https://example.com/data.csv -o data.csv\n', + ' run: curl -s https://x\n retry_policy:\n limit: 2\n' + ) + ); + }); + + it('keeps the comment and the blank line above a later step', () => { + expect( + textOf(yamlArraySteps, replace(yamlArraySteps, 'steps[2]', two)) + ).toBe( + yamlArraySteps.replace( + ' - name: publish\n depends:\n - transform\n run: ./publish.sh sorted.csv\n', + ' - name: fetch\n run: curl -s https://x\n retry_policy:\n limit: 2\n' + ) + ); + }); + + it('replaces the value of a map entry and keeps the key', () => { + expect( + textOf(yamlMapSteps, replace(yamlMapSteps, 'steps[1]', 'run: x')) + ).toBe( + yamlMapSteps.replace( + ' depends: [fetch]\n run: wc -l data.csv\n', + ' run: x\n' + ) + ); + }); + + it('replaces a plain string entry by a mapping', () => { + expect( + textOf( + yamlShorthandSteps, + replace(yamlShorthandSteps, 'steps[1]', 'name: two\nrun: echo 2') + ) + ).toBe( + yamlShorthandSteps.replace( + ' - echo two # shorthand step\n', + ' - name: two\n run: echo 2\n' + ) + ); + }); + + it('replaces a handler', () => { + expect( + textOf( + yamlHandlers, + replace(yamlHandlers, 'handler_on.failure', 'run: echo f') + ) + ).toBe( + yamlHandlers.replace( + ' # Notify on failure.\n run: ./notify.sh\n', + ' run: echo f\n' + ) + ); + }); + + it('replaces an entry of a parallel group', () => { + expect( + textOf( + yamlNestedParallel, + replace(yamlNestedParallel, 'steps[2]', 'name: unit\nrun: x') + ) + ).toBe(yamlNestedParallel.replace('run: make test', 'run: x')); + }); + + it('does not touch a later document', () => { + const out = textOf( + yamlMultiDoc, + replace(yamlMultiDoc, 'steps[0]', 'name: run-child\nrun: echo x') + ); + expect(out.endsWith(yamlMultiDoc.slice(yamlMultiDoc.indexOf('---')))).toBe( + true + ); + expect(out).toContain('run: echo x'); + }); + + it('gives noop for the same step', () => { + const same = + 'name: publish\ndepends:\n - transform\nrun: ./publish.sh sorted.csv\n'; + expect(failure(replace(yamlArraySteps, 'steps[2]', same))).toBe('noop'); + }); + + it('gives invalidYaml for a syntax error', () => { + const result = replace(yamlArraySteps, 'steps[0]', 'a: ['); + expect(failure(result)).toBe('invalidYaml'); + expect(result.ok ? '' : result.message).toBe( + 'Flow sequence in block collection must be sufficiently indented and end with a ] at line 1, column 5' + ); + }); + + it.each(['just text', '', '- a\n- b\n'])( + 'rejects %j as no mapping', + (yaml) => { + const result = replace(yamlArraySteps, 'steps[0]', yaml); + expect(failure(result)).toBe('invalidYaml'); + expect(result.ok ? '' : result.message).toBe( + 'The step YAML must be one mapping.' + ); + } + ); + + it('rejects two documents', () => { + expect( + failure(replace(yamlArraySteps, 'steps[0]', 'a: 1\n---\nb: 2\n')) + ).toBe('invalidYaml'); + }); + + it('gives unknownStep', () => { + expect(failure(replace(yamlArraySteps, 'steps[7]', 'run: x'))).toBe( + 'unknownStep' + ); + }); +}); + +describe('opRenameStep', () => { + type Rename = Extract; + const rename = ( + input: string, + step: string, + newName: string, + names?: StepNameMap + ): EditResult => + opRenameStep( + input, + { kind: 'renameStep', step, newName } as Rename, + names ? { names } : {} + ); + const code = (r: EditResult): string => failure(r); + + it('renames in array form and updates a flow depends list', () => { + expect( + textOf(yamlArraySteps, rename(yamlArraySteps, 'steps[0]', 'download')) + ).toBe( + yamlArraySteps + .replace('- name: fetch', '- name: download') + .replace('depends: [fetch]', 'depends: [download]') + ); + }); + + it('updates a block depends list', () => { + expect( + textOf(yamlArraySteps, rename(yamlArraySteps, 'steps[1]', 'convert')) + ).toBe( + yamlArraySteps + .replace('- name: transform', '- name: convert') + .replace(' - transform\n', ' - convert\n') + ); + }); + + it('renames the key in map form and selects the new key', () => { + const result = rename(yamlMapSteps, 'steps[0]', 'zeta'); + expect(textOf(yamlMapSteps, result)).toBe( + yamlMapSteps + .replace(' fetch:\n', ' zeta:\n') + .replace('depends: [fetch]', 'depends: [zeta]') + ); + expect(result.ok && result.select).toBe('steps[1]'); + }); + + it('updates the targets of a router', () => { + expect(textOf(yamlRouter, rename(yamlRouter, 'steps[1]', 'srv_err'))).toBe( + yamlRouter + .replace('[server_error]', '[srv_err]') + .replace('- name: server_error', '- name: srv_err') + ); + }); + + it('updates the targets of a legacy routes field', () => { + const input = + 'steps:\n - name: pick\n routes:\n x: [a, b]\n y: a\n - name: a\n run: x\n - name: b\n run: y\n'; + expect(textOf(input, rename(input, 'steps[1]', 'c'))).toBe( + input + .replace('[a, b]', '[c, b]') + .replace('y: a', 'y: c') + .replace('- name: a', '- name: c') + ); + }); + + // The server reads a number in a list of names as its text. + it('updates a number in a depends list', () => { + const input = + 'steps:\n - name: "1"\n run: x\n - name: b\n depends: [1]\n run: y\n'; + const out = textOf(input, rename(input, 'steps[0]', 'first')); + expect(out).toContain('depends: [first]'); + }); + + // The server trims a name, so `depends` names it without the blanks. + it('updates the depends of a name with blanks around it', () => { + const input = + 'steps:\n - name: " build "\n run: x\n - name: b\n depends: build\n run: y\n'; + const out = textOf(input, rename(input, 'steps[0]', 'compile')); + expect(out).toContain('depends: compile'); + }); + + it('keeps the quote style of a scalar depends', () => { + const input = + 'steps:\n - name: a\n run: x\n - name: b\n depends: "a"\n run: y\n - name: c\n depends: \'a\'\n run: z\n'; + expect(textOf(input, rename(input, 'steps[0]', 'd'))).toBe( + input + .replace('- name: a', '- name: d') + .replace('depends: "a"', 'depends: "d"') + .replace("depends: 'a'", "depends: 'd'") + ); + }); + + it('updates the rewind targets', () => { + const input = `steps: + - name: a + run: x + - name: gate + depends: a + approval: + prompt: ok + rewind_to: a + - name: task + depends: [a] + action: human.task + with: + push_back: + rewind_to: a +`; + expect(textOf(input, rename(input, 'steps[0]', 'b'))).toBe( + input + .replace('- name: a', '- name: b') + .replace('depends: a', 'depends: b') + .replace('rewind_to: a\n - name: task', 'rewind_to: b\n - name: task') + .replace('depends: [a]', 'depends: [b]') + .replace(' rewind_to: a\n', ' rewind_to: b\n') + ); + }); + + it('keeps references that resolve through the id', () => { + expect(textOf(yamlHttpJq, rename(yamlHttpJq, 'steps[0]', 'load'))).toBe( + yamlHttpJq.replace(' name: fetch\n', ' name: load\n') + ); + }); + + it('keeps tokens, text and other namespaces', () => { + const input = + 'steps:\n - name: a\n run: echo a ${steps.a.outputs.x} ${a.stdout} ${params.a}\n - name: b\n depends: [a]\n run: echo a\n'; + expect(textOf(input, rename(input, 'steps[0]', 'z'))).toBe( + input + .replace('- name: a', '- name: z') + .replace('depends: [a]', 'depends: [z]') + ); + }); + + it('inserts name first for an unnamed step and keeps the comment', () => { + const input = + 'steps:\n # Say hi.\n - run: echo hi\n\n - name: b\n depends: [cmd_1]\n run: x\n'; + const names: StepNameMap = { + byKey: { 'steps[0]': 'cmd_1', 'steps[1]': 'b' }, + byName: { cmd_1: 'steps[0]', b: 'steps[1]' }, + }; + expect(textOf(input, rename(input, 'steps[0]', 'hi', names))).toBe( + 'steps:\n # Say hi.\n - name: hi\n run: echo hi\n\n - name: b\n depends: [hi]\n run: x\n' + ); + }); + + it('keeps depends that name the id of an unnamed step', () => { + const input = + 'steps:\n - id: one\n run: x\n - name: b\n depends: [one]\n run: y\n'; + expect(textOf(input, rename(input, 'steps[0]', 'first'))).toBe( + input.replace(' - id: one\n', ' - name: first\n id: one\n') + ); + }); + + it('renames a handler without touching steps', () => { + expect( + textOf(yamlHandlers, rename(yamlHandlers, 'handler_on.success', 'ok')) + ).toBe(yamlHandlers.replace(' success:\n', ' success:\n name: ok\n')); + }); + + it('works on the names that the server gives', () => { + expect( + textOf( + yamlArraySteps, + rename(yamlArraySteps, 'steps[2]', 'ship', namesArraySteps) + ) + ).toBe(yamlArraySteps.replace('- name: publish', '- name: ship')); + }); + + it('does not touch a later document', () => { + const out = textOf(yamlMultiDoc, rename(yamlMultiDoc, 'steps[0]', 'go')); + expect(out.endsWith(yamlMultiDoc.slice(yamlMultiDoc.indexOf('---')))).toBe( + true + ); + expect(out).toContain('- name: go'); + }); + + it('gives invalidName', () => { + expect(code(rename(yamlArraySteps, 'steps[0]', ' x'))).toBe('invalidName'); + expect(code(rename(yamlArraySteps, 'steps[0]', ''))).toBe('invalidName'); + }); + + it('gives duplicateName for a name and for an id', () => { + expect(code(rename(yamlArraySteps, 'steps[0]', 'publish'))).toBe( + 'duplicateName' + ); + expect(code(rename(yamlHttpJq, 'steps[0]', 'names'))).toBe('duplicateName'); + expect(code(rename(yamlMapSteps, 'steps[0]', 'parse'))).toBe( + 'duplicateName' + ); + }); + + it('gives noop for the same name', () => { + expect(code(rename(yamlArraySteps, 'steps[0]', 'fetch'))).toBe('noop'); + }); + + it('gives unsupportedForm for a plain string and a group entry', () => { + const plain = rename(yamlShorthandSteps, 'steps[0]', 'x'); + expect(code(plain)).toBe('unsupportedForm'); + expect(plain.ok ? '' : plain.message).toContain( + 'convertStepsToExplicitForm' + ); + expect(code(rename(yamlNestedParallel, 'steps[1]', 'x'))).toBe( + 'unsupportedForm' + ); + }); + + it('gives unknownStep', () => { + expect(code(rename(yamlArraySteps, 'steps[8]', 'x'))).toBe('unknownStep'); + }); +}); + +describe('opSetStepId', () => { + type SetId = Extract; + const setId = ( + input: string, + step: string, + id: string | null, + names?: StepNameMap + ): EditResult => + opSetStepId( + input, + { kind: 'setStepId', step, id } as SetId, + names ? { names } : {} + ); + const message = (r: EditResult): string => (r.ok ? '' : r.message); + + it('rewrites the tokens of other steps', () => { + expect(textOf(yamlHttpJq, setId(yamlHttpJq, 'steps[0]', 'load'))).toBe( + yamlHttpJq + .replace(' - id: fetch\n', ' - id: load\n') + .replace('${steps.fetch.outputs.body}', '${steps.load.outputs.body}') + ); + }); + + it('leaves similar text unchanged', () => { + const input = `steps: + - id: fetch + name: get + run: echo fetch \${stepsx} \${steps.fetch2.outputs.a} \${fetch2.stdout} + - name: use + run: | + echo "\${steps.fetch.outputs.a}" \${fetch.stdout} \${params.fetch} + echo fetch \\\${steps.fetch.outputs.a} + env: + A: '\${steps.fetch.outputs.b}' + B: "\${steps.fetch.outputs.c}" +`; + expect(textOf(input, setId(input, 'steps[0]', 'load'))).toBe( + input + .replace(' - id: fetch\n', ' - id: load\n') + .replace( + 'echo "${steps.fetch.outputs.a}" ${fetch.stdout}', + 'echo "${steps.load.outputs.a}" ${load.stdout}' + ) + .replace("'${steps.fetch.outputs.b}'", "'${steps.load.outputs.b}'") + .replace('"${steps.fetch.outputs.c}"', '"${steps.load.outputs.c}"') + ); + }); + + it('rewrites tokens in handlers', () => { + const input = + 'handler_on:\n failure:\n run: echo ${fetch.exit_code}\nsteps:\n - id: fetch\n run: x\n'; + expect(textOf(input, setId(input, 'steps[0]', 'get'))).toBe( + input + .replace('${fetch.exit_code}', '${get.exit_code}') + .replace('- id: fetch', '- id: get') + ); + }); + + it('keeps depends that name the step by its name', () => { + expect(textOf(yamlHttpJq, setId(yamlHttpJq, 'steps[0]', 'load'))).toContain( + 'depends: [fetch]' + ); + }); + + it('rewrites depends and routes of a step that takes its name from the id', () => { + const input = `steps: + - id: one + run: echo + - name: b + depends: [one] + run: x \${steps.one.outputs.a} + - name: pick + action: router.route + with: + value: v + routes: + x: [one] +`; + expect(textOf(input, setId(input, 'steps[0]', 'uno'))).toBe( + input + .replace('- id: one', '- id: uno') + .replace('depends: [one]', 'depends: [uno]') + .replace('steps.one.', 'steps.uno.') + .replace('x: [one]', 'x: [uno]') + ); + }); + + it('adds an id after name', () => { + expect( + textOf(yamlArraySteps, setId(yamlArraySteps, 'steps[1]', 'conv')) + ).toBe( + yamlArraySteps.replace( + ' - name: transform # keep the header row\n', + ' - name: transform # keep the header row\n id: conv\n' + ) + ); + }); + + it('adds an id first when the name is unknown', () => { + const input = 'steps:\n - run: x\n env:\n A: b\n'; + expect(textOf(input, setId(input, 'steps[0]', 'first'))).toBe( + 'steps:\n - id: first\n run: x\n env:\n A: b\n' + ); + }); + + it('adds an id in map form', () => { + expect(textOf(yamlMapSteps, setId(yamlMapSteps, 'steps[0]', 'f'))).toBe( + yamlMapSteps.replace( + ' fetch:\n run:', + ' fetch:\n id: f\n run:' + ) + ); + }); + + it('keeps the name of an unnamed step when it gets an id', () => { + const input = + 'steps:\n # Say hi.\n - run: echo hi\n\n - name: b\n depends: [cmd_1]\n run: x\n'; + const names: StepNameMap = { + byKey: { 'steps[0]': 'cmd_1', 'steps[1]': 'b' }, + byName: { cmd_1: 'steps[0]', b: 'steps[1]' }, + }; + expect(textOf(input, setId(input, 'steps[0]', 'hi', names))).toBe( + 'steps:\n # Say hi.\n - name: cmd_1\n id: hi\n run: echo hi\n\n - name: b\n depends: [cmd_1]\n run: x\n' + ); + }); + + it('removes an id', () => { + expect(textOf(yamlHttpJq, setId(yamlHttpJq, 'steps[1]', null))).toBe( + yamlHttpJq.replace( + ' - id: names\n name: extract\n', + ' - name: extract\n' + ) + ); + }); + + it('writes the name when it removes the id of an unnamed step', () => { + const input = + 'steps:\n - id: one\n run: echo\n - name: b\n depends: [one]\n run: x\n'; + expect(textOf(input, setId(input, 'steps[0]', null))).toBe( + input.replace(' - id: one\n', ' - name: one\n') + ); + }); + + it('rewrites depends of the id to the name when it removes the id', () => { + const input = `steps: + - id: one + name: First + run: echo + - name: b + depends: [one] + run: x +`; + expect(textOf(input, setId(input, 'steps[0]', null))).toBe( + input + .replace(' - id: one\n name: First\n', ' - name: First\n') + .replace('depends: [one]', 'depends: [First]') + ); + }); + + it('refuses to remove an id that a token still uses', () => { + const result = setId(yamlHttpJq, 'steps[0]', null); + expect(failure(result)).toBe('invalidName'); + expect(message(result)).toBe( + 'Step "extract" still uses references to the ID "fetch". Change them first.' + ); + }); + + it('does not touch a later document', () => { + const out = textOf(yamlMultiDoc, setId(yamlMultiDoc, 'steps[0]', 'rc')); + expect(out.endsWith(yamlMultiDoc.slice(yamlMultiDoc.indexOf('---')))).toBe( + true + ); + expect(out).toContain(' id: rc\n'); + }); + + it('gives invalidName for a bad id', () => { + expect(failure(setId(yamlHttpJq, 'steps[0]', 'a-b'))).toBe('invalidName'); + expect(failure(setId(yamlHttpJq, 'steps[0]', 'Env'))).toBe('invalidName'); + expect(failure(setId(yamlHttpJq, 'steps[0]', ''))).toBe('invalidName'); + }); + + it('gives duplicateName for an id and for a name of another step', () => { + expect(failure(setId(yamlHttpJq, 'steps[0]', 'names'))).toBe( + 'duplicateName' + ); + expect(failure(setId(yamlHttpJq, 'steps[0]', 'extract'))).toBe( + 'duplicateName' + ); + }); + + it('accepts the name of the same step as id', () => { + const input = 'steps:\n - name: build\n run: x\n'; + expect(textOf(input, setId(input, 'steps[0]', 'build'))).toBe( + 'steps:\n - name: build\n id: build\n run: x\n' + ); + }); + + it('gives noop for the same id and for no id', () => { + expect(failure(setId(yamlHttpJq, 'steps[0]', 'fetch'))).toBe('noop'); + expect(failure(setId(yamlArraySteps, 'steps[0]', null))).toBe('noop'); + }); + + it('gives unsupportedForm for a plain string and a group entry', () => { + expect(failure(setId(yamlShorthandSteps, 'steps[0]', 'x'))).toBe( + 'unsupportedForm' + ); + expect(failure(setId(yamlNestedParallel, 'steps[1]', 'x'))).toBe( + 'unsupportedForm' + ); + }); + + it('gives unknownStep', () => { + expect(failure(setId(yamlArraySteps, 'steps[8]', 'x'))).toBe('unknownStep'); + }); +}); diff --git a/ui/src/features/visual-editor/doc/__tests__/golden.test.ts b/ui/src/features/visual-editor/doc/__tests__/golden.test.ts new file mode 100644 index 0000000000..0d9c2fb73c --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/golden.test.ts @@ -0,0 +1,521 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +/* + * Round-trip golden tests of the patch engine. + * + * Fixture sources. Paths are relative to the repository root. A "spec block" + * is the fenced yaml block of a file in specs/ that starts on the given line. + * "hand-written" files are made for this test. + * + * anchors-aliases.yaml hand-written: anchors, aliases, merge keys + * block-scalars.yaml hand-written: |, |+, |-, >, >-, explicit indent + * browser-flow-maps.yaml conformance/spec072_browser/testdata/checkout.yaml + * build-comments.yaml conformance/spec033_build/testdata/build.yaml + * chain-router.yaml conformance/spec040_router_route/testdata/chain_type_rejected.yaml + * comments-everywhere.yaml hand-written: comments at many positions + * config-cli.yaml internal/testdata/cli/config_test.yaml + * embedded-custom-executor.yaml examples/embedded/custom-executor/workflow.yaml + * embedded-local.yaml examples/embedded/local/workflow.yaml + * empty-file.yaml internal/testdata/config/empty.yaml + * flow-collections.yaml hand-written: flow maps and sequences, multi-line flow + * flow-maps-unicode.yaml conformance/spec074_computer/testdata/pinned.yaml + * folded-line-break.yaml conformance/spec014_step_run_command/testdata/resolved_line_break.yaml + * foreach-string-items.yaml conformance/spec018_parallel_foreach/testdata/foreach_string_items.yaml + * handler-env.yaml conformance/spec005_params/testdata/handler_env.yaml + * handlers-chain.yaml hand-written: type chain, all handler_on events, shorthand + * harness-acp.yaml examples/harness-acp.yaml + * invalid-yaml.yaml conformance/spec002_schema/testdata/invalid_yaml.yaml + * map-steps-forbidden.yaml conformance/spec002_schema/testdata/steps_mapping_forbidden.yaml + * map-steps.yaml hand-written: map-form steps with comments + * multidoc-comments-markers.yaml hand-written: --- and ... markers, comments before markers + * multidoc-empty-between.yaml conformance/spec002_schema/testdata/empty_document_between.yaml + * multidoc-subdag.yaml conformance/cli/testdata/parent_child.yaml + * multidoc-unnamed-later.yaml conformance/spec002_schema/testdata/later_document_without_name.yaml + * nested-array-run.yaml conformance/spec013_step_run/testdata/array_form_nested_array_item_invalid.yaml + * nested-parallel-groups.yaml hand-written: parallel groups as nested arrays + * odd-indentation.yaml hand-written: "- key" items, extra spaces after colons + * quoted-keys-and-values.yaml hand-written: quoted keys, escapes, complex keys, tags + * router-numeric-band.yaml conformance/spec040_router_route/testdata/shared_target_numeric_band.yaml + * router-regex-keys.yaml conformance/spec040_router_route/testdata/regex_route.yaml + * run-context.yaml conformance/spec017_built_in_run_context/testdata/runtime_context.yaml + * scheduler-cron.yaml internal/testdata/scheduler/cron-test.yaml + * script-utf8-multiline.yaml conformance/spec015_step_run_script/testdata/powershell_utf8_multiline.yaml + * shorthand-steps.yaml hand-written: shorthand string steps in several quote styles + * spec-harness-comments.yaml spec block: specs/067-harness.md, line 304 + * spec-outputs-heredoc.yaml spec block: specs/012-step-outputs.md, line 218 + * spec-subdag-parallel.yaml spec block: specs/019-sub-dag-working-directory.md, line 151 + * spec-xlsx-convert.yaml spec block: specs/077-xlsx.md, line 1211 + * unicode-comments.yaml hand-written: non-ASCII comments, names and values + * xlsx-japanese.yaml conformance/spec077_xlsx/testdata/extract_japanese.yaml + */ + +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + isMap, + isScalar, + isSeq, + parseAllDocuments, + type Document, + type YAMLMap, +} from 'yaml'; +import { applyEdit } from '../index'; +import { parseDagDoc } from '../parse'; +import { documentZeroEnd, editDocumentDetailed } from '../patch'; +import { + yamlArraySteps, + yamlBroken, + yamlChain, + yamlHandlers, + yamlHttpJq, + yamlMapSteps, + yamlMultiDoc, + yamlNestedParallel, + yamlRouter, + yamlShorthandSteps, +} from '../../testing/fixtures'; + +/** + * Files for which a part of the probe (c) is skipped, with the reason. The + * key is the path relative to the repository root, or `fixtures/`. + * `probe` skips the whole probe; `removal` skips only the byte-for-byte + * check after the removal of the probe key; `edits` skips the extra probes. + */ +const PROBE_LIMITS: Record< + string, + { skip: 'probe' | 'removal' | 'edits'; reason: string } +> = { + 'fixtures/odd-indentation.yaml': { + skip: 'edits', + reason: + 'the emitter cannot write "- key" items, so an edit of a scalar rewrites the whole document 0 (fallback)', + }, + 'fixtures/comments-everywhere.yaml': { + skip: 'removal', + reason: + 'yaml attaches the blank line before a trailing comment of a map to the last key, so the blank line leaves with the removed key', + }, +}; + +const stats = { probed: 0, opProbed: 0 }; + +const FIXTURE_DIR = path.resolve( + 'src/features/visual-editor/doc/__tests__/fixtures' +); +const CORPUS_DIRS = ['examples', 'internal', 'conformance']; + +type Path = (string | number)[]; + +/** Path of the first step entry that is a mapping: an array item or a map-form value. */ +function firstStepPath(doc: Document): Path | null { + const root = doc.contents; + if (!isMap(root)) return null; + const steps = root.get('steps', true); + if (isSeq(steps)) { + const index = steps.items.findIndex((item) => isMap(item)); + return index < 0 ? null : ['steps', index]; + } + if (isMap(steps)) { + for (const pair of steps.items) { + if (isMap(pair.value) && pair.key !== null) { + const key = (pair.key as { value?: unknown }).value; + if (typeof key === 'string') return ['steps', key]; + } + } + } + return null; +} + +function lineStartOf(text: string, offset: number): number { + return text.lastIndexOf('\n', offset - 1) + 1; +} + +function lineEndOf(text: string, offset: number): number { + const nl = text.indexOf('\n', offset); + return nl < 0 ? text.length : nl + 1; +} + +/** Problems of the `x_probe` check (c) for one parsed text; empty when the check holds. */ +function probeProblems( + text: string, + doc: Document, + skipRemoval: boolean +): string[] { + const at = firstStepPath(doc); + if (!at) return []; + const entry = doc.getIn(at, true) as + | { range?: [number, number, number] } + | undefined; + if (!entry?.range) return []; + stats.probed++; + const windowFrom = lineStartOf(text, entry.range[0]); + const windowTo = lineEndOf( + text, + Math.max(entry.range[1], entry.range[2] - 1) + ); + + const added = editDocumentDetailed(text, (d) => { + (d.getIn(at, true) as { set(k: string, v: unknown): void }).set( + 'x_probe', + 1 + ); + }); + if (!added.result.ok) { + return [`probe edit failed: ${added.result.code} ${added.result.message}`]; + } + const problems: string[] = []; + const { edits, text: changed } = added.result; + if (added.fallback) + problems.push('probe edit used the whole-region fallback'); + for (const edit of edits) { + if (edit.from < windowFrom || edit.to > windowTo) { + problems.push( + `probe edit ${edit.from}-${edit.to} is outside the step entry ${windowFrom}-${windowTo}` + ); + } + } + if (!changed.includes('x_probe')) + problems.push('probe key is missing from the result'); + + const removed = editDocumentDetailed(changed, (d) => { + (d.getIn(at, true) as { delete(k: string): void }).delete('x_probe'); + }); + if (!removed.result.ok) { + problems.push( + `probe removal failed: ${removed.result.code} ${removed.result.message}` + ); + } else if (skipRemoval) { + // Limit of yaml, see PROBE_LIMITS. + } else if (removed.result.text !== text) { + problems.push('removing the probe does not give back the original text'); + } else if (removed.fallback) { + problems.push('probe removal used the whole-region fallback'); + } + return problems; +} + +/** + * Extra probes on the first step entry: change the first scalar value, delete + * the last key, and add then remove a step. The edits must stay inside the + * entry (the new step goes to the end of the steps) and must not need the + * whole-region fallback. + */ +function editProbeProblems(text: string, doc: Document): string[] { + const at = firstStepPath(doc); + if (!at) return []; + const entry = doc.getIn(at, true) as YAMLMap | undefined; + if (!entry?.range || entry.items.length < 2) return []; + const windowFrom = lineStartOf(text, entry.range[0]); + const windowTo = lineEndOf( + text, + Math.max(entry.range[1], entry.range[2] - 1) + ); + const problems: string[] = []; + const check = ( + label: string, + outcome: ReturnType + ) => { + if (!outcome.result.ok) { + problems.push( + `${label}: ${outcome.result.code} ${outcome.result.message}` + ); + return; + } + if (outcome.fallback) problems.push(`${label}: whole-region fallback`); + for (const edit of outcome.result.edits) { + if (edit.from < windowFrom || edit.to > windowTo) { + problems.push( + `${label}: edit ${edit.from}-${edit.to} outside ${windowFrom}-${windowTo}` + ); + } + } + }; + const scalar = entry.items.find( + (pair) => isScalar(pair.value) && typeof pair.value.value === 'string' + ); + if (scalar) { + const key = (scalar.key as { value?: unknown }).value as string; + check( + 'change scalar', + editDocumentDetailed(text, (d) => { + (d.getIn(at, true) as YAMLMap).set(key, 'x_changed'); + }) + ); + } + const last = entry.items[entry.items.length - 1]; + const lastKey = (last?.key as { value?: unknown } | undefined)?.value; + if (typeof lastKey === 'string') { + check( + 'delete key', + editDocumentDetailed(text, (d) => { + (d.getIn(at, true) as YAMLMap).delete(lastKey); + }) + ); + } + return problems; +} + +/** Checks of `parseDagDoc` (d) on one text; `docs` is `parseAllDocuments(text)`. */ +function modelProblems(text: string, docs: Document[]): string[] { + let model: ReturnType; + try { + model = parseDagDoc(text); + } catch (error) { + return [`parseDagDoc threw: ${String(error)}`]; + } + const problems: string[] = []; + const broken = !!docs[0] && docs[0].errors.length > 0; + if (model.ok === broken) { + problems.push( + `model.ok is ${model.ok} but document 0 has ${broken ? '' : 'no '}errors` + ); + } + if (model.documentCount !== docs.length) { + problems.push( + `documentCount ${model.documentCount}, yaml gives ${docs.length}` + ); + } + if (broken) return problems; + + const end = documentZeroEnd(text, docs); + const keys = new Set(); + for (const step of [...model.steps, ...model.handlers]) { + if (keys.has(step.key)) problems.push(`duplicate step key ${step.key}`); + keys.add(step.key); + const { from, to } = step.range; + if ( + from < 0 || + to > end || + from >= to || + text.slice(from, to).trim() === '' + ) { + problems.push( + `${step.key}: range ${from}-${to} is empty or outside document 0 (${end})` + ); + } + } + model.steps.forEach((step, i) => { + if (step.key !== `steps[${i}]` || step.index !== i) { + problems.push( + `model.steps[${i}] has key ${step.key} and index ${step.index}` + ); + } + }); + return problems; +} + +/** + * Check (e): `applyEdit` `setStepField` on the first step of the model adds + * `x_probe` with edits that stay on the lines of that step entry, and the + * removal of the field gives back the original text. + */ +function opProbeProblems(text: string, skipRemoval: boolean): string[] { + const step = parseDagDoc(text).steps[0]; + if (!step || step.form === 'shorthand' || step.value === null) return []; + if (typeof step.value !== 'object' || 'x_probe' in step.value) return []; + const added = applyEdit(text, { + kind: 'setStepField', + step: step.key, + path: ['x_probe'], + value: 1, + }); + // An alias entry cannot be edited in place. + if (!added.ok && added.code === 'unsupportedForm') return []; + if (!added.ok) return [`setStepField failed: ${added.code} ${added.message}`]; + stats.opProbed++; + const problems: string[] = []; + const windowFrom = lineStartOf(text, step.range.from); + const windowTo = lineEndOf( + text, + Math.max(step.range.from, step.range.to - 1) + ); + for (const edit of added.edits) { + if (edit.from < windowFrom || edit.to > windowTo) { + problems.push( + `setStepField edit ${edit.from}-${edit.to} is outside ${step.key} ${windowFrom}-${windowTo}` + ); + } + } + const removed = applyEdit(added.text, { + kind: 'setStepField', + step: step.key, + path: ['x_probe'], + value: undefined, + }); + if (!removed.ok) { + problems.push(`setStepField removal failed: ${removed.code}`); + } else if (!skipRemoval && removed.text !== text) { + problems.push('setStepField removal does not give back the original text'); + } + return problems; +} + +/** Runs the checks (a) to (e) on one text. Returns the problems found. */ +function checkText( + text: string, + limit?: { skip: 'probe' | 'removal' | 'edits' } +): string[] { + let docs: Document[]; + try { + docs = parseAllDocuments(text) as Document[]; + } catch (error) { + return [`yaml threw: ${String(error)}`]; + } + const doc = docs[0]; + const broken = !!doc && doc.errors.length > 0; + const modelIssues = modelProblems(text, docs); + if (modelIssues.length > 0) return modelIssues; + + let outcome: ReturnType; + try { + outcome = editDocumentDetailed(text, () => {}); + } catch (error) { + return [`editDocument threw: ${String(error)}`]; + } + const { result } = outcome; + const problems: string[] = []; + if (broken) { + if (result.ok || result.code !== 'parseError') { + problems.push(`expected parseError, got ${JSON.stringify(result)}`); + } + return problems; + } + if (result.ok || result.code !== 'noop') { + problems.push(`expected noop, got ${JSON.stringify(result)}`); + return problems; + } + if (doc && limit?.skip !== 'probe') { + try { + problems.push(...probeProblems(text, doc, limit?.skip === 'removal')); + if (limit?.skip !== 'edits') + problems.push(...editProbeProblems(text, doc)); + problems.push(...opProbeProblems(text, limit?.skip === 'removal')); + } catch (error) { + problems.push(`probe threw: ${String(error)}`); + } + } + return problems; +} + +function yamlFilesUnder(dir: string): string[] { + const found: string[] = []; + const walk = (current: string) => { + for (const entry of readdirSync(current).sort()) { + if (entry === 'node_modules' || entry === '.git') continue; + const full = path.join(current, entry); + let stat; + try { + stat = statSync(full); + } catch { + continue; + } + if (stat.isDirectory()) walk(full); + else if (/\.ya?ml$/.test(entry)) found.push(full); + } + }; + walk(dir); + return found; +} + +function failureReport(failures: string[]): string { + return failures.length === 0 ? '' : `\n${failures.join('\n')}\n`; +} + +describe('fixture files', () => { + const files = existsSync(FIXTURE_DIR) ? yamlFilesUnder(FIXTURE_DIR) : []; + + it('has 30 to 50 files', () => { + expect(files.length).toBeGreaterThanOrEqual(30); + expect(files.length).toBeLessThanOrEqual(50); + }); + + it.each(files.map((file) => [path.basename(file), file]))( + '%s', + (name, file) => { + const text = readFileSync(file as string, 'utf8'); + const limit = PROBE_LIMITS[`fixtures/${name}`]; + expect(checkText(text, limit)).toEqual([]); + // The same file with CRLF line ends. + expect(checkText(text.replace(/\n/g, '\r\n'), limit)).toEqual([]); + } + ); + + it('probes most files', () => { + expect(stats.probed).toBeGreaterThanOrEqual(30); + expect(stats.opProbed).toBeGreaterThanOrEqual(30); + }); +}); + +describe('shared test fixtures', () => { + const texts: Record = { + yamlArraySteps, + yamlMapSteps, + yamlShorthandSteps, + yamlNestedParallel, + yamlChain, + yamlMultiDoc, + yamlRouter, + yamlHandlers, + yamlBroken, + yamlHttpJq, + }; + it.each(Object.entries(texts))('%s', (_name, text) => { + expect(checkText(text)).toEqual([]); + expect(checkText(text.replace(/\n/g, '\r\n'))).toEqual([]); + }); +}); + +// The repository directories are outside ui/. They exist when the test runs +// with the whole repository visible (repository CI), not in a ui-only copy. +for (const name of CORPUS_DIRS) { + const root = path.resolve('..', name); + describe.skipIf(!existsSync(root))(`corpus ${name}`, () => { + it('round-trips every YAML file', { timeout: 300_000 }, () => { + const failures: string[] = []; + let count = 0; + for (const file of yamlFilesUnder(root)) { + count++; + const rel = path + .relative(path.resolve('..'), file) + .split(path.sep) + .join('/'); + const problems = checkText( + readFileSync(file, 'utf8'), + PROBE_LIMITS[rel] + ); + for (const problem of problems) failures.push(`${rel}: ${problem}`); + } + expect(count).toBeGreaterThan(0); + expect(failureReport(failures)).toBe(''); + }); + }); +} + +// Fenced yaml blocks of the Markdown specifications are a second corpus. +describe.skipIf(!existsSync(path.resolve('..', 'specs')))( + 'corpus spec blocks', + () => { + it('round-trips every fenced yaml block', { timeout: 300_000 }, () => { + const root = path.resolve('..', 'specs'); + const failures: string[] = []; + let blocks = 0; + for (const entry of readdirSync(root).sort()) { + if (!entry.endsWith('.md')) continue; + const markdown = readFileSync(path.join(root, entry), 'utf8'); + for (const match of markdown.matchAll( + /^```ya?ml\r?\n([\s\S]*?)^```/gm + )) { + blocks++; + const line = markdown.slice(0, match.index).split('\n').length + 1; + for (const problem of checkText(match[1]!)) { + failures.push(`specs/${entry}:${line}: ${problem}`); + } + } + } + expect(blocks).toBeGreaterThan(0); + expect(failureReport(failures)).toBe(''); + }); + } +); diff --git a/ui/src/features/visual-editor/doc/__tests__/index.test.ts b/ui/src/features/visual-editor/doc/__tests__/index.test.ts new file mode 100644 index 0000000000..79d04bbdfe --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/index.test.ts @@ -0,0 +1,121 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import { describe, expect, it } from 'vitest'; +import type { DagEditOp } from '../../contracts'; +import { + yamlArraySteps, + yamlMultiDoc, + yamlRouter, +} from '../../testing/fixtures'; +import * as doc from '../index'; +import { applyTextEdits } from '../patch'; + +describe('applyEdit', () => { + it('dispatches each op kind', () => { + const cases: [string, DagEditOp][] = [ + [yamlArraySteps, { kind: 'addStep', step: { run: 'echo x' } }], + [yamlArraySteps, { kind: 'deleteStep', step: 'steps[2]', rewire: false }], + [ + yamlArraySteps, + { kind: 'renameStep', step: 'steps[2]', newName: 'ship' }, + ], + [yamlArraySteps, { kind: 'setStepId', step: 'steps[0]', id: 'fetch_id' }], + [ + yamlArraySteps, + { kind: 'addDependency', step: 'steps[2]', dependsOn: 'steps[0]' }, + ], + [ + yamlArraySteps, + { kind: 'removeDependency', step: 'steps[2]', dependsOn: 'steps[1]' }, + ], + [ + yamlArraySteps, + { + kind: 'setStepField', + step: 'steps[0]', + path: ['description'], + value: 'Get it', + }, + ], + [ + yamlArraySteps, + { + kind: 'replaceStepYaml', + step: 'steps[0]', + yaml: 'name: fetch\nrun: echo hi\n', + }, + ], + [ + yamlRouter, + { + kind: 'setRouteTargets', + step: 'steps[0]', + pattern: 're:.*', + targets: ['server_error'], + }, + ], + ]; + for (const [text, op] of cases) { + const result = doc.applyEdit(text, op); + expect(result.ok, op.kind).toBe(true); + if (result.ok) { + expect(applyTextEdits(text, result.edits)).toBe(result.text); + } + } + }); + + it('gives noop when nothing needs conversion', () => { + const result = doc.applyEdit(yamlArraySteps, { + kind: 'convertStepsToExplicitForm', + }); + expect(result).toMatchObject({ ok: false, code: 'noop' }); + }); + + it('gives unknownStep for a missing key', () => { + const result = doc.applyEdit(yamlArraySteps, { + kind: 'setStepField', + step: 'steps[9]', + path: ['run'], + value: 'x', + }); + expect(result).toMatchObject({ ok: false, code: 'unknownStep' }); + }); + + // Every op must leave document 1 and later byte for byte. + it('never changes later documents', () => { + const tail = yamlMultiDoc.slice(yamlMultiDoc.indexOf('---\n')); + const ops: DagEditOp[] = [ + { + kind: 'addStep', + step: { name: 'next', run: 'echo next' }, + dependsOn: ['steps[0]'], + }, + { kind: 'renameStep', step: 'steps[0]', newName: 'call-child' }, + { kind: 'setStepId', step: 'steps[0]', id: 'child_run' }, + { + kind: 'setStepField', + step: 'steps[0]', + path: ['with', 'dag'], + value: 'other', + }, + { + kind: 'replaceStepYaml', + step: 'steps[0]', + yaml: 'name: run-child\nrun: echo x\n', + }, + { kind: 'deleteStep', step: 'steps[0]', rewire: true }, + ]; + for (const op of ops) { + const result = doc.applyEdit(yamlMultiDoc, op); + expect(result.ok, op.kind).toBe(true); + if (!result.ok) continue; + expect(result.text.endsWith(tail), op.kind).toBe(true); + for (const edit of result.edits) { + expect(edit.to, op.kind).toBeLessThanOrEqual( + yamlMultiDoc.length - tail.length + ); + } + } + }); +}); diff --git a/ui/src/features/visual-editor/doc/__tests__/inspectorContract.test.ts b/ui/src/features/visual-editor/doc/__tests__/inspectorContract.test.ts new file mode 100644 index 0000000000..a4e68d8dd6 --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/inspectorContract.test.ts @@ -0,0 +1,155 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +// The step inspector relies on these three behaviors of applyEdit. + +import { describe, expect, it } from 'vitest'; +import type { DagEditOp, EditResult } from '../../contracts'; +import { yamlRouter } from '../../testing/fixtures'; +import { applyEdit, parseDagDoc } from '../index'; +import { applyTextEdits } from '../patch'; + +/** Applies the op, checks that the edits give the text, and returns the text. */ +function run(input: string, op: DagEditOp): string { + const result: EditResult = applyEdit(input, op); + if (!result.ok) throw new Error(`${result.code}: ${result.message}`); + expect(applyTextEdits(input, result.edits)).toBe(result.text); + return result.text; +} + +describe('replaceStepYaml with dedented text', () => { + // The text sits at column 0; the op moves it to the column of the entry. + it('re-indents a step that is not the first', () => { + const input = `name: t +steps: + - name: a + run: echo a + # About b. + - name: b + run: echo old + + - name: c + run: echo c +`; + const out = run(input, { + kind: 'replaceStepYaml', + step: 'steps[1]', + yaml: 'name: b\nrun: echo hi\n', + }); + expect(out).toBe( + input.replace(' run: echo old\n', ' run: echo hi\n') + ); + const model = parseDagDoc(out); + expect(model.ok).toBe(true); + expect(model.steps.map((s) => s.explicitName)).toEqual(['a', 'b', 'c']); + expect(model.steps[1]?.value).toEqual({ name: 'b', run: 'echo hi' }); + }); + + it('re-indents a nested map and a block scalar', () => { + const input = `name: t +steps: + - name: a + run: echo a + - name: b + run: echo old + - name: c + run: echo c +`; + const out = run(input, { + kind: 'replaceStepYaml', + step: 'steps[1]', + yaml: 'name: b\nwith:\n url: http://x\n method: GET\nrun: |\n line one\n line two\n', + }); + expect(out).toBe(`name: t +steps: + - name: a + run: echo a + - name: b + with: + url: http://x + method: GET + run: | + line one + line two + - name: c + run: echo c +`); + const model = parseDagDoc(out); + expect(model.ok).toBe(true); + expect(model.steps.map((s) => s.explicitName)).toEqual(['a', 'b', 'c']); + expect(model.steps[1]?.value).toEqual({ + name: 'b', + with: { url: 'http://x', method: 'GET' }, + run: 'line one\nline two\n', + }); + }); +}); + +describe('setRouteTargets with no target', () => { + it('removes the pattern and keeps the rest', () => { + const out = run(yamlRouter, { + kind: 'setRouteTargets', + step: 'steps[0]', + pattern: 're:^5\\d\\d$', + targets: [], + }); + expect(out).toBe( + yamlRouter.replace(' "re:^5\\\\d\\\\d$": [server_error]\n', '') + ); + expect(out).toContain('"re:.*": [other]'); + expect(out).toContain(' # Fallback branch.\n'); + }); +}); + +describe('setStepField with a list index and no value', () => { + const remove = (input: string, step: string, path: (string | number)[]) => + run(input, { kind: 'setStepField', step, path, value: undefined }); + + it('removes an item of a block depends list', () => { + const input = `steps: + - name: a + run: x + - name: b + run: y + # Join. + - name: c + depends: + - a + - b + run: z +`; + expect(remove(input, 'steps[2]', ['depends', 1])).toBe( + input.replace(' - b\n', '') + ); + }); + + it('removes an item of a flow depends list', () => { + const input = `steps: + - name: a + run: x + - name: b + run: y + - name: c + depends: [a, b] # both + run: z +`; + const out = remove(input, 'steps[2]', ['depends', 1]); + expect(out).toBe(input.replace('[a, b]', '[a]')); + expect(parseDagDoc(out).steps[2]?.depends).toEqual(['a']); + }); + + it('removes an item of an env list', () => { + const input = `steps: + - name: a + env: + - A=1 + - B=2 + run: x + - name: b + run: y +`; + expect(remove(input, 'steps[0]', ['env', 0])).toBe( + input.replace(' - A=1\n', '') + ); + }); +}); diff --git a/ui/src/features/visual-editor/doc/__tests__/issues.test.ts b/ui/src/features/visual-editor/doc/__tests__/issues.test.ts new file mode 100644 index 0000000000..0415076fcf --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/issues.test.ts @@ -0,0 +1,546 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import { describe, expect, it } from 'vitest'; +import { + ValidationIssueSeverity, + ValidationIssueSource, +} from '@/api/v1/schema'; +import type { + DagDocModel, + StepNameMap, + StepView, + TextRange, + ValidationIssue, +} from '../../contracts'; +import { + issuesSample, + modelArraySteps, + namesArraySteps, + yamlHandlers, + yamlHttpJq, +} from '../../testing/fixtures'; +import { parseDagDoc } from '../parse'; +import { + attributeIssues, + groupIssues, + locateIssue, + rangeFromOffsets, +} from '../issues'; + +function issue(extra: Partial): ValidationIssue { + return { + severity: ValidationIssueSeverity.error, + source: ValidationIssueSource.build, + message: 'm', + ...extra, + }; +} + +describe('groupIssues', () => { + it('groups the sample by step key', () => { + const groups = groupIssues(modelArraySteps, issuesSample, namesArraySteps); + expect(groups.byStep).toEqual({ + 'steps[1]': [issuesSample[0]], + 'steps[2]': [issuesSample[2]], + }); + expect(groups.dag).toEqual([issuesSample[1]]); + }); + + it('uses stepName when stepPath is missing', () => { + const a = issue({ stepName: 'fetch' }); + const groups = groupIssues(modelArraySteps, [a], namesArraySteps); + expect(groups.byStep).toEqual({ 'steps[0]': [a] }); + expect(groups.dag).toEqual([]); + }); + + it('uses stepName when stepPath is unknown', () => { + const a = issue({ stepPath: 'steps[9]', stepName: 'publish' }); + const groups = groupIssues(modelArraySteps, [a], namesArraySteps); + expect(groups.byStep).toEqual({ 'steps[2]': [a] }); + }); + + it('sends an unknown step to dag', () => { + const a = issue({ stepPath: 'steps[9]', stepName: 'nope' }); + const b = issue({ stepName: 'toString' }); + const groups = groupIssues(modelArraySteps, [a, b], namesArraySteps); + expect(groups.byStep).toEqual({}); + expect(groups.dag).toEqual([a, b]); + }); + + it('ignores a name that maps outside the model', () => { + const a = issue({ stepName: 'ghost' }); + const names = { + byKey: namesArraySteps.byKey, + byName: { ...namesArraySteps.byName, ghost: 'steps[7]' }, + }; + const groups = groupIssues(modelArraySteps, [a], names); + expect(groups.dag).toEqual([a]); + }); + + it('groups handler keys', () => { + const handler = { + key: 'handler_on.failure', + index: null, + handler: 'failure', + form: 'block', + kind: 'run', + depends: [], + value: {}, + range: { + from: 0, + to: 1, + startLine: 1, + startColumn: 1, + endLine: 1, + endColumn: 2, + }, + } as StepView; + const model: DagDocModel = { ...modelArraySteps, handlers: [handler] }; + const a = issue({ stepPath: 'handler_on.failure' }); + const b = issue({ stepPath: 'handler_on.exit' }); + const groups = groupIssues(model, [a, b], namesArraySteps); + expect(groups.byStep).toEqual({ 'handler_on.failure': [a] }); + expect(groups.dag).toEqual([b]); + }); + + it('keeps the input order in a group', () => { + const a = issue({ stepPath: 'steps[0]', message: 'a' }); + const b = issue({ stepName: 'fetch', message: 'b' }); + const c = issue({ stepPath: 'steps[0]', message: 'c' }); + const groups = groupIssues(modelArraySteps, [a, b, c], namesArraySteps); + expect(groups.byStep['steps[0]']).toEqual([a, b, c]); + }); +}); + +describe('rangeFromOffsets', () => { + it('matches the range of a fixture step', () => { + const step = modelArraySteps.steps[1]; + expect(step).toBeDefined(); + const { range } = step as StepView; + expect( + rangeFromOffsets(modelArraySteps.text, range.from, range.to) + ).toEqual(range); + }); + + it('counts lines and columns from 1', () => { + expect(rangeFromOffsets('ab\ncd\n', 3, 5)).toEqual({ + from: 3, + to: 5, + startLine: 2, + startColumn: 1, + endLine: 2, + endColumn: 3, + }); + }); + + it('puts a line end on the line that it ends', () => { + expect(rangeFromOffsets('ab\ncd', 1, 2)).toEqual({ + from: 1, + to: 2, + startLine: 1, + startColumn: 2, + endLine: 1, + endColumn: 3, + }); + }); + + it('spans lines', () => { + const r = rangeFromOffsets('ab\ncd\nef', 1, 7); + expect([r.startLine, r.startColumn, r.endLine, r.endColumn]).toEqual([ + 1, 2, 3, 2, + ]); + }); + + it('counts lines and columns in CRLF text', () => { + const r = rangeFromOffsets('ab\r\ncd\r\nef', 4, 6); + expect([r.startLine, r.startColumn, r.endLine, r.endColumn]).toEqual([ + 2, 1, 2, 3, + ]); + }); + + it('counts UTF-16 units', () => { + // The emoji takes two UTF-16 units. + const r = rangeFromOffsets('a\u{1F600}b', 3, 4); + expect([r.startColumn, r.endColumn]).toEqual([4, 5]); + }); + + it('clamps to the text', () => { + expect(rangeFromOffsets('ab', -4, 99)).toEqual({ + from: 0, + to: 2, + startLine: 1, + startColumn: 1, + endLine: 1, + endColumn: 3, + }); + expect(rangeFromOffsets('ab', 2, 1).to).toBe(2); + }); +}); + +const noNames: StepNameMap = { byKey: {}, byName: {} }; + +function slice(text: string, r: TextRange | null): string | null { + return r ? text.slice(r.from, r.to) : null; +} + +// Builds a step view that covers `first` up to the end of `last`. +function viewOf( + text: string, + key: string, + first: string, + last: string, + handler?: StepView['handler'] +): StepView { + const from = text.indexOf(first); + const to = text.indexOf(last, from) + last.length; + return { + key, + index: handler ? null : Number(/\d+/.exec(key)?.[0]), + handler, + form: 'block', + kind: 'run', + depends: [], + value: {}, + range: rangeFromOffsets(text, from, to), + }; +} + +function modelOf( + text: string, + steps: StepView[], + handlers: StepView[] = [] +): DagDocModel { + return { + text, + ok: true, + parseErrors: [], + documentCount: 1, + dagType: 'graph', + stepsForm: 'array', + steps, + handlers, + }; +} + +const httpJqModel = modelOf(yamlHttpJq, [ + viewOf(yamlHttpJq, 'steps[0]', 'id: fetch', 'url: ${params.API_URL}'), + viewOf(yamlHttpJq, 'steps[1]', 'id: names', '${steps.fetch.outputs.body}'), +]); + +describe('locateIssue: line', () => { + it('uses line and column to the end of the line', () => { + const r = locateIssue(modelArraySteps, issuesSample[0]!, namesArraySteps); + const at = modelArraySteps.text.indexOf('depends: [fetch]'); + expect(r).toEqual({ + from: at, + to: at + 16, + startLine: 9, + startColumn: 5, + endLine: 9, + endColumn: 21, + }); + expect(slice(modelArraySteps.text, r)).toBe('depends: [fetch]'); + }); + + it('defaults the column to 1', () => { + const r = locateIssue(modelArraySteps, issue({ line: 3 }), namesArraySteps); + expect(slice(modelArraySteps.text, r)).toBe('steps:'); + expect(r?.startColumn).toBe(1); + }); + + it('keeps the carriage return out of a line of CRLF text', () => { + const text = modelArraySteps.text.replace(/\n/g, '\r\n'); + const r = locateIssue(modelOf(text, []), issue({ line: 3 }), noNames); + expect(slice(text, r)).toBe('steps:'); + expect(r).toMatchObject({ startLine: 3, startColumn: 1, endLine: 3 }); + }); + + it('clamps the column to the line', () => { + const r = locateIssue( + modelArraySteps, + issue({ line: 3, column: 99 }), + namesArraySteps + ); + expect(r).toMatchObject({ startLine: 3, startColumn: 7, endColumn: 7 }); + expect(r?.from).toBe(r?.to); + }); + + it('skips a line beyond the text', () => { + const text = modelArraySteps.text; + const a = issue({ line: 99, fieldPath: 'steps[1].depends' }); + expect(slice(text, locateIssue(modelArraySteps, a, namesArraySteps))).toBe( + '[fetch]' + ); + const b = issue({ line: 99, stepName: 'publish' }); + expect(locateIssue(modelArraySteps, b, namesArraySteps)).toEqual( + modelArraySteps.steps[2]?.range + ); + expect( + locateIssue(modelArraySteps, issue({ line: 99 }), noNames) + ).toBeNull(); + }); + + it('does not open a line after the last line break', () => { + const lines = modelArraySteps.text.split('\n').length; + expect( + locateIssue(modelArraySteps, issue({ line: lines }), noNames) + ).toBeNull(); + expect( + locateIssue(modelArraySteps, issue({ line: lines - 1 }), noNames) + ).not.toBeNull(); + }); +}); + +describe('locateIssue: fieldPath', () => { + it('finds with.url', () => { + const r = locateIssue( + httpJqModel, + issue({ fieldPath: 'steps[0].with.url' }), + noNames + ); + expect(slice(yamlHttpJq, r)).toBe('${params.API_URL}'); + expect(r).toMatchObject({ startLine: 10, startColumn: 12, endLine: 10 }); + }); + + it('finds a value of the second step', () => { + const r = locateIssue( + httpJqModel, + issue({ fieldPath: 'steps[1].with.filter' }), + noNames + ); + expect(slice(yamlHttpJq, r)).toBe("'[.[].name]'"); + }); + + it('finds a sequence index at DAG level', () => { + const r = locateIssue( + httpJqModel, + issue({ fieldPath: 'params[0]' }), + noNames + ); + expect(slice(yamlHttpJq, r)).toBe('API_URL: https://api.example.com/items'); + expect(r?.startLine).toBe(3); + const key = locateIssue( + httpJqModel, + issue({ fieldPath: 'params[0].API_URL' }), + noNames + ); + expect(slice(yamlHttpJq, key)).toBe('https://api.example.com/items'); + }); + + it('finds a whole step', () => { + const r = locateIssue( + httpJqModel, + issue({ fieldPath: 'steps[1]' }), + noNames + ); + expect(r).toEqual(httpJqModel.steps[1]?.range); + }); + + it('returns the deepest node of a partial path', () => { + const r = locateIssue( + httpJqModel, + issue({ fieldPath: 'steps[0].with.nope.deeper' }), + noNames + ); + expect(slice(yamlHttpJq, r)).toBe( + 'method: GET\n url: ${params.API_URL}' + ); + }); + + it('falls back to the step when nothing is found', () => { + const r = locateIssue( + httpJqModel, + issue({ fieldPath: 'steps[7].with.url', stepPath: 'steps[1]' }), + noNames + ); + expect(r).toEqual(httpJqModel.steps[1]?.range); + const s = locateIssue( + modelArraySteps, + issue({ fieldPath: 'consts.image', stepName: 'fetch' }), + namesArraySteps + ); + expect(s).toEqual(modelArraySteps.steps[0]?.range); + }); + + it('counts UTF-16 units before the target', () => { + const text = 'env: {A: "\u{1F600} \u00e9", B: x}\nsteps:\n - run: echo\n'; + const model = modelOf(text, [ + viewOf(text, 'steps[0]', 'run: echo', 'echo'), + ]); + const r = locateIssue(model, issue({ fieldPath: 'env.B' }), noNames); + const at = text.indexOf('x}'); + expect(r).toEqual({ + from: at, + to: at + 1, + startLine: 1, + startColumn: at + 1, + endLine: 1, + endColumn: at + 2, + }); + expect(slice(text, r)).toBe('x'); + }); + + it('finds a key that has no value', () => { + const text = 'name: n\nenv:\nsteps:\n - run: a\n'; + const model = modelOf(text, [viewOf(text, 'steps[0]', 'run: a', 'a\n')]); + const r = locateIssue(model, issue({ fieldPath: 'env' }), noNames); + expect(slice(text, r)).toBe('env'); + }); +}); + +describe('locateIssue: map-form steps', () => { + const text = + 'name: m\nsteps:\n zeta:\n run: echo z\n alpha:\n run: echo a\n with:\n x: 1\n'; + // Server order is by name: steps[0] is the second entry of the file. + const steps = [ + viewOf(text, 'steps[0]', 'run: echo a', 'x: 1'), + viewOf(text, 'steps[1]', 'run: echo z', 'echo z'), + ]; + const model = { ...modelOf(text, steps), stepsForm: 'map' as const }; + + it('uses server numbering', () => { + const a = locateIssue(model, issue({ fieldPath: 'steps[0].run' }), noNames); + expect(slice(text, a)).toBe('echo a'); + const z = locateIssue(model, issue({ fieldPath: 'steps[1].run' }), noNames); + expect(slice(text, z)).toBe('echo z'); + expect(z?.startLine).toBe(4); + }); + + it('accepts a range that starts at the key', () => { + const keyed = { + ...model, + steps: [ + viewOf(text, 'steps[0]', 'alpha:', 'x: 1'), + viewOf(text, 'steps[1]', 'zeta:', 'echo z'), + ], + }; + const r = locateIssue( + keyed, + issue({ fieldPath: 'steps[0].with.x' }), + noNames + ); + expect(slice(text, r)).toBe('1'); + const whole = locateIssue(keyed, issue({ fieldPath: 'steps[1]' }), noNames); + expect(slice(text, whole)).toBe('run: echo z'); + }); +}); + +describe('locateIssue: handlers', () => { + const model = modelOf( + yamlHandlers, + [viewOf(yamlHandlers, 'steps[0]', 'run: ./job.sh', './job.sh')], + [ + viewOf( + yamlHandlers, + 'handler_on.success', + 'run: echo ok', + 'echo ok', + 'success' + ), + viewOf( + yamlHandlers, + 'handler_on.failure', + 'run: ./notify.sh', + './notify.sh', + 'failure' + ), + ] + ); + + it('finds a field of a handler', () => { + const r = locateIssue( + model, + issue({ fieldPath: 'handler_on.failure.run' }), + noNames + ); + expect(slice(yamlHandlers, r)).toBe('./notify.sh'); + expect(r?.startLine).toBe(7); + }); + + it('falls back to the handler range', () => { + const r = locateIssue( + model, + issue({ stepPath: 'handler_on.success' }), + noNames + ); + expect(r).toEqual(model.handlers[0]?.range); + }); +}); + +describe('locateIssue: step range and null', () => { + it('uses stepPath', () => { + const r = locateIssue( + modelArraySteps, + issue({ stepPath: 'steps[1]' }), + namesArraySteps + ); + expect(r).toEqual(modelArraySteps.steps[1]?.range); + }); + + it('uses stepName through names', () => { + const r = locateIssue( + modelArraySteps, + issue({ stepName: 'publish' }), + namesArraySteps + ); + expect(r).toEqual(modelArraySteps.steps[2]?.range); + }); + + it('returns null without a place', () => { + expect( + locateIssue(modelArraySteps, issuesSample[1]!, namesArraySteps) + ).toBeNull(); + expect( + locateIssue( + modelArraySteps, + issue({ stepName: 'nope', stepPath: 'steps[9]' }), + namesArraySteps + ) + ).toBeNull(); + }); +}); + +describe('attributeIssues', () => { + const empty = issue({ + message: 'failed to build: step command is empty (value: )', + }); + const text = [ + 'steps:', + ' - name: first', + ' run: echo hi', + ' - name: second', + ' run: ""', + ' - name: third', + ' run: ""', + '', + ].join('\n'); + + it('gives the first step with an empty command to an error without a step', () => { + const [result] = attributeIssues(parseDagDoc(text), [empty]); + expect(result).toMatchObject({ + stepName: 'second', + stepPath: 'steps[1]', + }); + }); + + it('keeps the issues that have a step and the other messages', () => { + const named = issue({ + message: 'step command is empty', + stepName: 'third', + }); + const other = issue({ message: 'invalid keys: x' }); + const issues = [named, other]; + expect(attributeIssues(parseDagDoc(text), issues)).toEqual(issues); + }); + + it('changes nothing when no step has an empty command', () => { + const issues = [empty]; + expect( + attributeIssues( + parseDagDoc('steps:\n - name: a\n run: echo\n'), + issues + ) + ).toEqual(issues); + }); +}); diff --git a/ui/src/features/workflow-workspace/__tests__/boundary.test.ts b/ui/src/features/workflow-workspace/__tests__/boundary.test.ts new file mode 100644 index 0000000000..30b5225e5a --- /dev/null +++ b/ui/src/features/workflow-workspace/__tests__/boundary.test.ts @@ -0,0 +1,111 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { + findForbiddenStaticImports, + MERMAID_FORBIDDEN, +} from '@/testing/importGraph'; + +const srcDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../..' +); + +/** Every .ts and .tsx file under a folder of ui/src, at test time. */ +function sourceFiles(folder: string): string[] { + const files: string[] = []; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (/\.tsx?$/.test(entry.name)) { + files.push(path.relative(srcDir, full).split(path.sep).join('/')); + } + } + }; + walk(path.join(srcDir, folder)); + return files.sort(); +} + +const WORKSPACE_DIR = 'features/workflow-workspace'; +const POPOUT_DIR = `${WORKSPACE_DIR}/popout`; + +/** The page that mounts the workspace and the pop-out logs. */ +const PAGE_ROOT = 'pages/dags/dag/index.tsx'; + +// Built from parts, so this file does not hold the marker itself. +const STUB_MARKER = ['WORKSPACE', 'STUB'].join('_'); + +/** The shared leaves that the workspace mounts. */ +const SHARED_LEAVES = [ + 'features/dags/components/dag-editor/DAGSpec.tsx', + 'features/visual-editor/VisualEditor.tsx', + 'features/visual-editor/DetailsCanvas.tsx', + 'features/dags/components/common/DAGActions.tsx', + 'features/dag-runs/components/common/DAGRunActions.tsx', + 'features/dags/components/dag-execution/ParallelExecutionModal.tsx', + 'features/dags/components/dag-execution/StepLog.tsx', + 'features/dags/components/dag-execution/LogViewer.tsx', + 'features/dags/components/dag-execution/ExecutionLog.tsx', + 'features/dags/components/dag-execution/ForeachBodyStepLog.tsx', + 'features/dags/components/dag-execution/StatusUpdateModal.tsx', + 'features/dags/components/dag-execution/StartDAGModal.tsx', + 'features/dags/components/common/SubRunStackModal.tsx', + 'features/dags/components/step-details/StepDetailsDrawer.tsx', + 'features/dags/components/artifacts/ArtifactsTab.tsx', + 'features/dags/components/human-task/HumanTasksTab.tsx', + 'features/dags/components/approval/ApprovalTab.tsx', + 'features/dags/components/agent-session/AgentSessionTab.tsx', + 'features/dags/components/agent/AgentTimeline.tsx', + 'features/dags/components/chat-history/StepMessagesTable.tsx', + 'features/dags/components/visualization/TimelineChart.tsx', + 'features/dags/components/dag-execution/HistoryTable.tsx', + 'features/dag-runs/components/common/RunMetaLine.tsx', + 'features/dag-runs/components/common/AutoRetryBadge.tsx', + 'features/dags/components/dag-details/DAGStepTable.tsx', + 'features/dags/components/dag-details/NotificationsTab.tsx', + 'features/dags/components/dag-details/IncidentsTab.tsx', + 'features/dags/components/dag-details/WebhookTab.tsx', + 'features/dags/components/dag-details/DAGSettingsTab.tsx', + 'features/dags/components/dag-details/DAGWikiTab.tsx', + 'features/dags/components/dag-editor/DAGSpecReadOnly.tsx', +]; + +// parity:dup-0 parity:test-mermaid-graph - the workspace never loads Mermaid. +// There is no exception list: a hit is a static chain to remove. +describe('workflow workspace import boundary', () => { + it('has every shared leaf on disk', () => { + const missing = SHARED_LEAVES.filter( + (file) => !fs.existsSync(path.join(srcDir, file)) + ); + expect(missing).toEqual([]); + }); + + // The scan parses the whole reachable tree; under a parallel run it can + // take longer than the default 5 s. + it('reaches no Mermaid module through a static import', () => { + const popout = sourceFiles(POPOUT_DIR); + expect(popout).not.toEqual([]); + const roots = [ + ...new Set([ + PAGE_ROOT, + ...popout, + ...sourceFiles(WORKSPACE_DIR), + ...SHARED_LEAVES, + ]), + ]; + expect(fs.existsSync(path.join(srcDir, PAGE_ROOT))).toBe(true); + expect(findForbiddenStaticImports(roots, MERMAID_FORBIDDEN)).toEqual([]); + }, 30_000); + + it('has no stub marker left in the workspace folder', () => { + const stubs = sourceFiles(WORKSPACE_DIR).filter((file) => + fs.readFileSync(path.join(srcDir, file), 'utf8').includes(STUB_MARKER) + ); + expect(stubs).toEqual([]); + }); +}); diff --git a/ui/src/features/workflow-workspace/__tests__/eslintBoundary.test.ts b/ui/src/features/workflow-workspace/__tests__/eslintBoundary.test.ts new file mode 100644 index 0000000000..b20af4a98d --- /dev/null +++ b/ui/src/features/workflow-workspace/__tests__/eslintBoundary.test.ts @@ -0,0 +1,243 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +// The default jsdom environment is kept: src/test/setup.ts patches Element, +// which the node environment does not define. + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ESLint } from 'eslint'; +import { beforeAll, describe, expect, it } from 'vitest'; + +const uiRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../../..' +); +const workspaceFile = 'src/features/workflow-workspace/__virtual__/Probe.tsx'; +const outsideFile = 'src/features/dags/__virtual__/Probe.tsx'; + +let eslint: ESLint; + +beforeAll(() => { + eslint = new ESLint({ cwd: uiRoot }); +}); + +// Only the import rule matters: the probe text has unused imports. +async function restrictedImportErrors( + source: string, + filePath = workspaceFile +): Promise { + const [result] = await eslint.lintText( + `import probe from '${source}';\nexport default probe;\n`, + { filePath: path.join(uiRoot, filePath) } + ); + return (result?.messages ?? []) + .filter((m) => m.ruleId === 'no-restricted-imports' && m.severity === 2) + .map((m) => m.message); +} + +const forbidden = [ + // Barrels, with no subpath. + '@/features/dags/components', + '@/features/dags/components/index', + '@/features/dags/components/common', + '@/features/dags/components/common/index', + '@/features/dags/components/dag-execution', + '@/features/dags/components/dag-details', + '@/features/dags/components/visualization', + '@/features/dags/components/step-details', + '../../dags/components/common', + // The run view and the Mermaid graph. + '@/features/dags/components/DAGStatus', + '@/features/dags/components/dag-details/DAGStatusOverview', + '@/features/dags/components/dag-details/NodeStatusTable', + '@/features/dags/components/dag-details/NodeStatusTableRow', + '@/features/dags/components/dag-execution/DAGExecutionHistory', + '@/features/dags/components/visualization/DAGGraph', + '@/features/dags/components/visualization/Graph', + '@/features/dags/components/step-details/StepDetailsDrawer', + '@/features/dags/components/dag-execution/LogSideModal', + '@/features/dags/components/dag-details/DAGDetailsContent', + '@/features/dags/components/dag-details/DAGHeader', + '../../dags/components/DAGStatus.tsx', + // The component library rule stays in force. + '@/ui/button', + '../../components/ui/button', + // The shared primitives replace these. + '@/features/dags/components/common/StatusDot', + '../../dags/components/common/StatusDot', + '@/components/ui/reload-button', +]; + +const allowed = [ + '@/components/ui/status-dot', + '@/features/dags/components/common/DAGActions', + '@/features/dags/components/dag-details/DAGSettingsTab', + '@/features/dags/components/dag-editor/DAGSpec', + '@/features/dags/components/dag-execution/StepLog', + '@/features/dags/components/step-details/StepDetails', + '@/components/ui/button', +]; + +const editorFile = 'src/features/visual-editor/__virtual__/Probe.tsx'; +const dagEditorFile = + 'src/features/dags/components/dag-editor/__virtual__/Probe.tsx'; + +// Messages of the no-restricted-syntax and no-restricted-imports rules for +// a source text. The probe text has other lint errors, which are ignored. +async function guardMessages( + source: string, + filePath: string +): Promise { + const [result] = await eslint.lintText(source, { + filePath: path.join(uiRoot, filePath), + }); + return (result?.messages ?? []) + .filter( + (m) => + (m.ruleId === 'no-restricted-syntax' || + m.ruleId === 'no-restricted-imports') && + m.severity === 2 + ) + .map((m) => m.message); +} + +// A planted violation fails the rule in the three guarded folders and passes +// outside them. +const planted: Array<[string, string, string]> = [ + ['dayjs from', 'const a = dayjs(x).from(y);', 'RelativeTime'], + ['dayjs fromNow', 'const a = dayjs(x).fromNow();', 'RelativeTime'], + ['dayjs toNow', 'const a = d.toNow();', 'RelativeTime'], + ['clipboard', 'navigator.clipboard.writeText(t);', 'copyText'], + ['window clipboard', 'window.navigator.clipboard.readText();', 'copyText'], + ['computed clipboard', "navigator['clipboard'].writeText(t);", 'copyText'], + ['destructured clipboard', 'const { clipboard } = navigator;', 'copyText'], + ['toLocaleString', 'const a = d.toLocaleString();', 'formatAbsoluteTime'], + [ + 'toLocaleDateString', + 'const a = d.toLocaleDateString();', + 'formatAbsoluteTime', + ], + [ + 'toLocaleTimeString', + 'const a = d.toLocaleTimeString();', + 'formatAbsoluteTime', + ], + ['showError literal', "showError('Failed to save');", 'ts()'], + ['showToast literal', "showToast('Saved');", 'ts()'], + ['showToast template', 'showToast(`Saved ${n}`);', 'ts()'], + ['showToast branch', "showToast(ok ? 'Saved' : 'Failed');", 'ts()'], + [ + 'ring literal', + "const c = 'focus-visible:ring-primary';", + 'focus-visible:ring-ring', + ], + [ + 'ring template', + 'const c = `focus-visible:ring-primary ${x}`;', + 'focus-visible:ring-ring', + ], + [ + 'reload button', + "import { ReloadButton } from '@/components/ui/reload-button';", + 'RefreshButton', + ], + [ + 'old StatusDot', + "import { StatusDot } from '../common/StatusDot';", + 'StatusDot', + ], + [ + 'formatDurationMs', + "import { formatDurationMs } from '@/lib/formatDuration';", + 'formatElapsed', + ], + [ + 'formatStepTime', + "import { formatStepTime } from './steps';", + 'formatElapsed', + ], +]; + +const passing = [ + 'const a = Array.from(set);', + 'const a = Uint8Array.from(list);', + 'const m = YAMLMap.from(schema, value);', + 'const s = YAMLSeq.from(schema, list);', + 'const i = Iterator.from(source);', + "showError(ts('Failed to save'));", + "showToast(ts('Saved'), { tone: 'error' });", + "showToast(ok ? ts('Saved') : ts('Failed'));", + 'const c = `focus-visible:ring-ring ${x}`;', + 'const n = formatElapsed(a, b);', + "import { formatElapsed } from '@/lib/dagRunTiming';", + "import { StatusDot } from '@/components/ui/status-dot';", +]; + +describe('shared-module guards', { timeout: 60_000 }, () => { + const hosts = [workspaceFile, editorFile, dagEditorFile]; + + it.each( + planted.flatMap(([name, source, text]) => + hosts.map((host) => [name, host, source, text] as const) + ) + )('rejects %s in %s', async (_name, host, source, text) => { + const messages = await guardMessages(source, host); + expect(messages.some((message) => message.includes(text))).toBe(true); + }); + + it.each(passing)('allows %s', async (source) => { + for (const host of hosts) { + expect(await guardMessages(source, host)).toEqual([]); + } + }); + + it('applies only to the guarded folders', async () => { + expect(await guardMessages('const a = d.fromNow();', outsideFile)).toEqual( + [] + ); + }); +}); + +// The probes lint planted text. CI runs no UI lint step, so this block +// lints the real files of the guarded folders with the same rules. +describe('guarded folders', { timeout: 120_000 }, () => { + it('break no guard rule', async () => { + const results = await eslint.lintFiles([ + 'src/features/workflow-workspace', + 'src/features/visual-editor', + 'src/features/dags/components/dag-editor', + ]); + const errors = results.flatMap((result) => + result.messages + .filter( + (m) => m.ruleId?.startsWith('no-restricted-') && m.severity === 2 + ) + .map( + (m) => + `${path.relative(uiRoot, result.filePath)}:${m.line} ${m.message}` + ) + ); + expect(results.length).toBeGreaterThan(100); + expect(errors).toEqual([]); + }); +}); + +describe('workspace ESLint boundary', { timeout: 60_000 }, () => { + it.each(forbidden)('rejects %s', async (source) => { + expect(await restrictedImportErrors(source)).toHaveLength(1); + }); + + it.each(allowed)('allows %s', async (source) => { + expect(await restrictedImportErrors(source)).toEqual([]); + }); + + it('applies only to the workspace folder', async () => { + expect( + await restrictedImportErrors('@/features/dags/components', outsideFile) + ).toEqual([]); + expect( + await restrictedImportErrors('@/ui/button', outsideFile) + ).toHaveLength(1); + }); +}); diff --git a/ui/src/features/workflow-workspace/__tests__/i18nCoverage.test.ts b/ui/src/features/workflow-workspace/__tests__/i18nCoverage.test.ts new file mode 100644 index 0000000000..b05e096eb1 --- /dev/null +++ b/ui/src/features/workflow-workspace/__tests__/i18nCoverage.test.ts @@ -0,0 +1,98 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +// Every text that the workspace shows goes through the message catalogs. +// This gate scans the workspace JSX and the editor files that the workspace +// hosts. It has no exception list: mark a literal that is not a message +// with `// i18n-ignore` next to it. + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { baseStaticMessages } from '@/i18n/staticMessages'; +import { findJsxLiterals } from '@/testing/jsxLiterals'; +import { workspaceMessages } from '../messages'; + +const srcDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../..' +); + +const WORKSPACE_DIR = 'features/workflow-workspace'; + +/** Editor files that the workspace mounts in host mode. */ +const HOSTED_FILES = [ + 'features/dags/components/dag-editor/NodeDetailsFrame.tsx', + 'features/dags/components/dag-editor/IssuesPopover.tsx', + 'features/dags/components/dag-editor/NoticeLine.tsx', + 'features/visual-editor/canvas/ContextMenu.tsx', + 'features/visual-editor/canvas/KeyHelpDialog.tsx', + 'features/visual-editor/canvas/EmptyCanvasTile.tsx', + 'features/visual-editor/canvas/CanvasToolbar.tsx', +] as const; + +/** The non-test .tsx files under features/workflow-workspace. */ +function workspaceTsxFiles(): string[] { + const out: string[] = []; + const walk = (rel: string) => { + for (const entry of fs.readdirSync(path.join(srcDir, rel), { + withFileTypes: true, + })) { + const child = `${rel}/${entry.name}`; + if (entry.isDirectory()) { + if (entry.name !== '__tests__') walk(child); + } else if ( + entry.name.endsWith('.tsx') && + !/\.(test|spec)\.tsx$/.test(entry.name) + ) { + out.push(child); + } + } + }; + walk(WORKSPACE_DIR); + return out.sort(); +} + +describe('workspace i18n coverage', () => { + // parity:crit-i18n-locales + it('leaves no untranslated literal in the workspace JSX', () => { + const files = [...workspaceTsxFiles(), ...HOSTED_FILES]; + const hits = findJsxLiterals(files, { srcDir }).map( + (hit) => `${hit.file}:${hit.line}:${hit.column} ${hit.kind} "${hit.text}"` + ); + expect(hits).toEqual([]); + }); + + it('scans the workspace tree and every hosted file', () => { + const files = workspaceTsxFiles(); + expect(files).toContain(`${WORKSPACE_DIR}/WorkflowWorkspace.tsx`); + expect(files.some((file) => file.includes('__tests__'))).toBe(false); + for (const file of HOSTED_FILES) { + expect(fs.existsSync(path.join(srcDir, file))).toBe(true); + } + }); +}); + +describe('workspace message keys', () => { + // A text has one key. "Loading run..." and "Loading run…" read the same + // but get two catalog entries, so the screens drift apart. + it('has no key with both a three-dot and an ellipsis form', () => { + const known = new Set([ + ...Object.keys(workspaceMessages.en), + ...Object.keys(baseStaticMessages.en), + ]); + const twins = Object.keys(workspaceMessages.en) + .map((key) => + key.includes('…') + ? [key, key.split('…').join('...')] + : key.includes('...') + ? [key, key.split('...').join('…')] + : null + ) + .filter((pair): pair is string[] => pair !== null) + .filter(([, other]) => known.has(other as string)) + .map(([key, other]) => `${key} / ${other}`); + expect(twins).toEqual([]); + }); +}); diff --git a/ui/src/features/workflow-workspace/__tests__/statusTokens.test.ts b/ui/src/features/workflow-workspace/__tests__/statusTokens.test.ts new file mode 100644 index 0000000000..7737ab6797 --- /dev/null +++ b/ui/src/features/workflow-workspace/__tests__/statusTokens.test.ts @@ -0,0 +1,322 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +// The workspace takes its status colours from lib/statusPalette.ts and its +// durations from lib/dagRunTiming.ts. This scan keeps other colours, spinners +// and formatters out of features/workflow-workspace. It also scans the editor +// code that the workspace hosts: there, a frozen baseline lists the literals +// that exist today. A count can go down, never up. + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +export type TokenRule = + | 'hex-colour' + | 'rgb-hsl-colour' + | 'tailwind-palette' + | 'animate-spin' + | 'formatter-import'; + +export interface TokenViolation { + /** Path relative to ui/src. */ + file: string; + rule: TokenRule; +} + +/** + * Violations in files that W5-N7 or W5-N6C own. Each task removes its + * entries; W6-Q1 empties the list. + */ +export const PENDING_W5: readonly TokenViolation[] = []; + +/** + * Literals that stay for a reason, by file, with their count. A file here + * can go down to a lower count (lower the number) and never up. + * exportCanvas.ts draws the PNG and SVG export outside the page, where CSS + * variables do not resolve, so its palette is literal. + */ +export const BASELINE: Readonly> = { + 'features/visual-editor/canvas/exportCanvas.ts': 26, +}; + +/** The one place each of these literals may appear. */ +const ANIMATE_SPIN_FILES = [ + 'components/ui/spinner.tsx', + 'components/ui/loading-indicator.tsx', +]; + +/** The directories that the scan reads. The first is strict: no baseline. */ +const SCAN_DIRS = [ + 'features/workflow-workspace', + 'features/visual-editor', + 'features/dags/components/dag-editor', + 'features/dags/components/step-details', +]; + +/** The workspace files that T28 owns: none of them can be pending. */ +const OWNED = [ + 'features/workflow-workspace/bar/RunChip.tsx', + 'features/workflow-workspace/bar/RunDetailsParts.tsx', + 'features/workflow-workspace/bar/RunDetailsPopover.tsx', + 'features/workflow-workspace/dock/RunStepList.tsx', + 'features/workflow-workspace/executions/RunStatusDot.tsx', + 'features/workflow-workspace/dock/RunTimeline.tsx', + 'features/workflow-workspace/messages/t28.ts', + 'features/workflow-workspace/step/StepFacts.tsx', +]; + +const srcDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../..' +); + +const PALETTE = + 'red|green|blue|yellow|amber|pink|purple|violet|emerald|orange|rose|sky|indigo|lime|teal|cyan|fuchsia|slate|gray|zinc|neutral|stone'; +const PATTERNS: Array<[TokenRule, RegExp]> = [ + [ + 'hex-colour', + /(? + part + .trim() + .replace(/^type\s+/, '') + .split(/\s+as\s+/)[0] + ); + if (names.some((name) => BANNED_NAMES.includes(name ?? ''))) return true; + if (/(^|\/)formatDuration$/.test(from) && clause.includes('*')) return true; + } + return false; +} + +/** The rules that a source text breaks. */ +export function tokenRules(source: string): TokenRule[] { + const rules: TokenRule[] = PATTERNS.filter(([, re]) => re.test(source)).map( + ([rule]) => rule + ); + if (bannedImport(source)) rules.push('formatter-import'); + return rules; +} + +/** The number of literals and banned imports in a source text. */ +export function literalCount(source: string): number { + const literals = PATTERNS.reduce( + (sum, [, re]) => + sum + (source.match(new RegExp(re.source, 'g'))?.length ?? 0), + 0 + ); + return literals + (bannedImport(source) ? 1 : 0); +} + +/** Every non-test .ts and .tsx file under the scanned directories. */ +function scanFiles(dirs: readonly string[] = SCAN_DIRS): string[] { + const files: string[] = []; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name !== '__tests__') walk(full); + } else if ( + /\.tsx?$/.test(entry.name) && + !/\.test\.tsx?$/.test(entry.name) + ) { + files.push(path.relative(srcDir, full).split(path.sep).join('/')); + } + } + }; + for (const dir of dirs) walk(path.join(srcDir, dir)); + return files.sort(); +} + +const read = (file: string) => fs.readFileSync(path.join(srcDir, file), 'utf8'); + +function scan(): TokenViolation[] { + return scanFiles().flatMap((file) => + tokenRules(read(file)) + .filter( + (rule) => rule !== 'animate-spin' || !ANIMATE_SPIN_FILES.includes(file) + ) + .map((rule) => ({ file, rule })) + ); +} + +const key = (v: TokenViolation) => `${v.file} ${v.rule}`; +const pendingFiles = () => new Set(PENDING_W5.map((v) => v.file)); + +// parity:dup-23 +describe('workspace status tokens', () => { + it('finds the five run surfaces in the scan', () => { + const files = scanFiles(); + for (const file of OWNED.filter((f) => !f.endsWith('t28.ts'))) { + expect(files).toContain(file); + } + }); + + it('scans the editor directories that the workspace hosts', () => { + const files = scanFiles(); + for (const file of [ + 'features/visual-editor/canvas/exportCanvas.ts', + 'features/dags/components/dag-editor/DAGSpec.tsx', + 'features/dags/components/step-details/StepDetails.tsx', + ]) { + expect(files).toContain(file); + } + }); + + it('finds no colour, spinner or formatter outside the shared modules', () => { + const pending = new Set(PENDING_W5.map(key)); + const baseline = new Set(Object.keys(BASELINE)); + expect( + scan().filter((v) => !pending.has(key(v)) && !baseline.has(v.file)) + ).toEqual([]); + }); + + it('lists only pending entries that still break their rule', () => { + const found = new Set(scan().map(key)); + expect(PENDING_W5.filter((v) => !found.has(key(v)))).toEqual([]); + }); + + it('lists no file that T28 owns as pending', () => { + expect(PENDING_W5.filter((v) => OWNED.includes(v.file))).toEqual([]); + }); + + // The count of a baseline file can go down. Lower the number when it does. + it('holds each baseline file at its frozen count', () => { + const files = scanFiles(); + const counts = Object.fromEntries( + Object.keys(BASELINE).map((file) => [ + file, + files.includes(file) ? literalCount(read(file)) : null, + ]) + ); + expect(counts).toEqual(BASELINE); + }); + + it('keeps the baseline and the pending list apart', () => { + const pending = pendingFiles(); + expect(Object.keys(BASELINE).filter((file) => pending.has(file))).toEqual( + [] + ); + }); + + it('keeps animate-spin in the files that own it', () => { + for (const file of ANIMATE_SPIN_FILES) { + expect(read(file)).toContain('animate-spin'); + } + }); +}); + +// Two exported formatters with one name in two modules make two near +// pairs: the callers pick one at random and the texts drift apart. +describe('formatter names', () => { + const FORMATTER_FILES = [ + 'lib/dagRunTiming.ts', + 'features/visual-editor/canvas/statusStyle.ts', + ...fs + .readdirSync(path.join(srcDir, 'lib')) + .filter((name) => /^format.*\.tsx?$/.test(name)) + .map((name) => `lib/${name}`), + ]; + + it('has no exported function name in two formatter modules', () => { + const owners = new Map(); + for (const file of new Set(FORMATTER_FILES)) { + const names = [ + ...read(file).matchAll( + /^export\s+(?:async\s+)?(?:function|const)\s+(\w+)/gm + ), + ].map((m) => m[1] as string); + for (const name of names) { + owners.set(name, [...(owners.get(name) ?? []), file]); + } + } + const shared = [...owners].filter(([, files]) => files.length > 1); + expect(shared).toEqual([]); + }); +}); + +describe('tokenRules', () => { + it.each([ + ["const c = '#c8382f';", 'hex-colour'], + ["style={{ color: '#fff' }}", 'hex-colour'], + ["fill: 'rgb(0 0 0)'", 'rgb-hsl-colour'], + ["color: 'rgba(0, 0, 0, 0.5)'", 'rgb-hsl-colour'], + ["color: 'hsl(210 40% 50%)'", 'rgb-hsl-colour'], + ["className='bg-red-500'", 'tailwind-palette'], + ["className='hover:text-emerald-600'", 'tailwind-palette'], + ["className='dark:stroke-sky-50'", 'tailwind-palette'], + [ + "import { formatDurationMs } from '@/lib/formatDuration';", + 'formatter-import', + ], + [ + "import {\n formatRoughDurationMs as rough,\n} from '../../lib/formatDuration';", + 'formatter-import', + ], + ["import * as d from '@/lib/formatDuration';", 'formatter-import'], + [ + "import { getStatusColors } from '@/lib/status-utils';", + 'formatter-import', + ], + ["import { formatStepTime } from '@/lib/formatStep';", 'formatter-import'], + ["className='text-slate-500'", 'tailwind-palette'], + ["className='dark:bg-zinc-900'", 'tailwind-palette'], + ["className='border-gray-200'", 'tailwind-palette'], + ["className='text-neutral-500'", 'tailwind-palette'], + ["className='bg-gradient-to-r from-red-500'", 'tailwind-palette'], + ['', 'animate-spin'], + ["className='motion-safe:animate-spin'", 'animate-spin'], + ] as const)('flags %s', (source, rule) => { + expect(tokenRules(source)).toEqual([rule]); + }); + + it.each([ + "className='bg-[var(--status-error)] text-destructive ring-success'", + "className='bg-red text-amber'", + "import { formatElapsed } from '@/lib/dagRunTiming';", + "import { statusRingClass } from '@/features/visual-editor/canvas/statusStyle';", + "className='text-slate-5 animate-pulse'", + "import { getStatusClass } from '@/lib/status-utils';", + "const anchor = '#add-step'; const issue = 'dup-23 #23';", + 'const entity = "{";', + ])('lets %s pass', (source) => { + expect(tokenRules(source)).toEqual([]); + }); +}); + +describe('literalCount', () => { + it('counts each literal and a banned import once', () => { + expect(literalCount("const a = ['#fff', '#000000']; // rgb(0 0 0)")).toBe( + 3 + ); + expect( + literalCount("import { formatDurationMs } from '@/lib/formatDuration';") + ).toBe(1); + expect(literalCount("const none = 'bg-primary';")).toBe(0); + }); +}); diff --git a/ui/src/features/workflow-workspace/dock/CachedLogs.tsx b/ui/src/features/workflow-workspace/dock/CachedLogs.tsx index 7835fb084e..335b8fd91a 100644 --- a/ui/src/features/workflow-workspace/dock/CachedLogs.tsx +++ b/ui/src/features/workflow-workspace/dock/CachedLogs.tsx @@ -124,6 +124,7 @@ function CachedLine({ : undefined } > + {/* i18n-ignore: a stream tag, written as in English like stderr */} {stderr ? 'ERR' : null} >; +type LocaleId = 'en' | 'zh-CN' | 'ja'; +type MessageModule = Readonly>; + +const LOCALES: readonly LocaleId[] = ['en', 'zh-CN', 'ja']; +const TRANSLATED: readonly LocaleId[] = ['zh-CN', 'ja']; + +function hasOwn(catalog: Catalog, key: string): boolean { + return Object.prototype.hasOwnProperty.call(catalog, key); +} + +function sameKeys(a: Catalog, b: Catalog): boolean { + const left = Object.keys(a).sort(); + const right = Object.keys(b).sort(); + return ( + left.length === right.length && left.every((key, i) => key === right[i]) + ); +} + +// Returns one line for each broken rule; an empty list means valid modules. +function findProblems( + modules: Readonly>, + base: MessageModule +): string[] { + const problems: string[] = []; + const firstOwner = new Map(); + + for (const [id, module] of Object.entries(modules)) { + for (const locale of TRANSLATED) { + if (!sameKeys(module.en, module[locale])) { + problems.push(`${id}: ${locale} keys differ from en keys`); + } + } + + for (const locale of LOCALES) { + for (const [key, value] of Object.entries(module[locale])) { + if (value.trim() === '') { + problems.push(`${id}: empty ${locale} value for "${key}"`); + } + } + } + + for (const [key, value] of Object.entries(module.en)) { + if (value !== key) { + problems.push(`${id}: en value of "${key}" is not its key`); + } + if (hasOwn(base.en, key)) { + for (const locale of TRANSLATED) { + if (module[locale][key] !== base[locale][key]) { + problems.push(`${id}: "${key}" shadowed in ${locale}`); + } + } + } + const owner = firstOwner.get(key); + if (owner === undefined) { + firstOwner.set(key, id); + continue; + } + const other = modules[owner]!; + for (const locale of LOCALES) { + if (module[locale][key] !== other[locale][key]) { + problems.push( + `${owner}, ${id}: "${key}" has different ${locale} texts` + ); + } + } + } + } + return problems; +} + +const base: MessageModule = { + en: { Save: 'Save' }, + 'zh-CN': { Save: '保存' }, + ja: { Save: '保存' }, +}; + +function module( + en: Catalog, + zh: Catalog = en, + ja: Catalog = en +): MessageModule { + return { en, 'zh-CN': zh, ja }; +} + +describe('workspace message modules', () => { + it('pass every rule', () => { + expect(findProblems(workspaceModules, baseStaticMessages)).toEqual([]); + }); + + it('import every module file', () => { + const dir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..' + ); + const files = readdirSync(dir) + .filter((file) => file.endsWith('.ts')) + .map((file) => file.replace(/\.ts$/, '')) + .filter((name) => name !== 'define' && name !== 'index') + .sort(); + expect(Object.keys(workspaceModules).sort()).toEqual(files); + }); + + it('give no module to a wave 1 task', () => { + for (const id of ['t01', 't02', 't03a', 't03b', 't04', 't05', 'n1']) { + expect(workspaceModules).not.toHaveProperty(id); + } + }); + + it('merge into the static catalog with base entries first', () => { + for (const locale of LOCALES) { + const catalog: Catalog = staticMessages[locale]; + const merged: Catalog = workspaceMessages[locale]; + const baseCatalog: Catalog = baseStaticMessages[locale]; + for (const key of Object.keys(merged)) { + const expected = hasOwn(baseCatalog, key) + ? baseCatalog[key] + : merged[key]; + expect(catalog[key]).toBe(expected); + } + for (const key of Object.keys(baseCatalog)) { + expect(catalog[key]).toBe(baseCatalog[key]); + } + } + }); + + it('accept a valid module that reuses a base key', () => { + const modules = { + t08: module( + { Save: 'Save', 'Run…': 'Run…' }, + { Save: '保存', 'Run…': '运行…' }, + { Save: '保存', 'Run…': '実行…' } + ), + t09: module({ 'Run…': 'Run…' }, { 'Run…': '运行…' }, { 'Run…': '実行…' }), + }; + expect(findProblems(modules, base)).toEqual([]); + }); + + it('reject an en value that is not its key', () => { + const modules = { t08: module({ Run: 'Start' }) }; + expect(findProblems(modules, base)).toEqual([ + 't08: en value of "Run" is not its key', + ]); + }); + + it('reject locales with different key sets', () => { + const modules = { + t08: module({ Run: 'Run' }, { Run: '运行', Stop: '停止' }, {}), + }; + expect(findProblems(modules, base)).toEqual([ + 't08: zh-CN keys differ from en keys', + 't08: ja keys differ from en keys', + ]); + }); + + it('reject a module that shadows a base translation', () => { + const modules = { + t08: module({ Save: 'Save' }, { Save: '存储' }, { Save: '保存' }), + }; + expect(findProblems(modules, base)).toEqual([ + 't08: "Save" shadowed in zh-CN', + ]); + }); + + it('reject a key with different texts in two modules', () => { + const modules = { + t08: module({ Run: 'Run' }, { Run: '运行' }, { Run: '実行' }), + t09: module({ Run: 'Run' }, { Run: '执行' }, { Run: '実行' }), + }; + expect(findProblems(modules, base)).toEqual([ + 't08, t09: "Run" has different zh-CN texts', + ]); + }); + + it('reject an empty value', () => { + const modules = { + t08: module({ Run: 'Run' }, { Run: ' ' }, { Run: '実行' }), + }; + expect(findProblems(modules, base)).toEqual([ + 't08: empty zh-CN value for "Run"', + ]); + }); +}); diff --git a/ui/src/index.tsx b/ui/src/index.tsx index 950deacca3..50878cdfa2 100644 --- a/ui/src/index.tsx +++ b/ui/src/index.tsx @@ -7,6 +7,7 @@ import './styles/global.css'; import './styles/prism.css'; import { Config } from './contexts/ConfigContext'; +import { watchPageVisibility } from './lib/pageVisibility'; declare global { const getConfig: () => Config; @@ -16,6 +17,8 @@ const container = document.getElementById('root'); const root = createRoot(container!); const config = getConfig(); +watchPageVisibility(); + // Clear stale JWT token when not using builtin auth if (config.authMode !== 'builtin') { localStorage.removeItem('dagu_auth_token'); diff --git a/ui/src/lib/__tests__/monacoChunk.test.ts b/ui/src/lib/__tests__/monacoChunk.test.ts new file mode 100644 index 0000000000..ff5e25a5c0 --- /dev/null +++ b/ui/src/lib/__tests__/monacoChunk.test.ts @@ -0,0 +1,122 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import { readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +// Monaco is about 1 MB of script. The pages that show no code editor must not +// load it, so that only a short list of modules imports it with a static +// import. The editors reach it through a dynamic import (loadDAGEditor, +// MonacoField), the one place that a page decides to pay for it. +const srcRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..' +); + +function sourceFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + return entry.name === '__tests__' || entry.name === 'test' + ? [] + : sourceFiles(full); + } + return /\.(ts|tsx)$/.test(entry.name) && !/\.test\./.test(entry.name) + ? [full] + : []; + }); +} + +// `{ type A, type B }` names only types, so the bundler drops the import. +function typeOnlyClause(clause: string): boolean { + const names = /^\{([^}]*)\}$/ + .exec(clause.trim())?.[1] + ?.split(',') + .map((name) => name.trim()) + .filter(Boolean); + return !!names?.length && names.every((name) => /^type\s/.test(name)); +} + +// The specifiers of static value imports and re-exports (not `import type`, +// `export type`, or a clause of type bindings only): a barrel that +// re-exports a module loads it too. +function staticSpecifiers(text: string): string[] { + const pattern = + /^(?:import|export)\s+(?!type\b)([^;]*?)from\s+(['"])([^'"]+)\2|^import\s+(['"])([^'"]+)\4/gm; + return [...text.matchAll(pattern)].flatMap((m) => + m[3] === undefined ? (m[5] ?? []) : typeOnlyClause(m[1]!) ? [] : m[3] + ); +} + +function staticImporters(match: (specifier: string) => boolean): string[] { + return sourceFiles(srcRoot) + .filter((file) => staticSpecifiers(readFileSync(file, 'utf8')).some(match)) + .map((file) => path.relative(srcRoot, file)) + .sort(); +} + +const ends = (name: string) => (specifier: string) => + specifier === name || specifier.endsWith(`/${name}`); + +describe('staticSpecifiers', () => { + it('reads both quote styles and re-exports, and skips type-only imports', () => { + const text = [ + 'import * as monaco from "monaco-editor";', + "import 'monaco-yaml';", + "export { DAGEditor } from './DAGEditor';", + 'export * from "./MonacoFieldEditor";', + "import type { editor } from 'monaco-editor';", + "export type { Props } from './types';", + "import { type editor, type IRange } from 'monaco-editor';", + "export {\n type Props,\n type Theme,\n} from './themes';", + "import { type editor, Range } from '@monaco-editor/react';", + ].join('\n'); + expect(staticSpecifiers(text)).toEqual([ + 'monaco-editor', + 'monaco-yaml', + './DAGEditor', + './MonacoFieldEditor', + '@monaco-editor/react', + ]); + }); +}); + +describe('modules that hold Monaco', () => { + it('lets only the editor modules import the Monaco packages', () => { + expect( + staticImporters((s) => + ['monaco-editor', '@monaco-editor/react', 'monaco-yaml'].includes(s) + ) + ).toEqual([ + 'components/editors/MarkdownEditor.tsx', + 'features/dags/components/dag-editor/DAGEditor.tsx', + 'features/visual-editor/inspector/MonacoFieldEditor.tsx', + 'features/visual-editor/inspector/monacoSetup.ts', + 'lib/monaco-theme.ts', + 'lib/monaco-yaml.ts', + 'pages/wiki/components/WikiPageHistoryModal.tsx', + ]); + }); + + it('keeps the code editors behind a dynamic import', () => { + expect(staticImporters(ends('DAGEditor'))).toEqual([]); + expect(staticImporters(ends('MonacoFieldEditor'))).toEqual([]); + }); + + it('lets only the editor modules import the Monaco setup', () => { + expect(staticImporters(ends('monacoSetup'))).toEqual([ + 'features/visual-editor/inspector/MonacoFieldEditor.tsx', + ]); + expect( + staticImporters(ends('monaco-yaml')).filter( + (f) => !f.endsWith('monaco-yaml.ts') + ) + ).toEqual([ + 'features/dags/components/dag-editor/DAGEditor.tsx', + 'features/visual-editor/inspector/monacoSetup.ts', + ]); + }); +}); diff --git a/ui/src/lib/__tests__/noRawCount.test.ts b/ui/src/lib/__tests__/noRawCount.test.ts new file mode 100644 index 0000000000..fd4c460f48 --- /dev/null +++ b/ui/src/lib/__tests__/noRawCount.test.ts @@ -0,0 +1,73 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +// A count of executions can reach millions (spec 083, Compact Count). No +// screen prints it raw: it goes through formatCompactCount, or CompactCount +// and RepeatBadge, which call it. This scan fails on the raw forms. The +// `×{...limit}` of a repeat policy is a limit, not a count, and is allowed. + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const srcDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../..' +); + +const RAW_FORMS: RegExp[] = [ + // `×${count}` in a template literal. + /×\$\{(?!formatCompactCount\()/, + // `×{count}` in JSX, but not the limit of a repeat policy. + /×\{(?!formatCompactCount\()(?![\w.?]*limit\b)/, + // The count of a node, written as it is or with a fallback such as `?? 0`. + // A prop (`count={...}`) is not text. + /String\([\w.?]*doneCount(?:\s*(?:\?\?|\|\|)\s*\w+)?\)/, + /(? re.test(source)).map((re) => String(re)); +} + +function sources(dir: string): string[] { + const out: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name !== '__tests__' && entry.name !== 'node_modules') { + out.push(...sources(full)); + } + } else if (/\.(ts|tsx)$/.test(entry.name) && !/\.test\./.test(entry.name)) { + out.push(full); + } + } + return out; +} + +describe('raw execution counts', () => { + it('finds the raw forms in a probe', () => { + expect(rawCountForms('`×${count}`')).toHaveLength(1); + expect(rawCountForms('×{node.doneCount}')).toHaveLength(2); + expect(rawCountForms('String(node.doneCount)')).toHaveLength(1); + expect(rawCountForms('{node.doneCount ?? 0}')).toHaveLength(1); + expect(rawCountForms('String(node.doneCount || 0)')).toHaveLength(1); + expect(rawCountForms('')).toEqual([]); + expect(rawCountForms('')).toEqual([]); + expect(rawCountForms('`×${formatCompactCount(count)}`')).toEqual([]); + expect(rawCountForms('×{step.repeatPolicy.limit}')).toEqual([]); + }); + + it('leaves no screen that prints a count of executions raw', () => { + const hits = sources(srcDir) + .filter((file) => !file.endsWith('schema.ts')) + .flatMap((file) => + rawCountForms(fs.readFileSync(file, 'utf8')).map( + (form) => `${path.relative(srcDir, file)} ${form}` + ) + ); + expect(hits).toEqual([]); + }); +}); diff --git a/ui/src/lib/__tests__/sharedClock.test.ts b/ui/src/lib/__tests__/sharedClock.test.ts new file mode 100644 index 0000000000..3df825a01c --- /dev/null +++ b/ui/src/lib/__tests__/sharedClock.test.ts @@ -0,0 +1,61 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +// The UI keeps its clocks on one shared timer for each period +// (hooks/useClock.ts). This scan fails on a new `setInterval(` in src outside +// that file, so that a component never owns a timer of its own again. Polls of +// data go through SWR or `subscribeClock`. + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const srcDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../..' +); + +/** Files that may call `setInterval`, as paths relative to src. */ +const ALLOWED = new Set(['hooks/useClock.ts']); + +const RAW_INTERVAL = /\bsetInterval\s*\(/; + +/** True when a source text calls `setInterval`. */ +export function callsSetInterval(source: string): boolean { + return RAW_INTERVAL.test(source); +} + +function sources(dir: string): string[] { + const out: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name !== '__tests__' && entry.name !== 'node_modules') { + out.push(...sources(full)); + } + } else if (/\.(ts|tsx)$/.test(entry.name) && !/\.test\./.test(entry.name)) { + out.push(full); + } + } + return out; +} + +describe('shared clock guard', () => { + it('finds a call in a probe', () => { + expect(callsSetInterval('const id = setInterval(tick, 1000);')).toBe(true); + expect(callsSetInterval('window.setInterval (tick, 1000)')).toBe(true); + expect(callsSetInterval('clearInterval(id)')).toBe(false); + expect(callsSetInterval('const setIntervalMs = 5')).toBe(false); + }); + + it('keeps every setInterval in the shared clock', () => { + const hits = sources(srcDir) + .map((file) => path.relative(srcDir, file).split(path.sep).join('/')) + .filter((rel) => !ALLOWED.has(rel)) + .filter((rel) => + callsSetInterval(fs.readFileSync(path.join(srcDir, rel), 'utf8')) + ); + expect(hits).toEqual([]); + }); +}); diff --git a/ui/src/pages/dags/dag/__tests__/importBoundary.test.ts b/ui/src/pages/dags/dag/__tests__/importBoundary.test.ts new file mode 100644 index 0000000000..3c6ac3e4ab --- /dev/null +++ b/ui/src/pages/dags/dag/__tests__/importBoundary.test.ts @@ -0,0 +1,22 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import { describe, expect, it } from 'vitest'; +import { + findForbiddenStaticImports, + MERMAID_FORBIDDEN, +} from '@/testing/importGraph'; + +// The chunk of the workflow page carries no Mermaid. +describe('workflow page import boundary', () => { + // The scan parses the whole reachable tree; under a parallel run it can + // take longer than the default 5 s. + it('reaches no Mermaid module statically', { timeout: 60_000 }, () => { + expect( + findForbiddenStaticImports( + ['pages/dags/dag/index.tsx'], + MERMAID_FORBIDDEN + ) + ).toEqual([]); + }); +}); diff --git a/ui/src/testing/__tests__/jsxLiterals.test.ts b/ui/src/testing/__tests__/jsxLiterals.test.ts new file mode 100644 index 0000000000..14796edb04 --- /dev/null +++ b/ui/src/testing/__tests__/jsxLiterals.test.ts @@ -0,0 +1,157 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { findJsxLiterals, type JsxLiteralHit } from '../jsxLiterals'; + +const fixture: Record = { + 'text.tsx': `export function A() { + return ( +
+ Hello world + + 42 + — · : ( ) 3.5% + {label} + Été + =>   → → + Fish & chips +
+ ); +} +`, + 'attributes.tsx': `export function B({ on }: { on: boolean }) { + return ( +
+