diff --git a/CHANGELOG.md b/CHANGELOG.md index a7adb50e..865186dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ not per individual commit. - The workflow canvas uses the Konductor docs theme, with light and dark modes, and system colours in forced-colors mode. - The Execution tab runs the workflow and shows live node states. - A launch code trade that gets no answer shows "Still waiting for the server" with a way to stop, and ends with an error after 120 seconds. +- The Designer tab edits stages (agent, rename, add, delete) and saves with checks while typing. ### Changed diff --git a/workflow-canvas/README.md b/workflow-canvas/README.md index a7abbbdf..60b42ed3 100644 --- a/workflow-canvas/README.md +++ b/workflow-canvas/README.md @@ -29,9 +29,9 @@ folder. - `api/`: the HTTP API contract (`openapi.yaml`) and its test. - `stub-api/`: a TypeScript stub server for the contract, for local development and tests. -- `e2e/`: Playwright tests (`tests/`), the screenshot capture (`capture/`), - shared page objects and helpers (`support/`) and the committed PNGs - (`screenshots/`). +- `e2e/`: Playwright tests (`tests/`, and `editing/`, which saves to the + stub), the screenshot capture (`capture/`), shared page objects and helpers + (`support/`) and the committed PNGs (`screenshots/`). A single `package.json` serves all of the above. @@ -195,6 +195,13 @@ launch code `e2e-launch-code-0123456789`, and run the tests within 60 seconds of it listening. The launch code works once, so restart the server before each run; a second run against the same server fails with a 401. +The `editing` project (`e2e/editing`) saves over the root workflow, so it runs +only against the stub. With `WC_API_TARGET` set it is left out of the project +list, `--project=editing` fails with "not found", and the `functional` project +runs alone, read-only. The fixture that puts the sample back between tests does +nothing there, and `npm run screenshots` refuses to start. To run the write +specs, unset `WC_API_TARGET`. + The proxy sends the `Origin` of the target in place of the page's own, and forwards any other `Origin` unchanged. A real server must pass the same Origin check as the stub (refuse a POST or PUT from any other `Origin`), and the same @@ -608,11 +615,29 @@ libraries): npx playwright install chromium ``` -Then `npm run test:e2e`. The HTML report is written to `playwright-report/`; -open it with `npx playwright show-report`. Accessibility is checked with -axe-core on each tab, on the error state, on the Execution tab with a stage -running, a failed stage, a passed run, the error of the API and a goal that -cannot be run, and in both theme modes. +Then `npm run test:e2e`. It has two Playwright projects. `functional` +(`e2e/tests/`) does not save to the stub. `editing` (`e2e/editing/`) does, so it +runs after `functional` has finished; one red functional test makes the editing +tests report "did not run". The editing tests run one after the other in one +worker (`fullyParallel: false`), and the `sample` fixture of +`e2e/support/editing-fixtures.ts` fails a test that finds itself in another +worker, because the stub keeps one workflow for all of them. So `--repeat-each`, +or a second spec file in `e2e/editing/`, needs `--workers=1`. To run only the +editing tests, add `--no-deps`, or `functional` runs first as its dependency. +The stub keeps what is saved until it stops, so the fixture puts the sample +workflow back before and after each test, and a test that needs the file changed +by someone else writes it itself (`e2e/support/stub-workflow.ts`, which fails on +a refused call). The editing tests also cover saves that go wrong: a refusal +with findings and one with none, a lost session, an answer that is lost or +cannot be read (the file holds the text, or does not), and a conflict whose +draft can be copied, and a graph that grows by added stages and is fitted again. +A test that says a save "writes nothing" counts the `PUT` requests that the page +sent; the file is checked only where a request reaches the stub. The HTML report +is written to `playwright-report/`; open it with `npx playwright show-report`. +Accessibility is checked with axe-core on each tab, on the error state, on the +Execution tab with a stage running, a failed stage, a passed run, the error of +the API and a goal that cannot be run, on the Designer tab while editing, with +problems shown and with a conflict shown, and in both theme modes. The stub runs one run at a time and never fails a stage. So the one test that starts a real run on it (`execution.spec.ts`, the first run test) waits for the diff --git a/workflow-canvas/e2e/editing/editing.spec.ts b/workflow-canvas/e2e/editing/editing.spec.ts new file mode 100644 index 00000000..7cf2096b --- /dev/null +++ b/workflow-canvas/e2e/editing/editing.spec.ts @@ -0,0 +1,491 @@ +// SPDX-License-Identifier: Apache-2.0 +import type { Page, Route } from '@playwright/test'; +import { expectNoAccessibilityViolations } from '../support/accessibility'; +import { expect, test } from '../support/editing-fixtures'; +import { + currentWorkflow, + overwriteWorkflow, + SAMPLE_TEXT, +} from '../support/stub-workflow'; + +// These tests save to the stub, which keeps one workflow for all of them, so +// they run one after the other in one worker. The `sample` fixture fails a +// test that finds itself in another worker, so `--repeat-each` or a second +// spec file needs `--workers=1`. + +test('shows the first stage in Properties and says it is selected', async ({ + designer, +}) => { + await expect(designer.selected).toContainText('draft'); + await expect(designer.name).toHaveValue('draft'); + await expect(designer.agent).toContainText('k-developer'); + await expect( + designer.page.getByRole('group', { name: /^Stage draft.*selected$/ }), + ).toBeVisible(); + await designer.expectStatus('No changes'); + await designer.expectSaveEnabled(false); +}); + +test('changes the agent of a stage, saves it, and shows it after a reload', async ({ + canvas, + designer, + request, +}) => { + await designer.chooseAgent('k-architect'); + + await expect(designer.stage('draft')).toContainText('k-architect ยท kiro-cli'); + await designer.expectStatus('Unsaved changes'); + await designer.save.click(); + await designer.expectStatus('Saved'); + await designer.expectSaveEnabled(false); + + const saved = await currentWorkflow(request); + expect(saved.text).toBe(SAMPLE_TEXT.replace('k-developer', 'k-architect')); + await canvas.page.reload(); + await canvas.title.waitFor(); + await expect(canvas.node('node-stage-draft')).toContainText('k-architect'); +}); + +test('renames a stage and saves it', async ({ designer, request }) => { + await designer.rename('write'); + + await expect(designer.stage('write')).toBeVisible(); + await expect(designer.stage('draft')).toHaveCount(0); + await designer.save.click(); + await designer.expectStatus('Saved'); + + expect((await currentWorkflow(request)).text).toBe( + SAMPLE_TEXT.replace('name: draft', 'name: write'), + ); +}); + +test('adds an agent stage, which is selected with focus in its name', async ({ + designer, + request, +}) => { + await designer.add.click(); + + await expect(designer.stage('agent-1')).toContainText('k-developer'); + await expect(designer.name).toHaveValue('agent-1'); + await expect(designer.name).toBeFocused(); + await designer.rename('ship'); + await designer.save.click(); + await designer.expectStatus('Saved'); + + expect((await currentWorkflow(request)).text).toBe( + `${SAMPLE_TEXT} - name: ship\n agent: k-developer\n`, + ); +}); + +test('deletes a stage and saves what is left', async ({ + designer, + request, +}) => { + await designer.stage('review').click(); + await expect(designer.name).toHaveValue('review'); + await expect(designer.name).toBeFocused(); + + await designer.remove.click(); + + await expect(designer.stage('review')).toHaveCount(0); + await expect(designer.name).toHaveValue('draft'); + await expect(designer.name).toBeFocused(); + await expect(designer.remove).toBeDisabled(); + await designer.save.click(); + await designer.expectStatus('Saved'); + expect((await currentWorkflow(request)).text).toBe( + 'name: hello\nintent: Add a greeting to the README\nstages:\n - name: draft\n agent: k-developer\n', + ); +}); + +test('deletes a stage in the middle, selects the one before it and saves the rest in order', async ({ + designer, + request, +}) => { + await designer.add.click(); + await designer.rename('ship'); + await designer.stage('review').click(); + await expect(designer.name).toHaveValue('review'); + + await designer.remove.click(); + + await expect(designer.stage('review')).toHaveCount(0); + await expect(designer.name).toHaveValue('draft'); + await designer.save.click(); + await designer.expectStatus('Saved'); + expect((await currentWorkflow(request)).text).toBe( + 'name: hello\nintent: Add a greeting to the README\nstages:\n - name: draft\n agent: k-developer\n - name: ship\n agent: k-developer\n', + ); +}); + +test('does everything with the keyboard', async ({ designer, request }) => { + const { page } = designer; + // Add a stage from the button. + await designer.add.focus(); + await page.keyboard.press('Enter'); + await expect(designer.name).toBeFocused(); + // Rename it where focus landed. + await page.keyboard.press('ControlOrMeta+a'); + await page.keyboard.type('ship'); + await expect(designer.stage('ship')).toBeVisible(); + // Pick another agent from the list. + await page.keyboard.press('Tab'); + await expect(designer.agent).toBeFocused(); + await page.keyboard.press('Enter'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Enter'); + await expect(designer.agent).not.toContainText('k-developer'); + // Choose a stage by its node, then delete it. + const review = designer.stage('review'); + await review.locator('xpath=ancestor::*[@role="group"][1]').focus(); + await page.keyboard.press('Enter'); + await expect(designer.name).toHaveValue('review'); + await expect(designer.name).toBeFocused(); + await designer.remove.focus(); + await page.keyboard.press('Enter'); + await expect(designer.stage('review')).toHaveCount(0); + await expect(designer.name).toBeFocused(); + // Save with the keyboard; focus stays on Save. + await designer.save.focus(); + await page.keyboard.press('Enter'); + await designer.expectStatus('Saved'); + await expect(designer.save).toBeFocused(); + expect((await currentWorkflow(request)).text).toBe( + 'name: hello\nintent: Add a greeting to the README\nstages:\n - name: draft\n agent: k-developer\n - name: ship\n agent: k-media-analyzer\n', + ); +}); + +test('shows a name the server refuses under the field, and keeps Save off', async ({ + designer, +}) => { + await designer.stage('review').click(); + await designer.rename('draft'); + + await expect(designer.problems).toContainText( + 'The stage name draft is already used.', + ); + await expect(designer.problems).not.toContainText('`'); + await expect(designer.name).toHaveAttribute('aria-invalid', 'true'); + await expect(designer.page.getByText('Needs a fix')).toBeVisible(); + await designer.expectSaveEnabled(false); + await expect(designer.name).toBeFocused(); + + await designer.rename('review-2'); + + await expect(designer.problems).toHaveCount(0); + await designer.expectSaveEnabled(true); +}); + +test("shows a name that is not a name, with the server's words, and keeps Save off", async ({ + designer, +}) => { + await designer.rename('Not A Name'); + + await expect(designer.problems).toContainText('must be lower-case letters'); + await expect(designer.name).toHaveAttribute('aria-invalid', 'true'); + await expect(designer.page.getByText('Needs a fix')).toBeVisible(); + await designer.expectSaveEnabled(false); +}); + +test('tells the person when the file changed, and loads it again', async ({ + canvas, + designer, + request, +}) => { + await designer.rename('write'); + await overwriteWorkflow( + request, + SAMPLE_TEXT.replace('k-quality-assurance', 'k-tpm'), + ); + + await designer.save.click(); + + await expect(designer.conflict).toContainText('The file changed on disk'); + await expect(designer.save).toBeFocused(); + await designer.expectSaveEnabled(false); + // Nothing of the draft was written: the file is the one the other writer left. + expect((await currentWorkflow(request)).text).toBe( + SAMPLE_TEXT.replace('k-quality-assurance', 'k-tpm'), + ); + + await designer.conflict + .getByRole('button', { name: 'Load the file again' }) + .click(); + + await expect(designer.conflict).toHaveCount(0); + await expect(canvas.node('node-stage-review')).toContainText('k-tpm'); + await expect(designer.stage('write')).toHaveCount(0); + await designer.expectStatus('Loaded again from the server'); + await expect(designer.name).toBeFocused(); +}); + +test('lets the person take the draft away on a conflict, before it is replaced', async ({ + designer, + request, +}) => { + await designer.rename('write'); + await overwriteWorkflow(request, SAMPLE_TEXT.replace('k-developer', 'k-tpm')); + await designer.save.click(); + await expect(designer.conflict).toBeVisible(); + + await designer.conflict + .getByRole('button', { name: 'Copy my version' }) + .click(); + + const clipboardDenied = designer.page.getByRole('textbox', { + name: 'My version of the workflow file', + }); + const copied = designer.conflict.getByText('Copied.'); + await expect(clipboardDenied.or(copied)).toBeVisible(); +}); + +test('keeps the draft when the tab is switched, and tells Execution it is not saved', async ({ + canvas, + designer, +}) => { + await designer.rename('write'); + + await canvas.selectTab('Execution'); + + await expect(canvas.node('node-stage-draft')).toBeVisible(); + await expect(canvas.page.getByTestId('unsaved-note')).toBeVisible(); + await canvas.selectTab('Designer'); + await expect(designer.name).toHaveValue('write'); +}); + +test('draws the saved stages on Execution after a save', async ({ + canvas, + designer, +}) => { + await designer.rename('write'); + await designer.save.click(); + await designer.expectStatus('Saved'); + + await canvas.selectTab('Execution'); + + await expect(designer.stage('write')).toBeVisible(); + await expect(canvas.page.getByTestId('unsaved-note')).toHaveCount(0); +}); + +test('Designer tab has no axe violations while editing', async ({ + designer, +}) => { + await designer.rename('write'); + await expectNoAccessibilityViolations(designer.page); +}); + +test('Designer tab has no axe violations with problems shown', async ({ + designer, +}) => { + await designer.stage('review').click(); + await designer.rename('draft'); + await expect(designer.problems).toBeVisible(); + + await expectNoAccessibilityViolations(designer.page); +}); + +test('Designer tab has no axe violations with a conflict shown', async ({ + designer, + request, +}) => { + await designer.rename('write'); + await overwriteWorkflow(request, SAMPLE_TEXT.replace('k-developer', 'k-tpm')); + await designer.save.click(); + await expect(designer.conflict).toBeVisible(); + + await expectNoAccessibilityViolations(designer.page); +}); + +test('Designer tab has no axe violations in the dark mode while editing', async ({ + canvas, + designer, +}) => { + await canvas.switchTheme(); + await designer.rename('write'); + + await expectNoAccessibilityViolations(designer.page); +}); + +/** + * Takes over the saves of the page: `answer` decides what each one gets. + * Returns how many saves the page sent, so a test can say that a save was + * sent once, and not again. + */ +async function interceptSaves( + page: Page, + answer: (route: Route) => Promise, +): Promise<{ sent: () => number }> { + let sent = 0; + await page.route('**/api/workflow', async (route) => { + if (route.request().method() !== 'PUT') return route.fallback(); + sent += 1; + await answer(route); + }); + return { sent: () => sent }; +} + +/** The server answers a save in a way it never does by itself. */ +function answerSaveWith( + page: Page, + reply: { status: number; body: string; contentType?: string }, +) { + return interceptSaves(page, (route) => + route.fulfill({ + status: reply.status, + contentType: reply.contentType ?? 'application/json', + body: reply.body, + }), + ); +} + +// A finding as the contract has it: every field of `Diagnostic` is there. +const DUPLICATE_STAGE = { + code: 'DUPLICATE_STAGE', + severity: 'error', + file: 'hello.yml', + path: 'stages[0].name', + line: 4, + column: 11, + end_line: 4, + end_column: 16, + message: 'The stage name `write` is already used.', +}; + +test('shows the findings of a save the server refuses, and sends the save once', async ({ + designer, +}) => { + const saves = await answerSaveWith(designer.page, { + status: 422, + body: JSON.stringify({ + error: 'The workflow has errors.', + code: 'invalid_workflow', + diagnostics: [DUPLICATE_STAGE], + }), + }); + await designer.rename('write'); + + await designer.save.click(); + + await expect(designer.problems).toContainText('is already used'); + await designer.expectStatus('Unsaved changes'); + expect(saves.sent()).toBe(1); +}); + +test('says so when a save is refused and the server shows nothing to fix', async ({ + designer, +}) => { + const saves = await answerSaveWith(designer.page, { + status: 422, + body: JSON.stringify({ + error: 'The workflow has errors.', + code: 'invalid_workflow', + }), + }); + await designer.rename('write'); + + await designer.save.click(); + + const alert = designer.alerts; + await expect(alert).toContainText('The workflow was not saved'); + await expect(alert).toContainText('The workflow has errors.'); + expect(saves.sent()).toBe(1); +}); + +test('warns that the draft is lost when the session is gone at a save', async ({ + designer, +}) => { + const saves = await answerSaveWith(designer.page, { + status: 401, + body: JSON.stringify({ + error: 'The session is not valid.', + code: 'unauthorized', + }), + }); + await designer.rename('write'); + + await designer.save.click(); + + const alert = designer.alerts; + await expect(alert).toContainText('The server no longer knows this session.'); + await expect(alert).toContainText('lost when you open a fresh address'); + await expect(designer.name).toHaveValue('write'); + expect(saves.sent()).toBe(1); + expect(await designer.page.evaluate(() => window.sessionStorage.length)).toBe( + 0, + ); +}); + +test('counts a save as done when the answer is lost but the file holds the text', async ({ + designer, + request, +}) => { + // The write lands, then the answer is lost. + const saves = await interceptSaves(designer.page, async (route) => { + await route.fetch(); + await route.abort('connectionreset'); + }); + await designer.rename('write'); + + await designer.save.click(); + + await designer.expectStatus('Saved'); + expect(saves.sent()).toBe(1); + expect((await currentWorkflow(request)).text).toBe( + SAMPLE_TEXT.replace('name: draft', 'name: write'), + ); +}); + +test('counts a save as done when the 200 cannot be read but the file holds the text', async ({ + designer, + request, +}) => { + const saves = await interceptSaves(designer.page, async (route) => { + await route.fetch(); + await route.fulfill({ status: 200, contentType: 'text/plain', body: 'ok' }); + }); + await designer.rename('write'); + + await designer.save.click(); + + await designer.expectStatus('Saved'); + expect(saves.sent()).toBe(1); + expect((await currentWorkflow(request)).text).toBe( + SAMPLE_TEXT.replace('name: draft', 'name: write'), + ); +}); + +test('says the save failed, and may be tried again, when the answer is lost and nothing was written', async ({ + designer, +}) => { + // The request is dropped before it reaches the stub, so the file cannot + // have changed; what can go wrong is a send that is repeated. + const saves = await interceptSaves(designer.page, (route) => + route.abort('connectionreset'), + ); + await designer.rename('write'); + + await designer.save.click(); + + await expect(designer.alerts).toContainText('The workflow was not saved'); + await designer.expectStatus('Unsaved changes'); + await designer.expectSaveEnabled(true); + expect(saves.sent()).toBe(1); +}); + +test('fits every stage in the graph after stages are added to it', async ({ + designer, +}) => { + const added = 12; + for (let n = 0; n < added; n += 1) await designer.add.click(); + + const last = designer.stage(`agent-${added}`); + await expect(last).toBeVisible(); + const area = await designer.graph.boundingBox(); + for (const node of [last, designer.stage('draft')]) { + const box = await node.boundingBox(); + expect(box?.y).toBeGreaterThanOrEqual(area?.y ?? 0); + expect((box?.y ?? 0) + (box?.height ?? 0)).toBeLessThanOrEqual( + (area?.y ?? 0) + (area?.height ?? 0), + ); + } +}); diff --git a/workflow-canvas/e2e/support/designer-page.ts b/workflow-canvas/e2e/support/designer-page.ts index db405e83..b23ee829 100644 --- a/workflow-canvas/e2e/support/designer-page.ts +++ b/workflow-canvas/e2e/support/designer-page.ts @@ -19,7 +19,11 @@ export class DesignerPage { readonly saveStatus: Locator; readonly conflict: Locator; readonly problems: Locator; + /** The alert region of the Designer: conflicts, a file loaded again, failed saves. */ + readonly alerts: Locator; readonly selected: Locator; + /** The canvas that draws the stages. */ + readonly graph: Locator; constructor(readonly page: Page) { this.name = page.getByRole('textbox', { name: 'name', exact: true }); @@ -30,7 +34,9 @@ export class DesignerPage { this.saveStatus = page.getByTestId('save-status'); this.conflict = page.getByTestId('conflict'); this.problems = page.getByTestId('problems'); + this.alerts = page.getByTestId('edit-alerts'); this.selected = page.getByText('Selected:'); + this.graph = page.getByRole('region', { name: 'Workflow graph' }); } /** The node of the stage with the name. */ diff --git a/workflow-canvas/e2e/support/editing-fixtures.ts b/workflow-canvas/e2e/support/editing-fixtures.ts index 7dee130b..2a63e5ff 100644 --- a/workflow-canvas/e2e/support/editing-fixtures.ts +++ b/workflow-canvas/e2e/support/editing-fixtures.ts @@ -1,4 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 +import { API_TARGET, EDITING_PROJECT } from '../../playwright.shared'; import { DesignerPage } from './designer-page'; import { test as base } from './fixtures'; import { restoreSample } from './stub-workflow'; @@ -8,11 +9,28 @@ export { expect } from './fixtures'; /** * `test` for specs that save to the stub. The stub keeps what is saved until * it stops, so an automatic fixture puts the sample back before each test - * opens the page, and after it. + * opens the page, and after it. Against another server it does nothing: the + * specs that run there never save, and putting the sample back would write + * over the workflow of that server. */ export const test = base.extend<{ sample: void; designer: DesignerPage }>({ sample: [ - async ({ request }, use) => { + async ({ request }, use, testInfo) => { + if (!API_TARGET.isStub) { + await use(); + return; + } + // The stub holds one workflow, so a second worker saving to it would + // overwrite the first one's. Fail with the way out, not with a test + // that flakes. The functional specs that use this fixture never save. + if ( + testInfo.project.name === EDITING_PROJECT && + testInfo.parallelIndex !== 0 + ) { + throw new Error( + 'The editing tests share one stub, so they need one worker. Run them with --workers=1.', + ); + } await restoreSample(request); await use(); await restoreSample(request); diff --git a/workflow-canvas/playwright.config.ts b/workflow-canvas/playwright.config.ts index 6ceb51ba..cf71858e 100644 --- a/workflow-canvas/playwright.config.ts +++ b/workflow-canvas/playwright.config.ts @@ -1,6 +1,11 @@ // SPDX-License-Identifier: Apache-2.0 import { defineConfig } from '@playwright/test'; -import { sharedConfig, stableRendering } from './playwright.shared'; +import { + API_TARGET, + EDITING_PROJECT, + sharedConfig, + stableRendering, +} from './playwright.shared'; // The default config, used by `npm run test:e2e`, a bare `npx playwright test` // and editor integrations. It never includes the screenshot capture, so none @@ -10,5 +15,19 @@ export default defineConfig({ reporter: [['list'], ['html', { open: 'never' }]], projects: [ { name: 'functional', testDir: 'e2e/tests', use: stableRendering }, + // These save to the stub, which keeps one workflow for all of them, so + // they run after the others, which only read it. They save over the + // workflow of a real server too, so they are listed only for the stub. + ...(API_TARGET.isStub + ? [ + { + name: EDITING_PROJECT, + testDir: 'e2e/editing', + use: stableRendering, + fullyParallel: false, + dependencies: ['functional'], + }, + ] + : []), ], }); diff --git a/workflow-canvas/playwright.screenshots.config.ts b/workflow-canvas/playwright.screenshots.config.ts index 791df412..ffa75c42 100644 --- a/workflow-canvas/playwright.screenshots.config.ts +++ b/workflow-canvas/playwright.screenshots.config.ts @@ -1,6 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 import { defineConfig } from '@playwright/test'; -import { sharedConfig, stableRendering } from './playwright.shared'; +import { API_TARGET, sharedConfig, stableRendering } from './playwright.shared'; + +if (!API_TARGET.isStub) { + throw new Error( + 'The screenshots overwrite the workflow of the server, so they run only against the stub. Unset WC_API_TARGET.', + ); +} // Used only by `npm run screenshots`, which rewrites the PNGs under // e2e/screenshots/. The list reporter leaves the functional run's HTML report diff --git a/workflow-canvas/playwright.shared.ts b/workflow-canvas/playwright.shared.ts index acb7cac5..a95fac7f 100644 --- a/workflow-canvas/playwright.shared.ts +++ b/workflow-canvas/playwright.shared.ts @@ -44,6 +44,9 @@ export const stableRendering: NonNullable = { }, }; +/** The name of the project whose tests save to the stub, one at a time. */ +export const EDITING_PROJECT = 'editing'; + /** Settings shared by the functional run and the screenshot capture. */ export const sharedConfig: PlaywrightTestConfig = { outputDir: 'test-results', diff --git a/workflow-canvas/stub-api/playwright-servers.test.ts b/workflow-canvas/stub-api/playwright-servers.test.ts index e5ef754b..1d11050b 100644 --- a/workflow-canvas/stub-api/playwright-servers.test.ts +++ b/workflow-canvas/stub-api/playwright-servers.test.ts @@ -114,3 +114,25 @@ describe('the shared Playwright config, read from the environment', () => { expect(shared.API_URL).toBe('http://127.0.0.1:8080'); }); }); + +describe('the projects of the default Playwright config', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + async function projectNames(target: string | undefined) { + vi.stubEnv('WC_API_TARGET', target); + vi.resetModules(); + const { default: config } = await import('../playwright.config.ts'); + return (config.projects ?? []).map((project) => project.name); + } + + it('run the editing specs after the functional ones against the stub', async () => { + expect(await projectNames(undefined)).toEqual(['functional', 'editing']); + }); + + it('leave out the editing specs, which save, against another server', async () => { + expect(await projectNames('http://127.0.0.1:8080')).toEqual(['functional']); + }); +});