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/continuous.spec.ts b/ui/e2e/workspace/continuous.spec.ts new file mode 100644 index 0000000000..ce80eb9306 --- /dev/null +++ b/ui/e2e/workspace/continuous.spec.ts @@ -0,0 +1,133 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import type { APIRequestContext } from '@playwright/test'; +import { + startDAG, + uniqueName, + waitForDAGAvailable, + writeLocalDAG, +} from '../helpers/e2e'; +import { adminToken, expect, signIn, test } from './fixtures'; + +// Spec 083: a run with a continuous step has a Continuous tab in the Run +// panel. The tab does not depend on recorded samples: its first state, with +// no sample yet, is the empty state. + +// The heartbeat waits for the gate, so it has recorded no sample while the +// gate runs. The run has a continuous step and a step that is not. +const GATED_HEARTBEAT = ` +steps: + - name: gate + run: sleep 60 + - name: heartbeat + depends: [gate] + run: echo beat + repeat_policy: + repeat: while + interval_sec: 1 +`; + +const PLAIN = ` +steps: + - name: hello + run: echo hello +`; + +function headers(token: string): Record { + return { Authorization: `Bearer ${token}` }; +} + +test.describe('workspace continuous tab', () => { + test.skip( + ({ viewport }) => (viewport?.width ?? 0) < 1024, + 'the dock shows every tab inline from 1024px' + ); + + const created: string[] = []; + const started: Array<{ dagName: string; runId: string }> = []; + + test.beforeEach(async ({ page }) => { + await signIn(page); + }); + + test.afterEach(async ({ request }) => { + const token = await adminToken(request); + for (const { dagName, runId } of started.splice(0)) { + await request.post( + `/api/v1/dag-runs/${encodeURIComponent(dagName)}/${encodeURIComponent( + runId + )}/stop?remoteNode=local`, + { headers: headers(token) } + ); + } + for (const fileName of created.splice(0)) { + await request.delete( + `/api/v1/dags/${encodeURIComponent(fileName)}?remoteNode=local`, + { headers: headers(token) } + ); + } + }); + + async function startRun( + request: APIRequestContext, + spec: string + ): Promise<{ dagName: string; fileName: string; runId: string }> { + const token = await adminToken(request); + const dagName = uniqueName('e2e-ws-cont'); + const fileName = await writeLocalDAG(dagName, spec); + created.push(fileName); + await waitForDAGAvailable(request, token, fileName); + const runId = await startDAG(request, token, fileName); + started.push({ dagName, runId }); + return { dagName, fileName, runId }; + } + + test('shows the tab and the empty state for a step with no sample', async ({ + page, + request, + gotoWorkspace, + }) => { + test.setTimeout(120_000); + const { fileName, runId } = await startRun(request, GATED_HEARTBEAT); + + await gotoWorkspace( + page, + `/dags/${encodeURIComponent(fileName)}?run=${encodeURIComponent(runId)}&dock=continuous` + ); + const dock = page.getByTestId('ww-dock'); + const tab = dock.getByRole('tab', { name: 'Continuous', exact: true }); + await expect(tab).toBeVisible({ timeout: 30_000 }); + await expect(tab).toHaveAttribute('aria-selected', 'true'); + + const panel = dock.getByTestId('continuous-panel'); + await expect(panel).toBeVisible({ timeout: 30_000 }); + await expect(panel).toHaveAttribute('data-step', 'heartbeat'); + await expect(panel.getByTestId('continuous-empty')).toHaveText( + 'No samples yet' + ); + // The chart draws nothing for no bucket. + await expect(panel.getByTestId('continuous-chart')).toHaveCount(0); + }); + + test('has no Continuous tab for a run without a continuous step', async ({ + page, + request, + gotoWorkspace, + }) => { + test.setTimeout(120_000); + const { fileName, runId } = await startRun(request, PLAIN); + + await gotoWorkspace( + page, + `/dags/${encodeURIComponent(fileName)}?run=${encodeURIComponent(runId)}&dock=log` + ); + const dock = page.getByTestId('ww-dock'); + await expect( + dock.getByRole('tab', { name: 'Log', exact: true }) + ).toBeVisible({ timeout: 30_000 }); + await expect( + dock.getByRole('tab', { name: 'Continuous', exact: true }) + ).toHaveCount(0); + }); +}); diff --git a/ui/e2e/workspace/deeplinks.spec.ts b/ui/e2e/workspace/deeplinks.spec.ts new file mode 100644 index 0000000000..8ff12f3507 --- /dev/null +++ b/ui/e2e/workspace/deeplinks.spec.ts @@ -0,0 +1,676 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import type { APIRequestContext, Page } from '@playwright/test'; +import { + startDAG, + uniqueName, + waitForDAGAvailable, + waitForRunStatus, + writeLocalDAG, +} from '../helpers/e2e'; +import { + adminToken, + expect, + expectAt, + signIn, + test as base, + type FixtureDAG, +} from './fixtures'; + +// Old and new workflow URLs in a browser: each link lands on the URL and the +// zone that the legacy table (state/legacyLinks.ts, rules R1-R11) gives, and +// every redirect keeps remoteNode and workspace. The workspace is one tab: +// the old Executions route redirects to the workflow route, and a link to a +// run opens the Run panel. +// parity:test-details-content-tabs parity:test-e2e-run-page-links +// parity:shell-remote-node + +// The workspace label lets the page show the workflow while ?workspace names +// that workspace. +const WORKSPACE = 'e2elinks'; + +function labelledSpec(marker: string): string { + return ` +labels: + - workspace=${WORKSPACE} +steps: + - name: hello + run: | + sleep 1 + echo ${marker} +`; +} + +// The child DAG is in the same file, so its sub-run belongs to the parent run. +const PARENT_WITH_CHILD = ` +labels: + - workspace=${WORKSPACE} +steps: + - name: call-child + action: dag.run + with: + dag: deeplink-child +--- +name: deeplink-child +steps: + - name: inner + run: echo deeplink-child-output +`; + +const test = base.extend<{ + /** Writes the YAML under a unique name; the DAG is deleted after the test. */ + inlineDAG: (spec: string, prefix?: string) => Promise; +}>({ + inlineDAG: async ({ request }, use) => { + const token = await adminToken(request); + const created: string[] = []; + await use(async (spec, prefix = 'e2e-ws-deep') => { + const dagName = uniqueName(prefix); + const fileName = await writeLocalDAG(dagName, spec); + created.push(fileName); + await waitForDAGAvailable(request, token, fileName); + return { dagName, fileName }; + }); + for (const fileName of created) { + await request.delete( + `/api/v1/dags/${encodeURIComponent(fileName)}?remoteNode=local`, + { headers: { Authorization: `Bearer ${token}` } } + ); + } + }, +}); + +async function runToEnd( + request: APIRequestContext, + token: string, + dag: FixtureDAG +): Promise { + const runId = await startDAG(request, token, dag.fileName); + await waitForRunStatus(request, token, dag.dagName, runId, ['succeeded']); + return runId; +} + +/** The query of every link: the keys that each redirect must keep. */ +const CARRY = `remoteNode=local&workspace=${WORKSPACE}`; + +function withCarry(target: string): string { + return `${target}${target.includes('?') ? '&' : '?'}${CARRY}`; +} + +type Zone = + | 'editor' + | 'dock' + | 'timeline' + | 'overview' + | 'webhook' + | 'notifications' + | 'incidents' + | 'wiki'; + +/** + * The pane, the Run panel, the YAML panel, the tab of the Workflow panel or + * the launcher panel that the URL shows. + */ +async function expectZone(page: Page, zone: Zone): Promise { + if (zone === 'editor') { + await expect(page.getByTestId('ww-pane')).toBeVisible({ timeout: 30_000 }); + return; + } + if (zone === 'dock' || zone === 'timeline') { + await expect(page.getByTestId('ww-dock')).toHaveAttribute( + 'data-state', + 'open', + { timeout: 30_000 } + ); + if (zone === 'timeline') { + await expect( + page + .getByTestId('ww-dock') + .getByRole('tab', { name: 'Timeline', exact: true }) + ).toHaveAttribute('aria-selected', 'true'); + } + return; + } + if (zone === 'webhook') { + await expect(page.getByTestId('ve-launcher-panel-webhook')).toBeVisible({ + timeout: 30_000, + }); + return; + } + const tabs = page.getByTestId('ww-workflow-tabs'); + await expect( + tabs.getByRole('tab', { name: ZONE_TABS[zone], exact: true }) + ).toHaveAttribute('aria-selected', 'true', { timeout: 30_000 }); + await expect(page.getByTestId(`ww-wtab-body-${zone}`)).toBeVisible(); + if (zone === 'overview') { + await expect(page.getByTestId('ww-runtime-profile')).toBeVisible(); + } +} + +function runPagePath(dagName: string, runId: string): string { + return `/dag-runs/${encodeURIComponent(dagName)}/${encodeURIComponent(runId)}`; +} + +const ZONE_TABS = { + overview: 'Overview', + notifications: 'Notifications', + incidents: 'Incidents', + wiki: 'Wiki', +} as const; + +/** The sub-run that the step of a finished root run started. */ +async function subRunOf( + request: APIRequestContext, + token: string, + dagName: string, + dagRunId: string, + step: string +): Promise { + let subRunId = ''; + await expect + .poll( + async () => { + const response = await request.get( + `/api/v1/dag-runs/${encodeURIComponent(dagName)}/${encodeURIComponent(dagRunId)}?remoteNode=local`, + { headers: { Authorization: `Bearer ${token}` } } + ); + if (!response.ok()) return ''; + const body = (await response.json()) as { + dagRunDetails: { + nodes?: { + step: { name: string }; + subRuns?: { dagRunId: string }[]; + }[]; + }; + }; + subRunId = + body.dagRunDetails.nodes?.find((node) => node.step.name === step) + ?.subRuns?.[0]?.dagRunId ?? ''; + return subRunId; + }, + { timeout: 30_000 } + ) + .not.toBe(''); + return subRunId; +} + +test.describe('workspace deep links', () => { + test.beforeEach(async ({ page }) => { + await signIn(page); + }); + + // R2, R3, R5, R6, R9, R10 (the panel paths) and R11. + // parity:shell-docs-redirect parity:page-legacy-docs-redirect + test('opens each workflow tab path in its zone or panel', async ({ + page, + request, + inlineDAG, + gotoWorkspace, + }) => { + test.setTimeout(180_000); + const dag = await inlineDAG(labelledSpec('tab-paths')); + const { fileName } = dag; + // The timeline tab of the Run panel needs a run. + await runToEnd(request, await adminToken(request), dag); + const base = `/dags/${encodeURIComponent(fileName)}`; + const rows: { + rule: string; + from: string; + to: string; + query?: Record; + zone: Zone; + }[] = [ + { rule: 'R3', from: '/status', to: '', zone: 'editor' }, + // The old Spec tab was the YAML: the editor drops ?yaml. The panel + // itself is in the YAML test of legacy-links.spec.ts. + { + rule: 'R5', + from: '/spec', + to: '', + query: { yaml: null }, + zone: 'editor', + }, + { rule: 'R11', from: '/unknown-tab', to: '', zone: 'editor' }, + { + rule: 'R2', + from: '/docs', + to: '', + query: { wtab: 'wiki', panel: null }, + zone: 'wiki', + }, + // The History grid is the timeline of the Run panel. + { + rule: 'R9', + from: '/history', + to: '', + query: { dock: 'timeline' }, + zone: 'timeline', + }, + // The runtime profile of the old settings path is in the Overview tab. + { + rule: 'R6', + from: '/settings', + to: '', + query: { panel: null, wtab: null }, + zone: 'overview', + }, + { + rule: 'R10', + from: '/webhook', + to: '', + query: { panel: null }, + zone: 'webhook', + }, + { + rule: 'R10', + from: '/notifications', + to: '', + query: { wtab: 'notifications', panel: null }, + zone: 'notifications', + }, + { + rule: 'R10', + from: '/incidents', + to: '', + query: { wtab: 'incidents', panel: null }, + zone: 'incidents', + }, + { + rule: 'R10', + from: '/wiki', + to: '', + query: { wtab: 'wiki', panel: null }, + zone: 'wiki', + }, + ]; + + for (const row of rows) { + await test.step(`${row.rule} ${row.from}`, async () => { + await gotoWorkspace(page, withCarry(`${base}${row.from}`)); + await expectAt(page, `${base}${row.to}`, { + ...row.query, + remoteNode: 'local', + workspace: WORKSPACE, + }); + await expectZone(page, row.zone); + }); + } + }); + + // R4. parity:run-page-subrun-deeplink parity:shell-subdag-run-load + test('opens /spec of a sub-run on the root run of that sub-run', async ({ + page, + request, + inlineDAG, + gotoWorkspace, + }) => { + test.setTimeout(150_000); + const token = await adminToken(request); + const parent = await inlineDAG(PARENT_WITH_CHILD, 'e2e-ws-deep-parent'); + const rootId = await runToEnd(request, token, parent); + const subId = await subRunOf( + request, + token, + parent.dagName, + rootId, + 'call-child' + ); + + await gotoWorkspace( + page, + withCarry( + `/dags/${encodeURIComponent(parent.fileName)}/spec?dagRunId=${rootId}&subDAGRunId=${subId}` + ) + ); + // The workflow page has no address for a sub-run: the link opens its root run. + await expectAt(page, `/dags/${encodeURIComponent(parent.fileName)}`, { + run: rootId, + subDAGRunId: null, + remoteNode: 'local', + workspace: WORKSPACE, + }); + await expect( + page + .getByRole('heading', { level: 1, name: parent.dagName, exact: true }) + .first() + ).toBeVisible({ timeout: 30_000 }); + }); + + // R11: the old Executions route is the workflow route. It redirects, with + // replace, to /dags/:f with every key; ?run opens the Run panel on the run + // (parity:chg-classic-executions-link). + test('redirects /executions to the workflow route', async ({ + page, + request, + inlineDAG, + gotoWorkspace, + }) => { + test.setTimeout(150_000); + const token = await adminToken(request); + const dag = await inlineDAG(labelledSpec('classic-links')); + const runId = await runToEnd(request, token, dag); + const base = `/dags/${encodeURIComponent(dag.fileName)}`; + const carried = { remoteNode: 'local', workspace: WORKSPACE }; + + await test.step('/executions?run', async () => { + await gotoWorkspace(page, withCarry(`${base}/executions?run=${runId}`)); + await expectAt(page, base, { ...carried, run: runId }); + await expectZone(page, 'editor'); + // ?run in the URL at load opens the Run panel on that run. + await expectZone(page, 'dock'); + await expect(page.getByTestId(`ww-run-bar-${runId}`)).toHaveAttribute( + 'aria-selected', + 'true', + { timeout: 30_000 } + ); + }); + + await test.step('/executions', async () => { + await gotoWorkspace(page, withCarry(`${base}/executions`)); + // No run in the link: the page names the latest run. + await expectAt(page, base, { ...carried, run: runId }); + await expectZone(page, 'editor'); + }); + }); +}); + +test.describe('workspace deep links to runs', () => { + // The run list, the NDV and the dock need the desktop layout. + test.skip( + ({ viewport }) => (viewport?.width ?? 0) < 768, + 'desktop layout only' + ); + + test.beforeEach(async ({ page }) => { + await signIn(page); + }); + + // R1, R5 and R6 with a run, R7 and R8. + // parity:shell-run-redirect parity:overview-legacy-run-redirect + // parity:history-selection-url + test('opens the run links of the legacy table on the run', async ({ + page, + request, + inlineDAG, + gotoWorkspace, + }) => { + test.setTimeout(240_000); + const token = await adminToken(request); + const dag = await inlineDAG(labelledSpec('run-links')); + // Oldest first. The timeline of the Run panel shows the runs in this + // order, so ?idx=2 names the third run. + const runs: string[] = []; + for (let i = 0; i < 4; i++) { + runs.push(await runToEnd(request, token, dag)); + } + const base = `/dags/${encodeURIComponent(dag.fileName)}`; + const carried = { + remoteNode: 'local', + workspace: WORKSPACE, + }; + // The bar of a run on the timeline of the Run panel; ?run opens the panel. + const row = (runId: string) => page.getByTestId(`ww-run-bar-${runId}`); + + await test.step('R1 /status?dagRunId', async () => { + await gotoWorkspace( + page, + withCarry(`${base}/status?dagRunId=${runs[3]}`) + ); + await expectAt(page, base, { ...carried, run: runs[3] }); + await expect( + page + .getByRole('heading', { level: 1, name: dag.dagName, exact: true }) + .first() + ).toBeVisible({ timeout: 30_000 }); + }); + + await test.step('R5 /spec?dagRunId', async () => { + await gotoWorkspace( + page, + withCarry(`${base}/spec?dagRunId=${runs[1]}&dagRunName=${dag.dagName}`) + ); + // The old Spec tab was the YAML: the editor opens it and keeps ?run. + await expectAt(page, base, { + run: runs[1], + yaml: null, + dagRunId: null, + dagRunName: null, + ...carried, + }); + await expectZone(page, 'editor'); + // The YAML panel and the Run panel take turns: the Run panel waits, + // collapsed, while the YAML shows. + await expect(page.getByTestId('ndv')).toHaveAttribute( + 'data-state', + 'docked', + { timeout: 30_000 } + ); + await expect(page.getByTestId('ww-dock')).toHaveAttribute( + 'data-state', + 'collapsed' + ); + }); + + await test.step('R6 /webhook?dagRunId', async () => { + await gotoWorkspace( + page, + withCarry(`${base}/webhook?dagRunId=${runs[1]}`) + ); + // A panel is not about one run: the link's run goes, and the page names + // the latest run (the last of the four). + await expectAt(page, base, { + run: runs[3], + dagRunId: null, + panel: null, + ...carried, + }); + await expectZone(page, 'webhook'); + }); + + await test.step('R7 /history?dagRunId&idx', async () => { + await gotoWorkspace( + page, + withCarry(`${base}/history?dagRunId=${runs[0]}&idx=2`) + ); + await expectAt(page, base, { + run: runs[0], + idx: null, + dagRunId: null, + ...carried, + }); + await expectZone(page, 'dock'); + await expect(row(runs[0])).toHaveAttribute('aria-selected', 'true', { + timeout: 30_000, + }); + }); + + await test.step('R8 /history?idx', async () => { + await gotoWorkspace(page, withCarry(`${base}/history?idx=2`)); + await expectAt(page, base, { + run: runs[2], + idx: null, + ...carried, + }); + // The redirect waits for the run history and opens the Run panel with + // ?dock; the run is selected on its timeline. + await expectZone(page, 'dock'); + await expect(row(runs[2])).toHaveAttribute('aria-selected', 'true', { + timeout: 30_000, + }); + }); + }); + + // The Run panel is always on the page: ?dock=log opens it on the step log. + // parity:execution-log-viewer + test('opens the dock log of ?step&dock=log', async ({ + page, + request, + inlineDAG, + gotoWorkspace, + }) => { + test.setTimeout(150_000); + const token = await adminToken(request); + const dag = await inlineDAG(labelledSpec('dock-log-marker')); + await runToEnd(request, token, dag); + const base = `/dags/${encodeURIComponent(dag.fileName)}`; + + await gotoWorkspace(page, withCarry(`${base}?step=hello&dock=log`)); + const dock = page.getByTestId('ww-dock'); + await expect( + dock.getByRole('tab', { name: 'Log', exact: true }) + ).toHaveAttribute('aria-selected', 'true', { timeout: 30_000 }); + await expect(dock).toHaveAttribute('data-state', 'open'); + const log = dock.getByTestId('ww-step-run'); + await expect(log).toHaveAttribute('data-step', 'hello'); + await expect(log).toHaveAttribute('data-variant', 'logs'); + await expect(log).toContainText('dock-log-marker', { timeout: 30_000 }); + + await expectAt(page, base, { + step: 'hello', + dock: 'log', + remoteNode: 'local', + workspace: WORKSPACE, + }); + }); + + // R8 and D6: a link to a run opens the Run panel on it, also when the + // redirect adds ?run after the history loads. + test('opens the Run panel on /history?idx', async ({ + page, + request, + inlineDAG, + gotoWorkspace, + }) => { + test.setTimeout(150_000); + const token = await adminToken(request); + const dag = await inlineDAG(labelledSpec('history-idx')); + const runs: string[] = []; + for (let i = 0; i < 2; i++) runs.push(await runToEnd(request, token, dag)); + const base = `/dags/${encodeURIComponent(dag.fileName)}`; + + await gotoWorkspace(page, withCarry(`${base}/history?idx=1`)); + await expectAt(page, base, { + run: runs[1], + idx: null, + remoteNode: 'local', + workspace: WORKSPACE, + }); + await expect(page.getByTestId('ww-dock')).toHaveAttribute( + 'data-state', + 'open', + { timeout: 10_000 } + ); + }); + + // ?def=run shows the definition that the run used, read-only, in place of + // the editor. The old Executions route keeps the key, and the back button + // of the snapshot returns to the current definition. + test('opens the read-only definition of ?run&def=run', async ({ + page, + request, + inlineDAG, + gotoWorkspace, + }) => { + test.setTimeout(150_000); + const token = await adminToken(request); + const dag = await inlineDAG(labelledSpec('def-run-marker')); + const runId = await runToEnd(request, token, dag); + const base = `/dags/${encodeURIComponent(dag.fileName)}`; + const carried = { remoteNode: 'local', workspace: WORKSPACE }; + + await gotoWorkspace( + page, + withCarry(`${base}/executions?run=${runId}&def=run`) + ); + await expectAt(page, base, { ...carried, run: runId, def: 'run' }); + const snapshot = page.getByTestId('ww-run-definition'); + await expect(snapshot).toBeVisible({ timeout: 30_000 }); + await expect(snapshot).toHaveAttribute('data-run-id', runId); + await expect(snapshot.getByTestId('ve-canvas-node-steps[0]')).toBeVisible({ + timeout: 30_000, + }); + + await page.getByTestId('ww-run-snapshot-back').click(); + await expectAt(page, base, { ...carried, run: runId, def: null }); + await expect(page.getByTestId('ww-run-definition')).toHaveCount(0); + await expect(page.getByTestId('ww-canvas')).toBeVisible(); + }); + + // The node view of a step fills the zone: ?ndv=expanded. It holds the Run + // panel collapsed, even when the same link has ?dock. + // parity:drawer-url-sync + test('opens the node view of ?step&ndv=expanded over the collapsed dock', async ({ + page, + request, + inlineDAG, + gotoWorkspace, + }) => { + test.setTimeout(150_000); + const token = await adminToken(request); + const dag = await inlineDAG(labelledSpec('ndv-marker')); + await runToEnd(request, token, dag); + const base = `/dags/${encodeURIComponent(dag.fileName)}`; + + await gotoWorkspace( + page, + withCarry(`${base}?step=hello&ndv=expanded&dock=log`) + ); + const ndv = page.getByTestId('ww-pane').getByTestId('ndv'); + await expect(ndv).toBeVisible({ timeout: 30_000 }); + await expect(ndv).toHaveAttribute('data-state', 'expanded'); + await expectZone(page, 'editor'); + await expect(page.getByTestId('ww-dock')).toHaveAttribute( + 'data-state', + 'collapsed' + ); + await expectAt(page, base, { + step: 'hello', + ndv: 'expanded', + remoteNode: 'local', + workspace: WORKSPACE, + }); + }); + + // parity:test-e2e-run-page-links parity:runpage-header + test('redirects the old run page to the workflow with the run', async ({ + page, + request, + fixtureDAG, + }) => { + test.setTimeout(180_000); + const token = await adminToken(request); + // No workspace label: the old link names no workspace. + const dag = await fixtureDAG('visual-params.yaml', 'e2e-ws-deep-show'); + const older = await startDAG(request, token, dag.fileName); + await waitForRunStatus(request, token, dag.dagName, older, [ + 'succeeded', + 'partially_succeeded', + ]); + const newer = await startDAG(request, token, dag.fileName); + await waitForRunStatus(request, token, dag.dagName, newer, [ + 'succeeded', + 'partially_succeeded', + ]); + // The source file name of the run may come with or without '.yaml'. + const workflowPath = new RegExp(`^/dags/${dag.dagName}(\\.yaml)?$`); + + await test.step('opens the run in the workspace', async () => { + await page.goto(runPagePath(dag.dagName, older)); + + await expect + .poll(() => new URL(page.url()).pathname, { timeout: 30_000 }) + .toMatch(workflowPath); + const url = new URL(page.url()); + expect(url.searchParams.get('run')).toBe(older); + expect(url.searchParams.get('remoteNode')).toBe('local'); + await expectZone(page, 'editor'); + await expectZone(page, 'dock'); + await expect(page.getByTestId(`ww-run-bar-${older}`)).toHaveAttribute( + 'aria-selected', + 'true', + { timeout: 30_000 } + ); + }); + }); +}); diff --git a/ui/e2e/workspace/fixtures/index.ts b/ui/e2e/workspace/fixtures/index.ts new file mode 100644 index 0000000000..d3dc399bc5 --- /dev/null +++ b/ui/e2e/workspace/fixtures/index.ts @@ -0,0 +1,114 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import { + test as base, + expect, + type APIRequestContext, + 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); +} + +/** Signs in to the API as the stack admin and returns the bearer token. */ +export async function adminToken(request: APIRequestContext): Promise { + const stack = await loadStack(); + return loginViaAPI( + request, + stack.auth.adminUsername, + stack.auth.adminPassword + ); +} + +/** + * Waits until the page is at the path and has these query values. A null + * value means that the key is absent. + */ +export async function expectAt( + page: Page, + pathname: string, + query: Record +): Promise { + await expect + .poll( + () => { + const url = new URL(page.url()); + const values = Object.keys(query).map( + (key) => `${key}=${url.searchParams.get(key)}` + ); + return [url.pathname, ...values]; + }, + { timeout: 30_000 } + ) + .toEqual([ + pathname, + ...Object.entries(query).map(([key, value]) => `${key}=${value}`), + ]); +} + +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 token = await adminToken(request); + 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/e2e/workspace/keyboard.spec.ts b/ui/e2e/workspace/keyboard.spec.ts new file mode 100644 index 0000000000..59075144f0 --- /dev/null +++ b/ui/e2e/workspace/keyboard.spec.ts @@ -0,0 +1,225 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import type { APIRequestContext, Locator, Page } from '@playwright/test'; +import { + listDAGRuns, + loadStack, + loginViaAPI, + startDAG, + waitForRunStatus, +} from '../helpers/e2e'; +import { expect, signIn, test } from './fixtures'; + +const STEP = 'prepare'; +const MAX_TABS = 150; + +/** Presses Tab until the target has the focus. Fails after MAX_TABS presses. */ +async function tabTo(page: Page, target: Locator): Promise { + for (let i = 0; i < MAX_TABS; i++) { + if (await target.evaluate((element) => element === document.activeElement)) + return; + await page.keyboard.press('Tab'); + } + await expect(target).toBeFocused(); +} + +/** Signs in and opens a DAG that has a run: Run step reuses its outputs. */ +async function startWithRun( + page: Page, + request: APIRequestContext, + fixtureDAG: ( + file: string, + prefix?: string + ) => Promise<{ + dagName: string; + fileName: string; + }> +) { + const stack = await loadStack(); + const token = await loginViaAPI( + request, + stack.auth.adminUsername, + stack.auth.adminPassword + ); + const { dagName, fileName } = await fixtureDAG( + 'visual-params.yaml', + 'e2e-ws-keys' + ); + const firstRun = await startDAG(request, token, fileName); + await waitForRunStatus( + request, + token, + dagName, + firstRun, + ['succeeded', 'partially_succeeded', 'failed'], + 'local', + 90_000 + ); + await signIn(page); + return { token, dagName, fileName }; +} + +/** Confirms a run dialog with the keyboard and waits for the new run. */ +async function confirmRunStep( + page: Page, + request: APIRequestContext, + token: string, + dagName: string +): Promise { + const runsBefore = new Set( + (await listDAGRuns(request, token, dagName)).map((run) => run.dagRunId) + ); + const confirm = page.getByRole('dialog', { name: 'Run step' }); + await expect(confirm).toBeVisible(); + // The dialog has Cancel, Step only and Run (Changed steps shows only when + // an earlier step changed): tab to Run. + await tabTo(page, confirm.getByRole('button', { name: 'Run' })); + await page.keyboard.press('Enter'); + await expect(confirm).toBeHidden({ timeout: 30_000 }); + let started: string[] = []; + await expect + .poll( + async () => { + started = (await listDAGRuns(request, token, dagName)) + .map((run) => run.dagRunId) + .filter((id) => !runsBefore.has(id)); + return started.length; + }, + { timeout: 60_000 } + ) + .toBe(1); + // The one tab stays on its page: ?run names the new run, not the earlier + // one, and the dock is not opened by the run. + await expect + .poll(() => new URL(page.url()).searchParams.get('run'), { + timeout: 30_000, + }) + .toBe(started[0]); + await expect(page.getByTestId('ww-pane')).toHaveCount(1); +} + +/** 'l' opens the dock on the log of the selected step. */ +async function openStepLogWithL(page: Page): Promise { + // The step that the toolbar ran is selected: ?step names it. + await expect(page).toHaveURL(/[?&]step=/); + const dock = page.getByTestId('ww-dock'); + await expect(dock).toHaveAttribute('data-state', 'collapsed'); + await page.keyboard.press('l'); + await expect(dock).toHaveAttribute('data-state', 'open'); + await expect(dock.getByTestId('ww-step-log')).toBeVisible({ + timeout: 30_000, + }); + await expect(page).toHaveURL(/[?&]dock=log(&|$)/); +} + +test.describe('workspace keyboard', () => { + // The node view needs the desktop layout. + test.skip( + ({ viewport }) => (viewport?.width ?? 0) < 1024, + 'desktop layout only' + ); + + test('runs a step and reads its log without the mouse', async ({ + page, + request, + fixtureDAG, + gotoWorkspace, + }) => { + test.setTimeout(180_000); + const { token, dagName, fileName } = await startWithRun( + page, + request, + fixtureDAG + ); + await gotoWorkspace(page, `/dags/${encodeURIComponent(fileName)}`); + const canvas = page.getByRole('region', { name: 'Workflow canvas' }); + await expect(canvas).toBeVisible({ timeout: 30_000 }); + await expect( + page.getByTestId('ww-canvas').getByTestId('ve-canvas-node-steps[0]') + ).toBeVisible({ timeout: 30_000 }); + + // Ctrl+K: 'Go to step ' selects the step. + await page.keyboard.press('ControlOrMeta+k'); + const palette = page.getByRole('dialog', { name: 'Command palette' }); + await expect(palette).toBeVisible(); + await page.keyboard.type(`Go to step ${STEP}`); + const command = palette.getByRole('option', { + name: new RegExp(`Go to step:? ${STEP}$`), + }); + await expect(command).toHaveAttribute('aria-selected', 'true'); + await page.keyboard.press('Enter'); + await expect(palette).toBeHidden(); + await expect(page).toHaveURL(new RegExp(`[?&]step=${STEP}(&|$)`)); + + // Enter on the canvas opens the node view of the selected step. + await tabTo(page, canvas); + await page.keyboard.press('Enter'); + const ndv = page.getByTestId('ndv'); + await expect(ndv).toBeVisible({ timeout: 30_000 }); + await expect(page).toHaveURL(/[?&]ndv=expanded(&|$)/); + + // Esc closes the node view and the focus comes back, visible, to the + // canvas. + await page.keyboard.press('Escape'); + await expect(ndv).toBeHidden(); + await expect + .poll(() => + canvas.evaluate((element) => { + const active = document.activeElement; + return ( + active instanceof HTMLElement && + element.contains(active) && + active.matches(':focus-visible') + ); + }) + ) + .toBe(true); + + // Tab reaches the hover toolbar of the selected step: its Run step button + // asks to confirm. + const runButton = page + .getByTestId('ww-canvas') + .getByTestId('ve-canvas-node-steps[0]') + .getByRole('button', { name: 'Run step', exact: true }); + await tabTo(page, runButton); + await page.keyboard.press('Enter'); + await confirmRunStep(page, request, token, dagName); + + // The run shows on the same page: 'l' opens the dock on the log of the + // step. + await openStepLogWithL(page); + }); + + // Run step in the node view stays on the editor: the node view stays + // open, and Escape still closes it. + test('keeps the node view and its keys after Run step', async ({ + page, + request, + fixtureDAG, + gotoWorkspace, + }) => { + test.setTimeout(180_000); + const { token, dagName, fileName } = await startWithRun( + page, + request, + fixtureDAG + ); + await gotoWorkspace( + page, + `/dags/${encodeURIComponent(fileName)}?step=${STEP}&ndv=expanded` + ); + const ndv = page.getByTestId('ndv'); + await expect(ndv).toBeVisible({ timeout: 30_000 }); + await tabTo(page, ndv.getByRole('button', { name: 'Run step' })); + await page.keyboard.press('Enter'); + await confirmRunStep(page, request, token, dagName); + await expect(page).not.toHaveURL(/\/executions/); + await expect(ndv).toBeVisible(); + await expect( + ndv.getByRole('region', { name: 'Output', exact: true }) + ).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(ndv).toBeHidden(); + }); +}); diff --git a/ui/e2e/workspace/layout.spec.ts b/ui/e2e/workspace/layout.spec.ts new file mode 100644 index 0000000000..4004458916 --- /dev/null +++ b/ui/e2e/workspace/layout.spec.ts @@ -0,0 +1,538 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import type { Locator, Page } from '@playwright/test'; +import { + loadStack, + loginViaAPI, + openStepForm, + startDAG, + waitForRunStatus, +} from '../helpers/e2e'; +import { expect, signIn, test } from './fixtures'; + +// parity:run-graph-expand (the canvas fills the zone between the bar and +// the dock: 400px or more at 1280x800. The one tab always has the Run panel: +// collapsed to its bar at load, open with a run in the URL) + +const DOCK_KEYS = [ + 'dagu.workspace.dockHeight', + 'dagu.workspace.dockOpen', + 'dagu.workspace.dockOpen.v2', +]; + +function canvasFrame(page: Page): Locator { + return page.getByTestId('ww-canvas').getByTestId('ve-canvas-frame'); +} + +async function box(locator: Locator) { + const value = await locator.boundingBox(); + expect(value).not.toBeNull(); + return value!; +} + +/** Opens the workspace editor on the DAG and waits for the first step. */ +async function openEditor( + page: Page, + gotoWorkspace: (page: Page, target: string) => Promise, + fileName: string +): Promise { + await gotoWorkspace(page, `/dags/${encodeURIComponent(fileName)}`); + await expect(page.getByTestId('workflow-workspace')).toBeVisible({ + timeout: 30_000, + }); + await expect( + page.getByTestId('ww-canvas').getByTestId('ve-canvas-node-steps[0]') + ).toBeVisible({ timeout: 30_000 }); +} + +async function documentScrolls(page: Page) { + return page.evaluate(() => ({ + vertical: + document.documentElement.scrollHeight > window.innerHeight || + document.body.scrollHeight > window.innerHeight, + horizontal: + document.documentElement.scrollWidth > window.innerWidth || + document.body.scrollWidth > window.innerWidth, + })); +} + +test.describe('workspace layout', () => { + // Each test sets its own viewport; one project is enough. + test.skip( + ({ viewport }) => (viewport?.width ?? 0) < 768, + 'runs once, in the desktop project' + ); + + test.beforeEach(async ({ page }) => { + // The Run panel starts collapsed, at its default height when it opens. + await page.addInitScript((keys) => { + for (const key of keys) window.localStorage.removeItem(key); + }, DOCK_KEYS); + await signIn(page); + }); + + test('keeps the page still and the canvas at 400px at 1280x800', async ({ + page, + request, + fixtureDAG, + gotoWorkspace, + }) => { + test.setTimeout(150_000); + await page.setViewportSize({ width: 1280, height: 800 }); + const stack = await loadStack(); + const token = await loginViaAPI( + request, + stack.auth.adminUsername, + stack.auth.adminPassword + ); + const { dagName, fileName } = await fixtureDAG('visual-params.yaml'); + const runId = await startDAG(request, token, fileName); + await waitForRunStatus(request, token, dagName, runId, [ + 'succeeded', + 'partially_succeeded', + ]); + + // One tab: the canvas with the run overlay, and the Run panel under it, + // collapsed to its 32px bar. + await openEditor(page, gotoWorkspace, fileName); + const dock = page.getByTestId('ww-dock'); + await expect(dock).toHaveAttribute('data-state', 'collapsed'); + expect((await box(dock)).height).toBeLessThanOrEqual(34); + expect(await documentScrolls(page)).toEqual({ + vertical: false, + horizontal: false, + }); + expect((await box(canvasFrame(page))).height).toBeGreaterThanOrEqual(400); + // The bar is one row. + expect((await box(page.getByTestId('ww-bar'))).height).toBeLessThanOrEqual( + 44 + ); + + // A run in a link opens the panel at load; the canvas stays the same + // editable graph, with the run overlaid. (The page named this run in its + // own URL, and a reload keeps the panel closed, so the link comes from a + // fresh entry.) + await page.goto('about:blank'); + await gotoWorkspace( + page, + `/dags/${encodeURIComponent(fileName)}?run=${encodeURIComponent(runId)}` + ); + await expect( + page.getByTestId('ww-canvas').getByTestId('ve-canvas-node-steps[0]') + ).toBeVisible({ timeout: 30_000 }); + await expect(dock).toHaveAttribute('data-state', 'open', { + timeout: 30_000, + }); + // The Run panel is open at about 40% of the window. + const dockBox = await box(dock); + expect(dockBox.height).toBeGreaterThanOrEqual(300); + expect(dockBox.height).toBeLessThanOrEqual(800 * 0.5); + expect(await documentScrolls(page)).toEqual({ + vertical: false, + horizontal: false, + }); + // The zone of the canvas keeps its floor of 280px, and the canvas is not + // squeezed to nothing. + expect( + (await box(page.getByTestId('ww-zone-main'))).height + ).toBeGreaterThanOrEqual(280); + expect((await box(canvasFrame(page))).height).toBeGreaterThanOrEqual(200); + // The timeline is above the two columns of the panel. + const timeline = await box(page.getByTestId('ww-run-timeline')); + const nav = await box(page.getByTestId('ww-run-steps-nav')); + expect(timeline.y + timeline.height).toBeLessThanOrEqual(nav.y + 0.5); + + // Collapsed, the canvas takes 400px or more again. + await dock + .getByRole('button', { name: 'Activity', exact: true }) + .first() + .click(); + await expect(dock).toHaveAttribute('data-state', 'collapsed'); + expect(await documentScrolls(page)).toEqual({ + vertical: false, + horizontal: false, + }); + expect((await box(canvasFrame(page))).height).toBeGreaterThanOrEqual(400); + }); + + // A control keeps its place in every state: Start does not move when the + // draft becomes dirty (its caret keeps a slot, disabled), and the compact + // run identity of a narrow bar keeps the way back to the latest run. + test('keeps Start in place when the draft is dirty and the way back in a narrow bar', async ({ + page, + request, + fixtureDAG, + gotoWorkspace, + }) => { + test.setTimeout(150_000); + await page.setViewportSize({ width: 1280, height: 800 }); + const stack = await loadStack(); + const token = await loginViaAPI( + request, + stack.auth.adminUsername, + stack.auth.adminPassword + ); + const { dagName, fileName } = await fixtureDAG('visual-params.yaml'); + const olderId = await startDAG(request, token, fileName); + await waitForRunStatus(request, token, dagName, olderId, [ + 'succeeded', + 'partially_succeeded', + ]); + const runId = await startDAG(request, token, fileName); + await waitForRunStatus(request, token, dagName, runId, [ + 'succeeded', + 'partially_succeeded', + ]); + + // The bar is too narrow for the full chip: the compact identity has the + // way back from an older run, and it needs no more than the 40px row. + await gotoWorkspace( + page, + `/dags/${encodeURIComponent(fileName)}?run=${encodeURIComponent(olderId)}` + ); + const compact = page.getByTestId('ww-bar-compact-run'); + await expect(compact).toBeVisible({ timeout: 30_000 }); + expect((await box(page.getByTestId('ww-bar'))).height).toBeLessThanOrEqual( + 44 + ); + await compact + .getByRole('button', { name: 'Back to latest', exact: true }) + .click(); + // The URL names the latest run. + await expect(page).toHaveURL( + new RegExp(`[?&]run=${encodeURIComponent(runId)}(&|$)`) + ); + await expect( + compact.getByRole('button', { name: 'Back to latest', exact: true }) + ).toHaveCount(0); + + await page.setViewportSize({ width: 1440, height: 900 }); + await openEditor(page, gotoWorkspace, fileName); + const start = page + .getByTestId('ww-run-split') + .locator('[data-ww-action="start"]'); + const caret = page.getByRole('button', { + name: 'More run options', + exact: true, + }); + await expect(caret).toBeDisabled(); + const clean = await box(start); + + const firstNode = page + .getByTestId('ww-canvas') + .getByTestId('ve-canvas-node-steps[0]'); + await firstNode.dblclick(); + await openStepForm(page); + const field = page + .getByTestId('ve-inspector') + .getByLabel('Description', { exact: true }); + await field.fill('Changed in the browser, not saved'); + await field.blur(); + await expect(page.locator('#save-config')).toBeEnabled({ + timeout: 30_000, + }); + await expect(caret).toBeEnabled(); + const dirty = await box(start); + expect(dirty.x).toBeCloseTo(clean.x, 0); + expect(dirty.width).toBeCloseTo(clean.width, 0); + }); + + // The canvas toolbar is in a row at the top of the graph zone. A panel that + // opens starts under that row and never moves the toolbar. Save does not + // move when the draft turns dirty, with the panel in the same state: the box + // of the slot is the same in both states. + test('keeps the Save slot in place when the draft is dirty', async ({ + page, + fixtureDAG, + gotoWorkspace, + }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + const { fileName } = await fixtureDAG('visual-params.yaml'); + await openEditor(page, gotoWorkspace, fileName); + await page + .getByTestId('ww-canvas') + .getByTestId('ve-canvas-node-steps[0]') + .dblclick(); + await openStepForm(page); + const slot = page.getByTestId('save-slot'); + const save = page.locator('#save-config'); + await expect(save).toBeDisabled(); + const clean = { slot: await box(slot), save: await box(save) }; + + const field = page + .getByTestId('ve-inspector') + .getByLabel('Description', { exact: true }); + await field.fill('Changed in the browser, not saved'); + await field.blur(); + await expect(save).toBeEnabled({ timeout: 30_000 }); + const dirty = { slot: await box(slot), save: await box(save) }; + for (const part of ['slot', 'save'] as const) { + for (const key of ['x', 'y', 'width', 'height'] as const) { + expect(dirty[part][key], `${part}.${key}`).toBeCloseTo( + clean[part][key], + 0 + ); + } + } + }); + + // The toolbar of the canvas is pinned to the top right of the zone: Save + // stays at the same x and y with no panel, with the YAML panel, with the + // Workflow panel and with the node view, clean and dirty. + test('pins the canvas toolbar to the zone when a panel opens, at five widths', async ({ + page, + fixtureDAG, + gotoWorkspace, + }) => { + test.setTimeout(240_000); + await page.setViewportSize({ width: 1920, height: 900 }); + const { fileName } = await fixtureDAG('visual-params.yaml'); + await openEditor(page, gotoWorkspace, fileName); + const toolbar = page.getByTestId('ve-toolbar'); + const slot = page.getByTestId('save-slot'); + const save = page.locator('#save-config'); + const row = page.getByTestId('ww-canvas-toolbar'); + const ndv = page.getByTestId('ndv'); + + const parts = async () => ({ + row: await box(row), + toolbar: await box(toolbar), + slot: await box(slot), + save: await box(save), + }); + // A box after the layout settled: two reads in a row are equal. + const settled = async () => { + let last = JSON.stringify(await parts()); + for (let i = 0; i < 20; i += 1) { + await page.waitForTimeout(150); + const next = JSON.stringify(await parts()); + if (next === last) + return JSON.parse(next) as Awaited>; + last = next; + } + throw new Error('the toolbar keeps moving'); + }; + const expectSame = async ( + reference: Awaited>, + where: string + ) => { + const now = await settled(); + for (const part of ['row', 'toolbar', 'slot', 'save'] as const) { + for (const key of ['x', 'y', 'width', 'height'] as const) { + expect(now[part][key], `${where}: ${part}.${key}`).toBeCloseTo( + reference[part][key], + 0 + ); + } + } + }; + + for (const width of [1920, 1440, 1280, 1152, 1024]) { + await test.step(`window of ${width}px`, async () => { + await page.setViewportSize({ width, height: 900 }); + await expect(slot).toBeVisible(); + const reference = await settled(); + // The toolbar sits in the row of the zone, which is 40px high and + // starts at the top of the zone. + const zone = await box(page.getByTestId('ww-canvas')); + expect(reference.row.y).toBeCloseTo(zone.y, 0); + expect(reference.row.height).toBeCloseTo(40, 0); + expect(reference.toolbar.y).toBeGreaterThanOrEqual(zone.y - 0.5); + expect( + reference.toolbar.y + reference.toolbar.height + ).toBeLessThanOrEqual(zone.y + 40.5); + expect( + reference.toolbar.x + reference.toolbar.width + ).toBeLessThanOrEqual(zone.x + zone.width + 0.5); + // The row floats over the graph: no background of its own, and the + // canvas fills the zone from its top edge. + const rowStyle = await row.evaluate((el) => { + const style = getComputedStyle(el); + return { + background: style.backgroundColor, + border: style.borderTopWidth, + shadow: style.boxShadow, + pointer: style.pointerEvents, + }; + }); + expect(rowStyle.background).toBe('rgba(0, 0, 0, 0)'); + expect(rowStyle.border).toBe('0px'); + expect(rowStyle.shadow).toBe('none'); + expect(rowStyle.pointer).toBe('none'); + const canvas = await box( + page.getByTestId('ww-canvas').getByTestId('ve-canvas') + ); + // The frame of the canvas has a 1px border: the canvas is inside it. + expect(canvas.y - zone.y).toBeLessThanOrEqual(1.5); + expect(zone.height - canvas.height).toBeLessThanOrEqual(2.5); + + // The YAML panel. + const yaml = toolbar.getByRole('button', { name: 'YAML', exact: true }); + await yaml.click(); + await expect(ndv).toHaveAttribute('data-state', 'docked'); + const panel = await box(ndv); + // The panel is one box from the top of the zone: the row of toolbars + // sits over its first 40px. + expect(panel.y).toBeCloseTo(zone.y, 0); + expect(reference.toolbar.y).toBeGreaterThanOrEqual(panel.y - 0.5); + expect( + reference.toolbar.y + reference.toolbar.height + ).toBeLessThanOrEqual(panel.y + 40.5); + await expectSame(reference, 'YAML panel'); + await yaml.click(); + await expect(ndv).toBeHidden(); + await expectSame(reference, 'YAML panel closed'); + + // The Workflow panel. + const workflow = toolbar.getByRole('button', { + name: 'Workflow', + exact: true, + }); + await workflow.click(); + await expect(page.getByTestId('ve-workflow')).toBeVisible(); + await expectSame(reference, 'Workflow panel'); + await workflow.click(); + await expect(page.getByTestId('ve-workflow')).toBeHidden(); + await expectSame(reference, 'Workflow panel closed'); + + // The node view. + await page + .getByTestId('ww-canvas') + .getByTestId('ve-canvas-node-steps[0]') + .dblclick(); + await expect(ndv).toHaveAttribute('data-state', 'expanded'); + // The expanded node view covers the whole zone, the row included. + expect((await box(ndv)).y).toBeCloseTo(zone.y, 0); + await expectSame(reference, 'node view'); + await ndv.getByRole('button', { name: 'Close', exact: true }).click(); + await expect(ndv).toBeHidden(); + await expectSame(reference, 'node view closed'); + }); + } + + // Dirty: the slot of Save keeps its box, with and without a panel. + await page.setViewportSize({ width: 1440, height: 900 }); + const clean = await settled(); + await page + .getByTestId('ww-canvas') + .getByTestId('ve-canvas-node-steps[0]') + .dblclick(); + await openStepForm(page); + const field = page + .getByTestId('ve-inspector') + .getByLabel('Description', { exact: true }); + await field.fill('Changed in the browser, not saved'); + await field.blur(); + await expect(save).toBeEnabled({ timeout: 30_000 }); + await expectSame(clean, 'dirty with the form open'); + await ndv.getByRole('button', { name: 'Close', exact: true }).click(); + await expectSame(clean, 'dirty with no panel'); + }); + + test('keeps the node view in the graph zone and docks it at 1280px and 900px', async ({ + page, + fixtureDAG, + gotoWorkspace, + }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + const { fileName } = await fixtureDAG('visual-params.yaml'); + await openEditor(page, gotoWorkspace, fileName); + + await page + .getByTestId('ww-canvas') + .getByTestId('ve-canvas-node-steps[0]') + .dblclick(); + const ndv = page.getByTestId('ndv'); + await expect(ndv).toBeVisible({ timeout: 30_000 }); + await expect(ndv).toHaveAttribute('data-state', 'expanded'); + + // The expanded view fills the graph zone: not the window, and it leaves + // the bar and the sidebar free. + const zone = await box(page.getByTestId('ww-canvas')); + const bar = await box(page.getByTestId('ww-bar')); + let panel = await box(ndv); + expect(panel.x).toBeGreaterThanOrEqual(zone.x - 0.5); + expect(panel.y).toBeGreaterThanOrEqual(bar.y + bar.height - 0.5); + expect(panel.x + panel.width).toBeLessThanOrEqual( + zone.x + zone.width + 0.5 + ); + expect(panel.y + panel.height).toBeLessThanOrEqual( + zone.y + zone.height + 0.5 + ); + expect(panel.width).toBeLessThan(1280 - 200); + await expect(page.getByTestId('ww-bar')).toBeVisible(); + await page + .getByTestId('ww-run-split') + .locator('[data-ww-action="start"]') + .click({ trial: true, timeout: 5_000 }); + + // The YAML panel takes the right side of the zone and the canvas keeps + // the rest. The node view has no button that docks it. + await expect( + ndv.getByRole('button', { name: 'Dock to the side', exact: true }) + ).toHaveCount(0); + await ndv.getByRole('button', { name: 'Close', exact: true }).click(); + // The toolbar of the canvas: the node view has a YAML tab of its own. + await page + .getByTestId('ve-toolbar') + .getByRole('button', { name: 'YAML', exact: true }) + .click(); + await expect(ndv).toHaveAttribute('data-state', 'docked'); + const frame0 = await box(canvasFrame(page)); + panel = await box(ndv); + expect(frame0.x + frame0.width).toBeLessThanOrEqual(panel.x + 0.5); + expect(panel.x + panel.width).toBeLessThanOrEqual( + zone.x + zone.width + 0.5 + ); + + await page.setViewportSize({ width: 900, height: 800 }); + await expect(ndv).toBeVisible(); + await expect(async () => { + const frame = await box(canvasFrame(page)); + panel = await box(ndv); + expect(frame.x + frame.width).toBeGreaterThan(panel.x); + }).toPass({ timeout: 10_000 }); + }); + + test('has no horizontal scroll at 375x812 and keeps the Tools button', async ({ + page, + request, + fixtureDAG, + gotoWorkspace, + }) => { + test.setTimeout(150_000); + await page.setViewportSize({ width: 375, height: 812 }); + const stack = await loadStack(); + const token = await loginViaAPI( + request, + stack.auth.adminUsername, + stack.auth.adminPassword + ); + const { dagName, fileName } = await fixtureDAG('visual-params.yaml'); + const runId = await startDAG(request, token, fileName); + await waitForRunStatus(request, token, dagName, runId, [ + 'succeeded', + 'partially_succeeded', + ]); + await openEditor(page, gotoWorkspace, fileName); + + await expect( + page + .getByTestId('ww-canvas') + .getByRole('button', { name: 'Tools', exact: true }) + ).toBeVisible(); + await expect(page.getByTestId('flow-minimap')).toBeHidden(); + expect((await documentScrolls(page)).horizontal).toBe(false); + + // The Run panel of the same tab is a bottom sheet at this width. + const dock = page.getByTestId('ww-dock'); + await dock + .getByRole('button', { name: 'Activity', exact: true }) + .first() + .click(); + await expect(dock).toHaveAttribute('data-state', 'open'); + await expect(dock).toHaveAttribute('data-sheet', 'true'); + expect((await documentScrolls(page)).horizontal).toBe(false); + }); +}); diff --git a/ui/e2e/workspace/legacy-links.spec.ts b/ui/e2e/workspace/legacy-links.spec.ts new file mode 100644 index 0000000000..a7a4741356 --- /dev/null +++ b/ui/e2e/workspace/legacy-links.spec.ts @@ -0,0 +1,167 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import { + loadStack, + loginViaAPI, + startDAG, + waitForRunStatus, +} from '../helpers/e2e'; +import { expect, expectAt, signIn, test } from './fixtures'; + +// The legacy link rules of state/legacyLinks.ts (N3) in a browser. Each old +// link carries remoteNode, and each redirect must keep it. The workspace is +// one tab, so no old link lands on a mode. + +test.describe('workspace legacy links', () => { + test.beforeEach(async ({ page }) => { + await signIn(page); + }); + + // R5. parity:shell-remote-node + test('opens /spec on the editor and drops ?yaml', async ({ + page, + fixtureDAG, + gotoWorkspace, + }) => { + const { fileName } = await fixtureDAG('visual-params.yaml', 'e2e-ws-link'); + const base = `/dags/${encodeURIComponent(fileName)}`; + + await gotoWorkspace(page, `${base}/spec?remoteNode=local`); + await expectAt(page, base, { remoteNode: 'local', yaml: null }); + await expect(page.getByTestId('ww-pane')).toBeVisible(); + }); + + // R5: the old Spec tab was the YAML, so the link opens the YAML panel + // (?ndv=docked). + test('opens /spec as the YAML panel', async ({ + page, + fixtureDAG, + gotoWorkspace, + }) => { + const { fileName } = await fixtureDAG('visual-params.yaml', 'e2e-ws-link'); + const base = `/dags/${encodeURIComponent(fileName)}`; + + await gotoWorkspace(page, `${base}/spec?remoteNode=local`); + await expect(page.getByTestId('ndv')).toHaveAttribute( + 'data-state', + 'docked', + { timeout: 10_000 } + ); + await expect(page.getByTestId('yaml-file-body')).toBeVisible({ + timeout: 10_000, + }); + }); + + // R1. parity:shell-run-redirect parity:overview-legacy-run-redirect + test('opens /status?dagRunId on the run of the workflow page', async ({ + page, + request, + fixtureDAG, + gotoWorkspace, + }) => { + const stack = await loadStack(); + const token = await loginViaAPI( + request, + stack.auth.adminUsername, + stack.auth.adminPassword + ); + const { fileName } = await fixtureDAG('visual-params.yaml', 'e2e-ws-link'); + const dagRunId = await startDAG(request, token, fileName); + + await gotoWorkspace( + page, + `/dags/${encodeURIComponent(fileName)}/status?dagRunId=${encodeURIComponent(dagRunId)}&remoteNode=local` + ); + await expectAt(page, `/dags/${encodeURIComponent(fileName)}`, { + run: dagRunId, + remoteNode: 'local', + }); + }); + + // R9 + test('opens /history on the timeline of the Run panel', async ({ + page, + request, + fixtureDAG, + gotoWorkspace, + }) => { + const stack = await loadStack(); + const token = await loginViaAPI( + request, + stack.auth.adminUsername, + stack.auth.adminPassword + ); + const { dagName, fileName } = await fixtureDAG( + 'visual-params.yaml', + 'e2e-ws-link' + ); + // The timeline tab of the Run panel needs a run. + const dagRunId = await startDAG(request, token, fileName); + await waitForRunStatus(request, token, dagName, dagRunId, [ + 'succeeded', + 'partially_succeeded', + ]); + const base = `/dags/${encodeURIComponent(fileName)}`; + + await gotoWorkspace(page, `${base}/history?remoteNode=local`); + await expectAt(page, base, { + view: null, + dock: 'timeline', + remoteNode: 'local', + }); + await expect(page.getByTestId('ww-pane')).toBeVisible(); + const dock = page.getByTestId('ww-dock'); + await expect(dock).toHaveAttribute('data-state', 'open', { + timeout: 30_000, + }); + await expect( + dock.getByRole('tab', { name: 'Timeline', exact: true }) + ).toHaveAttribute('aria-selected', 'true'); + }); + + // R11, D6: the old Executions route redirects to the workflow route with + // every key. The page replaces the entry, so Back leaves the workflow. + test('redirects /executions to the workflow route', async ({ + page, + fixtureDAG, + gotoWorkspace, + }) => { + const { fileName } = await fixtureDAG('visual-params.yaml', 'e2e-ws-link'); + const base = `/dags/${encodeURIComponent(fileName)}`; + const previous = page.url(); + + await gotoWorkspace( + page, + `${base}/executions?step=prepare&remoteNode=local` + ); + await expectAt(page, base, { step: 'prepare', remoteNode: 'local' }); + await expect(page.getByTestId('ww-pane')).toBeVisible(); + // A pushed redirect would bring Back to /executions and then redirect + // again to the workflow route. + await page.goBack(); + await expect(page).toHaveURL(previous); + }); + + // R2. parity:shell-docs-redirect parity:page-legacy-docs-redirect + test('opens /docs as the wiki tab of the Workflow panel', async ({ + page, + fixtureDAG, + gotoWorkspace, + }) => { + const { fileName } = await fixtureDAG('visual-params.yaml', 'e2e-ws-link'); + const base = `/dags/${encodeURIComponent(fileName)}`; + + await gotoWorkspace(page, `${base}/docs?remoteNode=local`); + await expectAt(page, base, { + remoteNode: 'local', + wtab: 'wiki', + panel: null, + }); + await expect( + page + .getByTestId('ww-workflow-tabs') + .getByRole('tab', { name: 'Wiki', exact: true }) + ).toHaveAttribute('aria-selected', 'true', { timeout: 30_000 }); + }); +}); diff --git a/ui/e2e/workspace/manual-actions.spec.ts b/ui/e2e/workspace/manual-actions.spec.ts new file mode 100644 index 0000000000..9084518230 --- /dev/null +++ b/ui/e2e/workspace/manual-actions.spec.ts @@ -0,0 +1,322 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import type { Locator, Page } from '@playwright/test'; +import { + getStepStdout, + showStepOutput, + startDAG, + uniqueName, + waitForDAGAvailable, + waitForRunStatus, + writeLocalDAG, +} from '../helpers/e2e'; +import { + adminToken, + expect, + signIn, + test as base, + type FixtureDAG, +} from './fixtures'; + +// parity:test-e2e-gaps (approve, push back and complete a human task from the +// step view of the workspace) + +// An approval gate between two steps. A push-back runs the gate again with +// FEEDBACK in its environment. The server needs the required FEEDBACK on +// Approve too, so the Approve dialog asks for it. +const APPROVAL = ` +steps: + - name: build + run: echo built + - name: review + depends: [build] + run: | + echo "feedback=\${FEEDBACK:-}" + approval: + prompt: Approve the release + input: [FEEDBACK] + required: [FEEDBACK] + - name: publish + depends: [review] + run: echo published +`; + +// The workflow of e2e/human-task-push-back.spec.ts. +const HUMAN_TASK = ` +steps: + - id: implement + run: echo "attempt=$DAG_PUSHBACK_ITERATION feedback=$feedback" + - id: review + depends: implement + action: human.task + with: + prompt: Review the implementation + push_back: + rewind_to: implement + form: + type: object + required: [feedback] + properties: + feedback: + type: string + title: Feedback + - id: publish + depends: review + run: echo published +`; + +const test = base.extend<{ + /** Writes the YAML under a unique name; the DAG is deleted after the test. */ + inlineDAG: (spec: string, prefix?: string) => Promise; +}>({ + inlineDAG: async ({ request }, use) => { + const token = await adminToken(request); + const created: string[] = []; + await use(async (spec, prefix = 'e2e-ws-actions') => { + const dagName = uniqueName(prefix); + const fileName = await writeLocalDAG(dagName, spec); + created.push(fileName); + await waitForDAGAvailable(request, token, fileName); + return { dagName, fileName }; + }); + for (const fileName of created) { + await request.delete( + `/api/v1/dags/${encodeURIComponent(fileName)}?remoteNode=local`, + { headers: { Authorization: `Bearer ${token}` } } + ); + } + }, +}); + +/** + * Opens the workflow on a run and opens the step view of a step with a double + * click on its node: the step view beside the canvas, with the run facts of + * the step. Returns the manual action block of the step. + */ +async function openStepView( + page: Page, + gotoWorkspace: (page: Page, target: string) => Promise, + fileName: string, + runId: string, + step: string, + nodeKey: string +): Promise { + await gotoWorkspace( + page, + `/dags/${encodeURIComponent(fileName)}?run=${encodeURIComponent(runId)}` + ); + const node = page + .getByTestId('ww-canvas') + .getByTestId(`ve-canvas-node-${nodeKey}`); + await expect(node).toBeVisible({ timeout: 30_000 }); + await node.dblclick(); + const ndv = page.getByTestId('ndv'); + await expect(ndv).toBeVisible({ timeout: 30_000 }); + await expect + .poll(() => new URL(page.url()).searchParams.get('step')) + .toBe(step); + // A narrow step view shows OUTPUT as a tab; a waiting step opens Actions. + await showStepOutput(ndv); + return ndv.getByTestId('ww-step-manual-actions'); +} + +/** Opens the Runs panel when it is collapsed. */ +async function openDock(page: Page): Promise { + const dock = page.getByTestId('ww-dock'); + if ((await dock.getAttribute('data-state')) !== 'open') { + await dock + .getByRole('button', { name: 'Activity', exact: true }) + .first() + .click(); + } + await expect(dock).toHaveAttribute('data-state', 'open'); + return dock; +} + +test.describe('workspace manual actions', () => { + // The docked step view and the dock tab strip need the desktop layout. + test.skip( + ({ viewport }) => (viewport?.width ?? 0) < 768, + 'desktop layout only' + ); + + test.beforeEach(async ({ page }) => { + await signIn(page); + }); + + // parity:approval-tab parity:run-tab-approval parity:step-review-modal + // parity:api-manual-actions + test('counts the waiting approval and approves it from the step view', async ({ + page, + request, + inlineDAG, + gotoWorkspace, + }) => { + test.setTimeout(150_000); + const token = await adminToken(request); + const { dagName, fileName } = await inlineDAG(APPROVAL); + const runId = await startDAG(request, token, fileName); + await waitForRunStatus(request, token, dagName, runId, ['waiting']); + + // The Runs panel is collapsed at load. The bar of the editor tells that + // an action waits; the Actions tab of the panel counts it. + await gotoWorkspace(page, `/dags/${encodeURIComponent(fileName)}`); + await expect(page.getByText('An action is waiting.')).toBeVisible({ + timeout: 30_000, + }); + const dock = await openDock(page); + const actionsTab = dock.getByRole('tab', { + name: 'Actions, 1 waiting', + exact: true, + }); + await expect(actionsTab).toBeVisible({ timeout: 30_000 }); + await expect(actionsTab).toContainText('1'); + + const actions = await openStepView( + page, + gotoWorkspace, + fileName, + runId, + 'review', + 'steps[1]' + ); + await expect(actions).toContainText('Approve the release', { + timeout: 30_000, + }); + await actions.getByRole('button', { name: 'Approve', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: 'Approve: review' }); + await expect(dialog).toBeVisible(); + const submit = dialog.getByRole('button', { name: 'Approve', exact: true }); + await expect(submit).toBeDisabled(); + await dialog.getByLabel(/FEEDBACK/).fill('Ship it'); + await submit.click(); + await expect(dialog).toBeHidden({ timeout: 30_000 }); + + await waitForRunStatus(request, token, dagName, runId, ['succeeded']); + expect( + await getStepStdout(request, token, dagName, runId, 'publish') + ).toContain('published'); + // Nothing waits any more: the count goes away. The panel may have + // yielded to the step view, so it opens again first. + await page.keyboard.press('Escape'); + await expect(page.getByTestId('ndv')).toBeHidden(); + const after = await openDock(page); + await expect( + after.getByRole('tab', { name: 'Actions, 1 waiting' }) + ).toHaveCount(0, { timeout: 30_000 }); + }); + + // parity:test-human-tasks-approval + test('pushes an approval back with feedback and the gate reopens', async ({ + page, + request, + inlineDAG, + gotoWorkspace, + }) => { + test.setTimeout(180_000); + const token = await adminToken(request); + const { dagName, fileName } = await inlineDAG(APPROVAL); + const runId = await startDAG(request, token, fileName); + await waitForRunStatus(request, token, dagName, runId, ['waiting']); + + const actions = await openStepView( + page, + gotoWorkspace, + fileName, + runId, + 'review', + 'steps[1]' + ); + await expect(actions).toContainText('Approve the release', { + timeout: 30_000, + }); + await actions.getByRole('button', { name: 'Retry', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: 'Retry: review' }); + await expect(dialog).toBeVisible(); + await dialog.getByLabel(/FEEDBACK/).fill('Add tests'); + await dialog.getByRole('button', { name: 'Retry', exact: true }).click(); + await expect(dialog).toBeHidden({ timeout: 30_000 }); + + // The gate runs again with the feedback, then waits again. + await expect + .poll( + () => + getStepStdout(request, token, dagName, runId, 'review', { + retryNotFound: true, + }), + { timeout: 60_000 } + ) + .toContain('feedback=Add tests'); + await waitForRunStatus(request, token, dagName, runId, ['waiting']); + await expect(actions).toContainText('Iteration', { timeout: 30_000 }); + await expect(actions.getByText('Add tests').first()).toBeVisible(); + + await actions.getByRole('button', { name: 'Approve', exact: true }).click(); + const approve = page.getByRole('dialog', { name: 'Approve: review' }); + await approve.getByLabel(/FEEDBACK/).fill('Tests added'); + await approve.getByRole('button', { name: 'Approve', exact: true }).click(); + await expect(approve).toBeHidden({ timeout: 30_000 }); + await waitForRunStatus(request, token, dagName, runId, ['succeeded']); + }); + + // The flow of e2e/human-task-push-back.spec.ts, from the step view. + // parity:human-task-card parity:run-tab-human-tasks + test('sends a human task back with feedback and completes it', async ({ + page, + request, + inlineDAG, + gotoWorkspace, + }) => { + test.setTimeout(180_000); + const token = await adminToken(request); + const { dagName, fileName } = await inlineDAG(HUMAN_TASK); + const runId = await startDAG(request, token, fileName); + await waitForRunStatus(request, token, dagName, runId, ['waiting']); + + const actions = await openStepView( + page, + gotoWorkspace, + fileName, + runId, + 'review', + 'steps[1]' + ); + await expect(actions).toContainText('Review the implementation', { + timeout: 30_000, + }); + await actions.getByRole('button', { name: 'Request changes' }).click(); + await expect( + actions.getByText( + 'implement and the steps that depend on it run again with your feedback. This task reopens afterward.' + ) + ).toBeVisible(); + await actions.getByLabel(/Feedback/).fill('Add tests'); + await actions.getByRole('button', { name: 'Request changes' }).click(); + + // The rewound step runs again with the feedback before the task reopens. + await expect + .poll( + () => + getStepStdout(request, token, dagName, runId, 'implement', { + retryNotFound: true, + }), + { timeout: 60_000 } + ) + .toContain('attempt=1 feedback=Add tests'); + await waitForRunStatus(request, token, dagName, runId, ['waiting']); + + await expect(actions.getByText('Previous Push-backs')).toBeVisible({ + timeout: 30_000, + }); + await expect( + actions.getByText('feedback="Add tests"').first() + ).toBeVisible(); + await actions.getByRole('button', { name: 'Complete task' }).click(); + + await waitForRunStatus(request, token, dagName, runId, ['succeeded']); + expect( + await getStepStdout(request, token, dagName, runId, 'publish') + ).toContain('published'); + }); +}); diff --git a/ui/e2e/workspace/ndv.spec.ts b/ui/e2e/workspace/ndv.spec.ts new file mode 100644 index 0000000000..c2cf78e001 --- /dev/null +++ b/ui/e2e/workspace/ndv.spec.ts @@ -0,0 +1,1305 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import type { + APIRequestContext, + Locator, + Page, + Response, + Route, +} from '@playwright/test'; +import { + getStepStdout, + showStepOutput, + startDAG, + uniqueName, + waitForDAGAvailable, + waitForRunStatus, + writeLocalDAG, +} from '../helpers/e2e'; +import { + adminToken, + expect, + signIn, + test as base, + type FixtureDAG, +} from './fixtures'; + +// parity:test-e2e-gaps (the step view of the canvas: NDV, step YAML, Set +// status, Retry step and the failed-run dock. The workflow is one tab: the +// canvas shows the selected run (the latest without ?run), and the Runs panel +// is at the bottom. The manual actions are in manual-actions.spec.ts) + +// NodeStatus in ui/src/api/v1/schema.ts. +const NODE_FAILED = '2'; +const NODE_SUCCEEDED = '4'; + +// Two steps whose stdout changes at each run, so a rerun shows. +const TWO_STEPS = ` +steps: + - name: first + run: echo "first-$(date +%s%N)" + - name: second + depends: [first] + run: echo "second-$(date +%s%N)" +`; + +// The second step writes a marker to stderr, then fails the run. +const FAILING = ` +steps: + - name: ok + run: echo fine + - name: broken + depends: [ok] + run: | + echo "broken-stderr-marker" >&2 + exit 3 +`; + +// The second step has no id and an out-of-order, long description; the first +// and the third are canonical, so that a change outside the second is a bug. +const LONG_WORDS = 'word '.repeat(30).trim(); +const STEP_TOOLS = ` +steps: + - id: gather + name: gather + run: echo gather + + - run: echo second # keep this + name: second step + depends: [gather] + outputs: + - name: out + description: ${LONG_WORDS} end + + - id: third + name: third + run: echo third +`; + +// The second step has no id, as the two others have: the editor offers the +// quick fix that adds one. +const NO_ID = ` +steps: + - name: first step + id: first_step + run: echo first + - name: second step + depends: [first_step] + run: echo second + - name: third step + id: third_step + depends: [first_step] + run: echo third +`; + +const test = base.extend<{ + /** Writes the YAML under a unique name; the DAG is deleted after the test. */ + inlineDAG: (spec: string, prefix?: string) => Promise; +}>({ + inlineDAG: async ({ request }, use) => { + const token = await adminToken(request); + const created: string[] = []; + await use(async (spec, prefix = 'e2e-ws-ndv') => { + const dagName = uniqueName(prefix); + const fileName = await writeLocalDAG(dagName, spec); + created.push(fileName); + await waitForDAGAvailable(request, token, fileName); + return { dagName, fileName }; + }); + for (const fileName of created) { + await request.delete( + `/api/v1/dags/${encodeURIComponent(fileName)}?remoteNode=local`, + { headers: { Authorization: `Bearer ${token}` } } + ); + } + }, +}); + +/** The saved text of the DAG, as the server has it. */ +async function readSpecOf( + request: APIRequestContext, + token: string, + fileName: string +): Promise { + const response = await request.get( + `/api/v1/dags/${encodeURIComponent(fileName)}/spec?remoteNode=local`, + { headers: { Authorization: `Bearer ${token}` } } + ); + expect(response.ok()).toBeTruthy(); + return ((await response.json()) as { spec: string }).spec; +} + +/** The position and size of an element, in whole pixels. */ +async function box(locator: Locator): Promise { + const b = await locator.boundingBox(); + return b ? [b.x, b.y, b.width, b.height].map(Math.round).join(',') : 'none'; +} + +/** The status of one step in a run, as the server has it. */ +async function serverNodeStatus( + request: APIRequestContext, + token: string, + dagName: string, + dagRunId: string, + step: string +): Promise { + const response = await request.get( + `/api/v1/dag-runs/${encodeURIComponent(dagName)}/${encodeURIComponent(dagRunId)}?remoteNode=local`, + { headers: { Authorization: `Bearer ${token}` } } + ); + expect(response.ok()).toBeTruthy(); + const body = (await response.json()) as { + dagRunDetails: { nodes?: { step: { name: string }; status: number }[] }; + }; + return body.dagRunDetails.nodes?.find((node) => node.step.name === step) + ?.status; +} + +function canvasNode(page: Page, key: string): Locator { + return page.getByTestId('ww-canvas').getByTestId(`ve-canvas-node-${key}`); +} + +/** A node of the read-only canvas of the run definition (?def=run). */ +function runNode(page: Page, key: string): Locator { + return page + .getByTestId('ww-run-definition') + .getByTestId(`ve-canvas-node-${key}`); +} + +/** The status that the selected run puts on a node of the canvas. */ +function nodeStatus(page: Page, key: string): Locator { + return canvasNode(page, key).getByTestId('ve-canvas-node-status'); +} + +/** The URL of the workflow, on one run when given. */ +function workflowPath(fileName: string, runId?: string): string { + const base = `/dags/${encodeURIComponent(fileName)}`; + return runId ? `${base}?run=${encodeURIComponent(runId)}` : base; +} + +function saveButton(page: Page): Locator { + return page.locator('#save-config'); +} + +function query(page: Page, key: string): string | null { + return new URL(page.url()).searchParams.get(key); +} + +/** Opens the workspace editor and waits for the canvas to show the first step. */ +async function openWorkspace( + page: Page, + gotoWorkspace: (page: Page, target: string) => Promise, + target: string +): Promise { + await gotoWorkspace(page, target); + await expect(page.getByTestId('workflow-workspace')).toBeVisible({ + timeout: 30_000, + }); + await expect(canvasNode(page, 'steps[0]')).toBeVisible({ timeout: 30_000 }); +} + +/** Opens the node menu of the canvas with a right-click and picks a row. */ +async function pickNodeMenu( + page: Page, + key: string, + label: string +): Promise { + await canvasNode(page, key).click({ button: 'right' }); + const menu = page.getByTestId('ve-canvas-menu'); + await expect(menu).toBeVisible(); + await menu.getByRole('menuitem', { name: label, exact: true }).click(); +} + +/** Sets the step to Failed in the Update status dialog. */ +async function setFailed(page: Page, key: string): Promise { + await pickNodeMenu(page, key, 'Change status'); + const dialog = page.getByRole('dialog', { name: 'Update status' }); + await expect(dialog).toBeVisible(); + await dialog.getByRole('button', { name: 'Failed' }).click(); + await expect(dialog).toBeHidden({ timeout: 30_000 }); +} + +test.describe('workspace node details view', () => { + // The docked NDV, the node menu and the dock tab strip need the desktop + // layout. + test.skip( + ({ viewport }) => (viewport?.width ?? 0) < 768, + 'desktop layout only' + ); + + test.beforeEach(async ({ page }) => { + await signIn(page); + }); + + // parity:drawer-url-sync parity:run-graph-click-inspect + test('opens the node view on a double-click with the outputs of the run', async ({ + page, + request, + fixtureDAG, + gotoWorkspace, + }) => { + test.setTimeout(150_000); + const token = await adminToken(request); + const { dagName, fileName } = await fixtureDAG('visual-params.yaml'); + const runId = await startDAG(request, token, fileName); + await waitForRunStatus(request, token, dagName, runId, [ + 'succeeded', + 'partially_succeeded', + ]); + + await openWorkspace(page, gotoWorkspace, workflowPath(fileName)); + // One tab: the editor shows the latest run on its canvas and in the bar, + // and the Runs panel is collapsed at load. + await expect(page.getByTestId('ww-run-chip')).toBeVisible(); + await expect(page.getByTestId('ww-dock')).toHaveAttribute( + 'data-state', + 'collapsed' + ); + await expect(nodeStatus(page, 'steps[0]')).toHaveAttribute( + 'data-status', + NODE_SUCCEEDED, + { timeout: 60_000 } + ); + + // A single click only selects the step. It does not open the Runs panel. + await canvasNode(page, 'steps[0]').click(); + await expect.poll(() => query(page, 'step')).toBe('prepare'); + await expect(page.getByTestId('ndv')).toBeHidden(); + expect(query(page, 'ndv')).toBeNull(); + await expect(page.getByTestId('ww-dock')).toHaveAttribute( + 'data-state', + 'collapsed' + ); + + await canvasNode(page, 'steps[0]').dblclick(); + const ndv = page.getByTestId('ndv'); + await expect(ndv).toBeVisible({ timeout: 30_000 }); + await expect(ndv).toHaveAttribute('data-state', 'expanded'); + await expect.poll(() => query(page, 'step')).toBe('prepare'); + await expect.poll(() => query(page, 'ndv')).toBe('expanded'); + + // The view sits below the workflow bar, so that Start and the run menu + // stay clickable. + const bar = await page.getByTestId('ww-bar').boundingBox(); + expect((await ndv.boundingBox())!.y).toBeGreaterThanOrEqual( + bar!.y + bar!.height - 0.5 + ); + + // INPUT | CENTER | OUTPUT. The output column is the right side of the + // Runs panel for this step: the facts line and the tabs of the latest run. + await expect(ndv.getByRole('button', { name: 'Run step' })).toBeVisible(); + await expect(ndv.getByRole('region', { name: 'Input' })).toBeVisible(); + const output = ndv.getByRole('region', { name: 'Output', exact: true }); + await expect(output).toBeVisible(); + await expect(output.getByTestId('ww-step-output')).toHaveAttribute( + 'data-run-id', + runId + ); + await expect(output.getByTestId('ww-step-facts-line')).toBeVisible(); + await expect( + output.getByRole('tab', { name: 'Output', exact: true }) + ).toBeVisible(); + + // Escape closes the view and keeps the selection. It comes before the + // trial clicks below: their hover opens a tooltip that takes one Escape. + await page.keyboard.press('Escape'); + await expect(ndv).toBeHidden(); + await expect.poll(() => query(page, 'ndv')).toBeNull(); + expect(query(page, 'step')).toBe('prepare'); + + // Open again: the view leaves Start and the run menu clickable. + await canvasNode(page, 'steps[0]').dblclick(); + await expect(ndv).toBeVisible({ timeout: 30_000 }); + await expect(ndv).toHaveAttribute('data-state', 'expanded'); + await page + .locator('[data-ww-action="start"]') + .click({ trial: true, timeout: 5_000 }); + }); + + // parity:drawer-url-sync + test('shows the declared outputs of a step that never ran', async ({ + page, + fixtureDAG, + gotoWorkspace, + }) => { + test.setTimeout(120_000); + const { fileName } = await fixtureDAG('visual-params.yaml'); + + await openWorkspace(page, gotoWorkspace, workflowPath(fileName)); + // No run: the bar has no run chip. + await expect(page.getByTestId('ww-run-chip')).toHaveCount(0); + await canvasNode(page, 'steps[0]').dblclick(); + const ndv = page.getByTestId('ndv'); + await expect(ndv).toBeVisible({ timeout: 30_000 }); + const output = ndv.getByRole('region', { name: 'Output', exact: true }); + await expect( + output.getByRole('region', { name: 'Declared outputs' }) + ).toContainText('token', { timeout: 30_000 }); + }); + + // parity:drawer-url-sync parity:run-graph-click-inspect + test('shows the run of the step in the node view and the Run panel', async ({ + page, + request, + fixtureDAG, + gotoWorkspace, + }) => { + test.setTimeout(150_000); + const token = await adminToken(request); + const { dagName, fileName } = await fixtureDAG('visual-params.yaml'); + const runId = await startDAG(request, token, fileName); + await waitForRunStatus(request, token, dagName, runId, [ + 'succeeded', + 'partially_succeeded', + ]); + + // No ?run: the latest run. + await openWorkspace(page, gotoWorkspace, workflowPath(fileName)); + await expect(nodeStatus(page, 'steps[0]')).toHaveAttribute( + 'data-status', + NODE_SUCCEEDED, + { timeout: 60_000 } + ); + + // The run chip keeps its own place in the bar: none of its facts runs + // under the Start button. + const start = page.locator('[data-ww-action="start"]'); + const latest = page.getByTestId('ww-run-chip').getByTestId('ww-run-latest'); + const startBox = await start.boundingBox(); + const latestBox = await latest.boundingBox(); + expect(latestBox!.x + latestBox!.width).toBeLessThanOrEqual(startBox!.x); + + const ndv = page.getByTestId('ndv'); + const dock = page.getByTestId('ww-dock'); + // A click selects the step: the canvas and the step list of the Runs + // panel follow. The panel stays collapsed and the node view stays closed. + await canvasNode(page, 'steps[0]').click(); + await expect.poll(() => query(page, 'step')).toBe('prepare'); + expect(query(page, 'ndv')).toBeNull(); + await expect(ndv).toBeHidden(); + await expect(dock).toHaveAttribute('data-state', 'collapsed'); + + // A double click opens the node view, with the facts of the run in the + // OUTPUT column. + await canvasNode(page, 'steps[0]').dblclick(); + await expect(ndv).toBeVisible({ timeout: 30_000 }); + await expect(ndv).toHaveAttribute('data-state', 'expanded'); + await expect.poll(() => query(page, 'step')).toBe('prepare'); + await expect.poll(() => query(page, 'ndv')).toBe('expanded'); + await expect(page.getByTestId('step-side-drawer')).toHaveCount(0); + + // The YAML of the view is the editor of the step alone: coloured, from + // line 1, no change bars while nothing changed. + const yaml = ndv.getByTestId('ndv-center').locator('.monaco-editor'); + await expect(yaml).toBeVisible({ timeout: 30_000 }); + const yamlLines = yaml.locator('.view-lines'); + await expect(yamlLines).toContainText('DAGU_OUTPUT_FILE', { + timeout: 30_000, + }); + await expect(yamlLines).not.toContainText('GREETING'); + await expect(yamlLines).not.toContainText('flaky'); + await expect(yaml.locator('.line-numbers').first()).toHaveText('1'); + await expect(yamlLines.locator('[class*="mtk"]').first()).toBeVisible(); + // Many colours: keys, strings and the rest are not one plain text. + await expect + .poll(async () => + yamlLines + .locator('span[class*="mtk"]') + .evaluateAll( + (spans) => + new Set(spans.map((span) => getComputedStyle(span).color)).size + ) + ) + .toBeGreaterThan(1); + await expect(yaml.locator('.dag-change-added')).toHaveCount(0); + await expect(yaml.locator('.dag-change-modified')).toHaveCount(0); + await expect(ndv.getByRole('button', { name: 'Copy YAML' })).toBeVisible(); + + // The output column is a tab when the view is narrow: the facts line of + // the step, then the tabs of the latest run. + await showStepOutput(ndv); + const stepOutput = ndv.locator('#ndv-output').getByTestId('ww-step-output'); + await expect(stepOutput).toHaveAttribute('data-run-id', runId); + await expect(stepOutput.getByTestId('ww-step-facts-line')).toContainText( + 'Succeeded' + ); + // The Output tab shows the output that the step wrote (token=abc123). + await stepOutput.getByRole('tab', { name: 'Output', exact: true }).click(); + await expect(stepOutput).toContainText('abc123', { timeout: 30_000 }); + + // Escape closes the node view and keeps the step; a second Escape clears + // the step. + await page.keyboard.press('Escape'); + await expect(ndv).toBeHidden(); + await expect.poll(() => query(page, 'ndv')).toBeNull(); + expect(query(page, 'step')).toBe('prepare'); + await page.keyboard.press('Escape'); + await expect.poll(() => query(page, 'step')).toBeNull(); + + // A panel that the user opens before the view shows stays open when the + // view closes: the expanded view covers the tab of the panel. + await dock + .getByRole('button', { name: 'Activity', exact: true }) + .first() + .click(); + await expect(dock).toHaveAttribute('data-state', 'open'); + + // The Open step command of the node toolbar opens the node view again. + await canvasNode(page, 'steps[0]').click(); + await canvasNode(page, 'steps[0]') + .locator('..') + .getByRole('button', { name: 'Open step', exact: true }) + .click(); + await expect(ndv).toBeVisible({ timeout: 30_000 }); + await expect.poll(() => query(page, 'step')).toBe('prepare'); + await expect.poll(() => query(page, 'ndv')).toBe('expanded'); + + await page.keyboard.press('Escape'); + await expect(ndv).toBeHidden(); + await expect(dock).toHaveAttribute('data-state', 'open'); + }); + + // The YAML button of the toolbar opens the whole definition in the same + // right panel on the editor and on the run definition (?def=run). The + // run definition is read-only and takes no edit. The stale dot of the run + // chip shows it, and the dot or the back button ends it. + // parity:drawer-url-sync + test('opens the whole-file YAML panel on the run definition, as the editor does', async ({ + page, + request, + fixtureDAG, + gotoWorkspace, + }) => { + test.setTimeout(180_000); + const token = await adminToken(request); + const { dagName, fileName } = await fixtureDAG('visual-params.yaml'); + const runId = await startDAG(request, token, fileName); + await waitForRunStatus(request, token, dagName, runId, [ + 'succeeded', + 'partially_succeeded', + ]); + // The saved file changes after the run: the run used another definition. + await writeLocalDAG( + dagName, + `${readFileSync(path.resolve(__dirname, '../fixtures/dags/visual-params.yaml'), 'utf8')} + - name: added-after-run + depends: [report] + run: echo added +` + ); + const rect = (locator: Locator) => + locator.evaluate((element) => { + const r = element.getBoundingClientRect(); + return { + x: Math.round(r.x), + y: Math.round(r.y), + width: Math.round(r.width), + height: Math.round(r.height), + }; + }); + // The box of a part of the panel, from the top right corner of the graph + // zone that holds it: the zones of the two views differ in their size. + const measure = async (zone: Locator, zoneBox: Locator) => { + const ndv = zone.getByTestId('ndv'); + const origin = await zoneBox.evaluate((element) => { + const r = element.getBoundingClientRect(); + return { right: r.right, top: r.top, bottom: r.bottom }; + }); + const rel = async (locator: Locator) => { + const r = await rect(locator); + return { + fromRight: origin.right - (r.x + r.width), + fromTop: r.y - origin.top, + fromBottom: origin.bottom - (r.y + r.height), + width: r.width, + height: r.height, + }; + }; + return { + panel: await rel(ndv), + header: await rel(ndv.locator('[data-panel-header]')), + close: await rel(ndv.getByRole('button', { name: 'Close' })), + }; + }; + + // The editor first: the reference of the geometry. + await openWorkspace(page, gotoWorkspace, workflowPath(fileName, runId)); + const toolbar = page.getByTestId('ve-toolbar'); + await toolbar.getByRole('button', { name: 'YAML', exact: true }).click(); + const editorNdv = page.getByTestId('ww-pane').getByTestId('ndv'); + await expect(editorNdv).toHaveAttribute('data-state', 'docked', { + timeout: 30_000, + }); + await expect(editorNdv.locator('.monaco-editor')).toBeVisible({ + timeout: 30_000, + }); + expect(query(page, 'ndv')).toBe('docked'); + const editorBox = await measure( + page.getByTestId('ww-pane'), + page.getByTestId('ww-canvas') + ); + const editorButton = await rect( + toolbar.getByRole('button', { name: 'YAML', exact: true }) + ); + + // The stale dot opens the definition of the run in place of the editor. + await page.goto(workflowPath(fileName, runId)); + const dot = page.getByTestId('ww-run-stale-dot'); + await expect(dot).toBeVisible({ timeout: 30_000 }); + await expect(dot).toHaveAttribute('aria-pressed', 'false'); + await dot.click(); + await expect.poll(() => query(page, 'def')).toBe('run'); + await expect(dot).toHaveAttribute('aria-pressed', 'true'); + const definition = page.getByTestId('ww-run-definition'); + await expect(definition).toBeVisible({ timeout: 30_000 }); + await expect(definition).toHaveAttribute('data-run-id', runId); + const zone = definition.getByTestId('ww-run-zone'); + await expect( + runNode(page, 'steps[0]'), + 'the nodes of the run, not the added step' + ).toBeVisible({ timeout: 30_000 }); + await expect(runNode(page, 'steps[3]')).toHaveCount(0); + const yamlButton = zone + .getByTestId('ve-toolbar') + .getByRole('button', { name: 'YAML', exact: true }); + await expect(yamlButton).toHaveAttribute('aria-pressed', 'false'); + await yamlButton.focus(); + await yamlButton.click(); + const ndv = zone.getByTestId('ndv'); + await expect(ndv).toHaveAttribute('data-state', 'docked', { + timeout: 30_000, + }); + await expect(ndv).toHaveAttribute('data-panel-shell', 'right'); + await expect(yamlButton).toHaveAttribute('aria-pressed', 'true'); + await expect.poll(() => query(page, 'ndv')).toBe('docked'); + expect(query(page, 'step')).toBeNull(); + + // The header: the YAML title, the run identity and the lock. + const header = ndv.locator('[data-panel-header]'); + await expect(header.getByRole('heading', { name: 'YAML' })).toBeVisible(); + await expect(ndv.getByTestId('readonly-marker')).toBeVisible(); + await expect(ndv.getByTestId('yaml-run-ref')).toHaveText(runId.slice(0, 8)); + expect((await rect(header)).height, 'the header is 48 px high').toBe(48); + + // The editor: the whole file of the run, coloured, absolute lines, + // read-only. + const yaml = ndv.locator('.monaco-editor'); + await expect(yaml).toBeVisible({ timeout: 30_000 }); + const lines = yaml.locator('.view-lines'); + await expect(lines).toContainText('prepare', { timeout: 30_000 }); + await expect(lines).toContainText('DAGU_OUTPUT_FILE'); + await expect(lines).not.toContainText('added-after-run'); + await expect(yaml.locator('.line-numbers').first()).toHaveText('1'); + await expect + .poll(async () => + lines + .locator('span[class*="mtk"]') + .evaluateAll( + (spans) => + new Set(spans.map((span) => getComputedStyle(span).color)).size + ) + ) + .toBeGreaterThan(1); + await expect(yaml.locator('.dag-change-added')).toHaveCount(0); + await expect(yaml.locator('.dag-change-modified')).toHaveCount(0); + // No tools of the editor. + await expect(ndv.getByTestId('yaml-format')).toHaveCount(0); + await expect(ndv.getByTestId('yaml-fix-all')).toHaveCount(0); + await expect(ndv.getByRole('button', { name: 'Open step' })).toHaveCount(0); + await expect(zone.getByRole('button', { name: 'Save' })).toHaveCount(0); + + // A typed key changes nothing: the text of the run stays. + const before = await lines.innerText(); + await lines.click(); + await page.keyboard.type('zzz-not-typed'); + await expect(lines).not.toContainText('zzz-not-typed'); + expect(await lines.innerText()).toBe(before); + + // The same box in both views. + const runBox = await measure(zone, zone); + // The panel is one box from the top of the zone to the bottom: its + // header stands under the row of the canvas toolbars. Its height is the + // one of the zone, which differs between the views. + expect(runBox.panel).toMatchObject({ + fromRight: 0, + fromTop: 0, + fromBottom: 0, + width: editorBox.panel.width, + }); + const top = (box: typeof runBox.header) => ({ + fromRight: box.fromRight, + fromTop: box.fromTop, + width: box.width, + height: box.height, + }); + expect(top(runBox.header)).toEqual(top(editorBox.header)); + expect(top(runBox.close)).toEqual(top(editorBox.close)); + // The YAML button sits at the same place of the toolbar, with its look. + const runButton = await rect(yamlButton); + expect(runButton.width).toBe(editorButton.width); + expect(runButton.height).toBe(editorButton.height); + + // The Copy overlay copies the definition of the run. + await page + .context() + .grantPermissions(['clipboard-read', 'clipboard-write']); + await ndv.getByRole('button', { name: 'Copy YAML' }).click(); + await expect( + ndv.getByRole('button', { name: 'YAML copied' }) + ).toBeVisible(); + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toContain('prepare'); + + // Escape closes the panel; the focus goes back to the YAML button. + await header.click(); + await page.keyboard.press('Escape'); + await expect(ndv).toBeHidden(); + await expect.poll(() => query(page, 'ndv')).toBeNull(); + await expect(yamlButton).toBeFocused(); + await expect(yamlButton).toHaveAttribute('aria-pressed', 'false'); + + // The back button ends the view of the run definition. + await definition.getByTestId('ww-run-snapshot-back').click(); + await expect.poll(() => query(page, 'def')).toBeNull(); + await expect(definition).toHaveCount(0); + await expect(dot).toHaveAttribute('aria-pressed', 'false'); + }); + + // The YAML editor never unmounts when the frame changes its layout. + // parity:chg-step-yaml-live + test('keeps the typed step YAML across the form, and undoes it', async ({ + page, + fixtureDAG, + gotoWorkspace, + }) => { + test.setTimeout(120_000); + const { fileName } = await fixtureDAG('visual-params.yaml'); + + await openWorkspace( + page, + gotoWorkspace, + `/dags/${encodeURIComponent(fileName)}?step=prepare&ndv=expanded` + ); + const ndv = page.getByTestId('ndv'); + await expect(ndv).toHaveAttribute('data-state', 'expanded', { + timeout: 30_000, + }); + const editor = page.getByTestId('ndv-center').locator('.monaco-editor'); + await expect(editor).toBeVisible({ timeout: 30_000 }); + const lines = editor.locator('.view-lines'); + await expect(saveButton(page)).toBeDisabled(); + + // A YAML comment at the end of the run line of the step. + await editor + .locator('.view-line') + .filter({ hasText: 'DAGU_OUTPUT_FILE' }) + .click(); + await page.keyboard.press('End'); + await page.keyboard.type(' #ndvmarker'); + await expect(lines).toContainText('ndvmarker'); + await expect(saveButton(page)).toBeEnabled({ timeout: 30_000 }); + + // The node view has no dock button: the form and the YAML take turns. + await expect( + ndv.getByRole('button', { name: 'Dock to the side', exact: true }) + ).toHaveCount(0); + await ndv.getByRole('button', { name: 'Form', exact: true }).click(); + await ndv.getByRole('button', { name: 'YAML', exact: true }).click(); + await expect(ndv).toHaveAttribute('data-state', 'expanded'); + await expect(lines).toContainText('ndvmarker'); + + // The undo history survived too. Monaco may keep the typing as more + // than one undo step; an undo at the bottom of the stack does nothing. + await editor + .locator('.view-line') + .filter({ hasText: 'DAGU_OUTPUT_FILE' }) + .click(); + await expect(async () => { + await page.keyboard.press('ControlOrMeta+z'); + await expect(lines).not.toContainText('ndvmarker', { timeout: 1_000 }); + }).toPass({ timeout: 15_000 }); + await expect(saveButton(page)).toBeDisabled({ timeout: 30_000 }); + }); + + // The save state follows the document, not the characters: a blank line + // is no change. The numbers of the step view start at 1. + test('keeps Save off for a blank line and numbers the step lines from 1', async ({ + page, + fixtureDAG, + gotoWorkspace, + }) => { + test.setTimeout(120_000); + const { fileName } = await fixtureDAG('visual-params.yaml'); + + await openWorkspace( + page, + gotoWorkspace, + `/dags/${encodeURIComponent(fileName)}?step=prepare&ndv=expanded` + ); + const editor = page.getByTestId('ndv-center').locator('.monaco-editor'); + await expect(editor).toBeVisible({ timeout: 30_000 }); + await expect(saveButton(page)).toBeDisabled(); + await expect(editor.locator('.line-numbers').first()).toHaveText('1'); + + await editor + .locator('.view-line') + .filter({ hasText: 'DAGU_OUTPUT_FILE' }) + .click(); + await page.keyboard.press('End'); + await page.keyboard.press('Enter'); + await page.keyboard.press('Enter'); + await expect(editor.locator('.line-numbers').first()).toHaveText('1'); + // The two new lines are in reach of the cursor and change nothing. + await page.keyboard.type(' '); + await expect(saveButton(page)).toBeDisabled(); + await expect(editor.locator('.dag-change-added')).toHaveCount(0); + + // A real change turns Save on. + await page.keyboard.type('# real'); + await expect(saveButton(page)).toBeEnabled({ timeout: 30_000 }); + }); + + for (const [label, pinned] of [ + ['latest run', false], + ['pinned run', true], + ] as const) { + // parity:graph-rightclick-status parity:steps-status-update-modal + // parity:api-step-status + test(`sets a step status from the node menu before and after the refresh (${label})`, async ({ + page, + request, + inlineDAG, + gotoWorkspace, + }) => { + test.setTimeout(180_000); + const token = await adminToken(request); + const { dagName, fileName } = await inlineDAG(TWO_STEPS); + const runId = await startDAG(request, token, fileName); + await waitForRunStatus(request, token, dagName, runId, ['succeeded']); + + await openWorkspace( + page, + gotoWorkspace, + workflowPath(fileName, pinned ? runId : undefined) + ); + const first = nodeStatus(page, 'steps[0]'); + await expect(first).toHaveAttribute('data-status', NODE_SUCCEEDED, { + timeout: 60_000, + }); + + // Optimistic: the server never sees this change, and the refresh of the + // run waits until the ring has changed. + // The latest run comes with the DAG; its own read is held too. + const dagPath = `/dags/${encodeURIComponent(fileName)}`; + const runPath = `/dag-runs/${encodeURIComponent(dagName)}/${encodeURIComponent(runId)}`; + const isDetails = (url: URL) => + url.pathname.endsWith(dagPath) || url.pathname.endsWith(runPath); + const isStatusPatch = (url: URL) => + url.pathname.endsWith('/steps/first/status'); + let holding = false; + let release = () => {}; + const released = new Promise((resolve) => { + release = resolve; + }); + const holdDetails = async (route: Route) => { + if (holding && route.request().method() === 'GET') await released; + await route.continue(); + }; + const fakePatch = async (route: Route) => { + if (route.request().method() !== 'PATCH') { + await route.continue(); + return; + } + holding = true; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: '{}', + }); + }; + await page.route(isDetails, holdDetails); + await page.route(isStatusPatch, fakePatch); + try { + await setFailed(page, 'steps[0]'); + await expect(first).toHaveAttribute('data-status', NODE_FAILED); + expect( + await serverNodeStatus(request, token, dagName, runId, 'first') + ).toBe(Number(NODE_SUCCEEDED)); + } finally { + release(); + await page.unroute(isStatusPatch, fakePatch); + await page.unroute(isDetails, holdDetails); + } + + // The optimistic patch lives in the page only. + await page.reload(); + await expect(first).toHaveAttribute('data-status', NODE_SUCCEEDED, { + timeout: 60_000, + }); + + // The real change keeps the ring after the refresh and after a reload. + // Responses arrive in order: a details read after the PATCH response is + // the refresh (or a later read). + let patchOk: boolean | null = null; + let readsAfterPatch = 0; + const onResponse = (response: Response) => { + const url = new URL(response.url()); + const method = response.request().method(); + if (method === 'PATCH' && isStatusPatch(url)) { + patchOk = response.ok(); + } else if (patchOk !== null && method === 'GET' && isDetails(url)) { + readsAfterPatch += 1; + } + }; + page.on('response', onResponse); + try { + await setFailed(page, 'steps[0]'); + await expect.poll(() => patchOk, { timeout: 30_000 }).toBe(true); + await expect(first).toHaveAttribute('data-status', NODE_FAILED); + await expect + .poll(() => readsAfterPatch, { timeout: 30_000 }) + .toBeGreaterThan(0); + await expect(first).toHaveAttribute('data-status', NODE_FAILED); + } finally { + page.off('response', onResponse); + } + expect( + await serverNodeStatus(request, token, dagName, runId, 'first') + ).toBe(Number(NODE_FAILED)); + await page.reload(); + await expect(first).toHaveAttribute('data-status', NODE_FAILED, { + timeout: 60_000, + }); + }); + } + + // parity:run-step-retry parity:steps-actions-menu + test('retries a step and its downstream steps from the node menu', async ({ + page, + request, + inlineDAG, + gotoWorkspace, + }) => { + test.setTimeout(180_000); + const token = await adminToken(request); + const { dagName, fileName } = await inlineDAG(TWO_STEPS); + const runId = await startDAG(request, token, fileName); + await waitForRunStatus(request, token, dagName, runId, ['succeeded']); + const firstBefore = await getStepStdout( + request, + token, + dagName, + runId, + 'first' + ); + const secondBefore = await getStepStdout( + request, + token, + dagName, + runId, + 'second' + ); + + await openWorkspace(page, gotoWorkspace, workflowPath(fileName, runId)); + await expect(nodeStatus(page, 'steps[0]')).toHaveAttribute( + 'data-status', + NODE_SUCCEEDED, + { timeout: 60_000 } + ); + + await pickNodeMenu(page, 'steps[0]', 'Retry step'); + const dialog = page.getByRole('dialog', { name: 'Retry this step?' }); + await expect(dialog).toBeVisible(); + const thisOnly = dialog.getByRole('radio', { name: /This step only/ }); + const downstream = dialog.getByRole('radio', { + name: /This step and all downstream steps/, + }); + await expect(thisOnly).toBeChecked(); + await downstream.check(); + await expect(downstream).toBeChecked(); + await dialog.getByRole('button', { name: 'Retry', exact: true }).click(); + await expect(dialog).toBeHidden({ timeout: 30_000 }); + + // The step and its downstream step ran again in the same run. A reset + // step has no log yet (''), so only a new marker line counts. + const rerun = async (step: string, before: string) => { + const stdout = await getStepStdout(request, token, dagName, runId, step, { + retryNotFound: true, + }); + return new RegExp(`${step}-\\S+`).test(stdout) && stdout !== before; + }; + await expect + .poll(() => rerun('first', firstBefore), { timeout: 60_000 }) + .toBe(true); + await expect + .poll(() => rerun('second', secondBefore), { timeout: 60_000 }) + .toBe(true); + await waitForRunStatus(request, token, dagName, runId, ['succeeded']); + await expect(nodeStatus(page, 'steps[1]')).toHaveAttribute( + 'data-status', + NODE_SUCCEEDED, + { timeout: 60_000 } + ); + }); + + // A link with ?run opens the Runs panel at load. The failed step of the + // run chip opens the Log tab of that step, on its stderr. + // parity:execution-log-viewer parity:run-log-viewer + test('opens the Runs panel on ?run and the failed step log from the chip', async ({ + page, + request, + inlineDAG, + gotoWorkspace, + }) => { + test.setTimeout(150_000); + const token = await adminToken(request); + const { dagName, fileName } = await inlineDAG(FAILING); + const runId = await startDAG(request, token, fileName); + await waitForRunStatus(request, token, dagName, runId, ['failed']); + + await openWorkspace(page, gotoWorkspace, workflowPath(fileName, runId)); + const dock = page.getByTestId('ww-dock'); + await expect(dock).toHaveAttribute('data-state', 'open', { + timeout: 30_000, + }); + await expect( + dock.getByRole('tab', { name: 'Log', exact: true }) + ).toHaveAttribute('aria-selected', 'true'); + // The link names no step: the run summary is current. + expect(query(page, 'step')).toBeNull(); + + await page + .getByTestId('ww-run-chip') + .getByRole('button', { name: 'Failed at broken' }) + .click(); + await expect.poll(() => query(page, 'step')).toBe('broken'); + await expect(dock.locator('li[data-step="broken"]')).toHaveAttribute( + 'aria-current', + 'true' + ); + const log = dock.getByTestId('ww-step-run'); + await expect(log).toHaveAttribute('data-step', 'broken'); + await expect(log).toHaveAttribute('data-variant', 'logs'); + await expect(log).toContainText('broken-stderr-marker', { + timeout: 30_000, + }); + }); + + // A run that the user starts in this tab and that fails opens the Runs + // panel by itself, on the Log of the failed step. The URL stays as it is. + // parity:execution-log-viewer parity:run-log-viewer + test('opens the Log tab on the failed step of a run started here', async ({ + page, + inlineDAG, + gotoWorkspace, + }) => { + test.setTimeout(150_000); + const { fileName } = await inlineDAG(FAILING); + + await openWorkspace(page, gotoWorkspace, workflowPath(fileName)); + const dock = page.getByTestId('ww-dock'); + await expect(dock).toHaveAttribute('data-state', 'collapsed'); + await page.locator('[data-ww-action="start"]').click(); + const dialog = page.getByRole('dialog', { name: 'Start workflow' }); + await expect(dialog).toBeVisible(); + await dialog.getByRole('button', { name: 'Start', exact: true }).click(); + + await expect(dock).toHaveAttribute('data-state', 'open', { + timeout: 60_000, + }); + await expect( + dock.getByRole('tab', { name: 'Log', exact: true }) + ).toHaveAttribute('aria-selected', 'true'); + await expect(dock.locator('li[data-step="broken"]')).toHaveAttribute( + 'aria-current', + 'true' + ); + const log = dock.getByTestId('ww-step-run'); + await expect(log).toHaveAttribute('data-step', 'broken'); + await expect(log).toHaveAttribute('data-variant', 'logs'); + await expect(log).toContainText('broken-stderr-marker', { + timeout: 30_000, + }); + // The auto-open never writes ?step or ?dock. + expect(query(page, 'step')).toBeNull(); + expect(query(page, 'dock')).toBeNull(); + }); + + // The wand and the format of the step YAML act on the step only, like the + // ones of the whole file act on the file, and one undo takes each back. + test('applies the quick fixes and the format to the step YAML only', async ({ + page, + request, + inlineDAG, + gotoWorkspace, + }) => { + test.setTimeout(150_000); + const token = await adminToken(request); + const { fileName } = await inlineDAG(STEP_TOOLS, 'e2e-ws-steptools'); + const before = readSpecOf(request, token, fileName); + + await openWorkspace( + page, + gotoWorkspace, + `/dags/${encodeURIComponent(fileName)}?step=${encodeURIComponent('second step')}&ndv=expanded` + ); + const ndv = page.getByTestId('ndv'); + await expect(ndv).toHaveAttribute('data-state', 'expanded', { + timeout: 30_000, + }); + const editor = page.getByTestId('ndv-center').locator('.monaco-editor'); + await expect(editor).toBeVisible({ timeout: 30_000 }); + const lines = editor.locator('.view-lines'); + await expect(lines).toContainText('second step', { timeout: 30_000 }); + // The YAML of one step has the tools of the step, not those of the file. + await expect(ndv.getByTestId('yaml-fix-all')).toHaveCount(0); + await expect(ndv.getByTestId('yaml-format')).toHaveCount(0); + const wand = ndv.getByTestId('step-yaml-fix'); + const format = ndv.getByTestId('step-yaml-format'); + await expect(wand).toHaveAttribute( + 'aria-label', + 'Apply quick fixes to this step' + ); + await expect(format).toHaveAttribute('aria-label', 'Format this step'); + await expect(wand).toBeEnabled({ timeout: 30_000 }); + await expect(format).toBeEnabled(); + const enabledBox = [await box(wand), await box(format)]; + const beforeText = (await lines.innerText()).replace(/\u00a0/g, ' '); + + // The wand adds the id of this step, and only this step. + await wand.click(); + await expect(lines).toContainText('id: second_step', { timeout: 30_000 }); + await expect(wand).toBeDisabled({ timeout: 30_000 }); + await expect(wand).toHaveAttribute('title', 'No quick fix'); + expect([await box(wand), await box(format)]).toEqual(enabledBox); + + // The format puts the keys in order and folds the long description. + await format.click(); + await expect(lines).toContainText('# keep this'); + // Monaco writes the spaces of a line as no-break spaces. + const text = (await lines.innerText()).replace(/\u00a0/g, ' '); + const rows = text.split('\n').filter((line) => line.trim() !== ''); + expect( + rows.every((line) => line.length <= 100), + text + ).toBe(true); + expect(text.indexOf('id: second_step')).toBeLessThan( + text.indexOf('name: second step') + ); + expect(text.indexOf('name: second step')).toBeLessThan( + text.indexOf('run: echo second') + ); + await expect(saveButton(page)).toBeEnabled(); + + // One undo takes back the format, one more the id. + await editor + .locator('.view-line') + .filter({ hasText: 'echo second' }) + // The lines are as wide as the long description: aim at their start. + .click({ position: { x: 30, y: 5 } }); + const shown = async () => (await lines.innerText()).replace(/\u00a0/g, ' '); + await page.keyboard.press('ControlOrMeta+z'); + await expect.poll(shown).toContain('echo second # keep this'); + expect(await shown()).toContain('id: second_step'); + await page.keyboard.press('ControlOrMeta+z'); + await expect.poll(shown).not.toContain('id: second_step'); + expect(await shown()).toBe(beforeText); + await expect(wand).toBeEnabled(); + + // Again, then save: the rest of the file is the same, byte for byte. + await wand.click(); + await expect(wand).toBeDisabled({ timeout: 30_000 }); + await format.click(); + await expect(saveButton(page)).toBeEnabled(); + // The expanded view covers the Save button: the shortcut saves. + await page.keyboard.press('ControlOrMeta+s'); + const review = page.getByRole('dialog', { name: 'Review changes' }); + await expect(review).toBeVisible({ timeout: 30_000 }); + // The diff has coloured lines for what changed. + await expect( + review.locator('td[class*="-diff-added"]').first() + ).toBeVisible(); + await review.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(review).toBeHidden({ timeout: 30_000 }); + const saved = await readSpecOf(request, token, fileName); + const original = await before; + const firstStep = + ' - id: gather\n name: gather\n run: echo gather\n'; + const lastStep = ' - id: third\n name: third\n run: echo third\n'; + expect(original).toContain(firstStep); + expect( + saved.startsWith(original.slice(0, original.indexOf(' - run:'))) + ).toBe(true); + expect(saved).toContain(firstStep); + expect(saved.endsWith(lastStep)).toBe(true); + expect(saved).toContain(' - id: second_step\n name: second step\n'); + expect(saved).toContain('# keep this'); + expect(saved).not.toContain('id: third_step'); + }); + // The code actions of the quick fixes are in every editor of the page: the + // YAML of one step in its view, and the whole file in its panel. Hover on the + // mark shows Quick Fix, and the quick fix adds the id line. + for (const [label, search, scope] of [ + ['the step view', 'step=second+step&ndv=expanded', 'ndv-center'], + ['the whole-file panel', 'ndv=docked', 'ndv'], + ] as const) { + test(`offers the quick fix of a step without id in ${label}`, async ({ + page, + inlineDAG, + gotoWorkspace, + }) => { + test.setTimeout(150_000); + const { fileName } = await inlineDAG(NO_ID); + await gotoWorkspace(page, `${workflowPath(fileName)}?${search}`); + const ndv = page.getByTestId('ndv'); + await expect(ndv).toBeVisible({ timeout: 30_000 }); + if (scope === 'ndv-center') { + await expect( + ndv.getByRole('button', { name: 'YAML', pressed: true }) + ).toBeVisible({ timeout: 30_000 }); + } + const editor = (scope === 'ndv-center' ? ndv.getByTestId(scope) : ndv) + .locator('.monaco-editor') + .first(); + await expect(editor).toBeVisible({ timeout: 30_000 }); + const mark = editor + .locator('.squiggly-warning, .squiggly-error, .squiggly-info') + .first(); + await expect(mark).toBeVisible({ timeout: 30_000 }); + + // The hover opens when the editor is idle: the mouse moves in and out + // until it does. + const hover = page.locator('.monaco-hover:not(.hidden)').first(); + await expect(async () => { + await page.mouse.move(0, 0); + await mark.hover({ force: true }); + await expect(hover).toContainText('The step has no id', { + timeout: 4_000, + }); + await expect(hover).toContainText('Quick Fix', { timeout: 4_000 }); + }).toPass({ timeout: 45_000 }); + await expect(hover).not.toContainText('No quick fixes available'); + + await hover.getByText('Quick Fix', { exact: false }).first().click(); + const actions = page.locator('.action-widget'); + await expect(actions).toContainText('Add id: second_step'); + await page.keyboard.press('Enter'); + await expect(editor.locator('.view-lines')).toContainText( + 'id: second_step', + { timeout: 15_000 } + ); + }); + } + + // A run that starts from the step view leaves the view as it was: the + // same tab, no frame without the view, the same editor. On the definition + // of the run (?def=run) the selected run changes, and the snapshot of the + // new run must not unmount the view while it loads. + for (const [label, snapshot, choice] of [ + ['Step only', false, 'Step only'], + ['Run', false, 'Run'], + ['Step only, on the run definition', true, 'Step only'], + ['Run, on the run definition', true, 'Run'], + ] as const) { + test(`keeps the step view and its YAML after Run step: ${label}`, async ({ + page, + request, + inlineDAG, + gotoWorkspace, + }) => { + test.setTimeout(180_000); + const token = await adminToken(request); + const { dagName, fileName } = await inlineDAG(TWO_STEPS); + const runId = await startDAG(request, token, fileName); + await waitForRunStatus(request, token, dagName, runId, ['succeeded']); + + await gotoWorkspace( + page, + `${workflowPath(fileName, runId)}${snapshot ? '&def=run' : ''}&step=second&ndv=expanded&dock=log` + ); + const scopeSelector = snapshot + ? '[data-testid="ww-run-zone"] ' + : '[data-testid="ww-canvas-graph"] '; + const ndv = page.locator(`${scopeSelector}[data-testid="ndv"]`); + await expect(ndv).toBeVisible({ timeout: 30_000 }); + // The step opens on its YAML. + await expect( + ndv.getByRole('button', { name: 'YAML', pressed: true }) + ).toBeVisible({ timeout: 30_000 }); + const editor = ndv.locator('.monaco-editor').first(); + await expect(editor).toBeVisible({ timeout: 30_000 }); + if (!snapshot) { + await editor.locator('.view-lines').click(); + await page.keyboard.press('Control+End'); + await page.keyboard.type('\n# edited'); + } + + // One frame at a time: the view and its editor stay, in the YAML. + await page.evaluate((selector) => { + const mark = document.querySelector( + `${selector}[data-testid="yaml-file-body"] .monaco-editor` + ); + if (mark) (mark as unknown as { __mark: string }).__mark = 'first'; + const frames: { + view: boolean; + mark: string; + yaml: string | null; + }[] = []; + (window as unknown as { __frames: typeof frames }).__frames = frames; + const tick = () => { + const found = document.querySelector( + `${selector}[data-testid="yaml-file-body"] .monaco-editor` + ) as unknown as { __mark?: string } | null; + const yaml = Array.from( + document.querySelectorAll( + `${selector}[role=group][aria-label=Editor] button` + ) + ).find((button) => button.textContent === 'YAML'); + frames.push({ + view: + document.querySelector(`${selector}[data-testid="ndv"]`) !== null, + mark: found ? (found.__mark ?? 'new') : 'none', + yaml: yaml ? yaml.getAttribute('aria-pressed') : null, + }); + (window as unknown as { __raf: number }).__raf = + requestAnimationFrame(tick); + }; + tick(); + }, scopeSelector); + + await ndv + .locator('header') + .getByRole('button', { name: 'Run step' }) + .click(); + await page + .getByRole('dialog') + .getByRole('button', { name: choice, exact: true }) + .click(); + await expect + .poll(() => query(page, 'run'), { timeout: 60_000 }) + .not.toBe(runId); + await page.waitForTimeout(1500); + + const frames = await page.evaluate(() => { + cancelAnimationFrame((window as unknown as { __raf: number }).__raf); + return ( + window as unknown as { + __frames: { view: boolean; mark: string; yaml: string | null }[]; + } + ).__frames; + }); + expect(frames.length).toBeGreaterThan(10); + expect(frames.filter((frame) => !frame.view)).toEqual([]); + expect([...new Set(frames.map((frame) => frame.mark))]).toEqual([ + 'first', + ]); + expect([...new Set(frames.map((frame) => frame.yaml))]).toEqual(['true']); + expect(query(page, 'step')).toBe('second'); + expect(query(page, 'ndv')).toBe('expanded'); + }); + } +}); diff --git a/ui/e2e/workspace/no-mermaid.spec.ts b/ui/e2e/workspace/no-mermaid.spec.ts new file mode 100644 index 0000000000..51b3e74d02 --- /dev/null +++ b/ui/e2e/workspace/no-mermaid.spec.ts @@ -0,0 +1,269 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import type { Locator, Page } from '@playwright/test'; +import { + loadStack, + loginViaAPI, + startDAG, + openStepForm, + uniqueName, + waitForDAGAvailable, + waitForRunStatus, + writeLocalDAG, +} from '../helpers/e2e'; +import { test as base, expect, signIn } from './fixtures'; + +// parity:test-e2e-other parity:dup-0 parity:test-mermaid-graph - the +// workspace page loads no Mermaid code at runtime; a Wiki page with a Mermaid +// block is the control. + +// Present in the Mermaid library and in no module of the app. +const MERMAID_MARKER = 'mermaidAPI'; + +type InlineDAG = { dagName: string; fileName: string; token: string }; + +const test = base.extend<{ + /** + * Writes a DAG with writeLocalDAG. The spec gets the DAG name. The file is + * deleted after the test. + */ + inlineDAG: ( + prefix: string, + spec: (dagName: string) => string + ) => Promise; +}>({ + inlineDAG: async ({ request }, use) => { + const stack = await loadStack(); + const token = await loginViaAPI( + request, + stack.auth.adminUsername, + stack.auth.adminPassword + ); + const created: string[] = []; + await use(async (prefix, spec) => { + const dagName = uniqueName(prefix); + const fileName = await writeLocalDAG(dagName, spec(dagName)); + created.push(fileName); + await waitForDAGAvailable(request, token, fileName); + return { dagName, fileName, token }; + }); + for (const fileName of created) { + await request.delete( + `/api/v1/dags/${encodeURIComponent(fileName)}?remoteNode=local`, + { headers: { Authorization: `Bearer ${token}` } } + ); + } + }, +}); + +type Script = { url: string; body: string }; + +/** + * Signs in and leaves the landing page, so the check counts only the + * documents opened after this. + */ +async function signInAndLeave(page: Page): Promise { + await signIn(page); + await page.goto('about:blank'); +} + +/** + * Keeps the body of every JavaScript response of the page. Production chunk + * names are numbers, so a check must read the bodies, not the URLs. + */ +async function collectScripts(page: Page): Promise<() => Promise> { + // A route turns the HTTP cache of the page off, so every script comes from + // the network and gives a response event. + await page.route( + (url) => url.pathname.endsWith('.js'), + (route) => route.continue() + ); + const pending: Promise