import assert from 'node:assert/strict'; import process from 'node:process'; const { chromium } = await import( process.env.PLAYWRIGHT_MODULE ?? 'playwright' ); const browser = await chromium.launch({ channel: 'chrome', headless: true }); const base = 'http://127.0.0.1:4188'; const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, }); const page = await context.newPage(); page.setDefaultTimeout(10000); const errors = []; page.on('pageerror', (error) => errors.push(error.message)); const requests = []; page.on('request', (request) => { if (['POST', 'PATCH', 'DELETE'].includes(request.method())) { requests.push(request); } }); const button = (name) => page.getByRole('button', { name, exact: true }); const dialog = () => page.getByRole('dialog'); const result = (value) => context.addCookies([{ name: 'workflow_test_result', value, url: base }]); const visit = async (query = '?reset') => { await result('success'); await page.goto(base + '/projects/1/settings/board' + query); await page .getByRole('heading', { name: 'Board workflow', exact: true }) .waitFor(); }; const noOverflow = async () => assert.equal( await page.evaluate( () => document.documentElement.scrollWidth <= window.innerWidth, ), true, ); const submit = async (label, method, pathname, expected) => { const sent = page.waitForRequest((request) => request.method() === method); await dialog().getByRole('button', { name: label, exact: true }).click(); const request = await sent; assert.equal(new URL(request.url()).pathname, pathname); assert.deepEqual(request.postDataJSON(), expected); }; const closed = () => dialog().waitFor({ state: 'hidden' }); const formState = (state) => dialog() .locator('[data-form-state="' + state + '"]') .waitFor({ state: 'visible' }); try { for (const width of [1440, 768, 390, 320]) { await page.setViewportSize({ width, height: 1000 }); for (const theme of ['light', 'dark']) { await page.emulateMedia({ colorScheme: theme }); await visit(); await noOverflow(); assert.equal( await button('Delete status Backlog').isDisabled(), true, ); assert.equal( await button('Delete status Ready').isDisabled(), true, ); assert.equal( await button('Delete status Review').isDisabled(), false, ); const trigger = button('Edit column In progress'); await trigger.focus(); await page.keyboard.press('Enter'); await dialog().waitFor(); assert.equal( await page .getByLabel('Column name', { exact: true }) .inputValue(), 'In progress', ); await page.keyboard.press('Tab'); assert.equal( await dialog().evaluate((element) => element.contains(document.activeElement), ), true, ); await noOverflow(); await page.keyboard.press('Escape'); await closed(); assert.equal( await trigger.evaluate( (element) => document.activeElement === element, ), true, ); if ([390, 1440].includes(width)) { await page.screenshot({ path: '/private/tmp/KAN-40-' + width + '-' + theme + '.png', fullPage: true, }); } assert.equal(requests.length, 0); } } await visit('?reset&empty'); await page.getByText('No board columns yet', { exact: true }).waitFor(); await visit(); await button('Add column').click(); await page .getByLabel('Column name', { exact: true }) .fill('Quality assurance'); await result('validation'); await submit('Add column', 'POST', '/projects/1/settings/board/columns', { name: 'Quality assurance', }); await formState('validation'); assert.equal( await page.getByLabel('Column name', { exact: true }).inputValue(), 'Quality assurance', ); assert.equal( await page .getByLabel('Column name', { exact: true }) .getAttribute('aria-invalid'), 'true', ); await result('server'); await dialog() .getByRole('button', { name: 'Add column', exact: true }) .click(); await formState('server'); await result('success'); await dialog() .getByRole('button', { name: 'Try again', exact: true }) .click(); await closed(); await button('Edit column Quality assurance').waitFor(); const newColumnId = await page .locator('[data-column-id]') .last() .getAttribute('data-column-id'); await button('Edit column Quality assurance').click(); await page.getByLabel('Column name', { exact: true }).fill('QA'); await page.getByLabel('Position', { exact: true }).fill('1'); await result('position'); await dialog().getByRole('button', { name: 'Save', exact: true }).click(); await formState('validation'); assert.equal( await page .getByLabel('Position', { exact: true }) .getAttribute('aria-invalid'), 'true', ); await result('success'); await submit( 'Save', 'PATCH', '/projects/1/settings/board/columns/' + newColumnId, { name: 'QA', position: 1 }, ); await closed(); assert.equal( await page .locator('[data-column-id]') .first() .getAttribute('data-column-id'), newColumnId, ); await button('Edit column QA').click(); assert.equal( await page.getByLabel('Position', { exact: true }).inputValue(), '1', ); assert.equal( await page.getByLabel('Column name', { exact: true }).inputValue(), 'QA', ); await page.getByLabel('Column name', { exact: true }).fill('Unsaved'); page.once('dialog', (prompt) => prompt.dismiss()); await dialog().getByRole('button', { name: 'Cancel', exact: true }).click(); assert.equal(await dialog().isVisible(), true); page.once('dialog', (prompt) => prompt.accept()); await dialog().getByRole('button', { name: 'Cancel', exact: true }).click(); await closed(); await button('Add status to Backlog').click(); assert.equal( await page.getByLabel('Status name', { exact: true }).inputValue(), '', ); await page.getByLabel('Status name', { exact: true }).fill('Triage'); await submit('Add status', 'POST', '/projects/1/settings/board/statuses', { name: 'Triage', board_column_id: null, }); await closed(); await button('Rename status Triage').waitFor(); await button('Add status to Todo').click(); await page.getByLabel('Status name', { exact: true }).fill('Refinement'); await submit('Add status', 'POST', '/projects/1/settings/board/statuses', { name: 'Refinement', board_column_id: 10, }); await closed(); await button('Rename status Review').click(); await page.getByLabel('Status name', { exact: true }).fill('Peer review'); await page.route('**/statuses/4', (route) => route.abort('failed')); await dialog().getByRole('button', { name: 'Save', exact: true }).click(); await formState('network'); await page.unroute('**/statuses/4'); await submit( 'Try again', 'PATCH', '/projects/1/settings/board/statuses/4', { name: 'Peer review' }, ); await closed(); const before = requests.length; await button('Delete status Peer review').click(); await dialog() .getByText(/assigned to “Building”/) .waitFor(); await dialog().getByRole('button', { name: 'Cancel', exact: true }).click(); await closed(); assert.equal(requests.length, before); await button('Delete status Peer review').click(); await result('validation'); await submit( 'Delete', 'DELETE', '/projects/1/settings/board/statuses/4', {}, ); await formState('validation'); await dialog() .getByText('This group needs at least one status.', { exact: true }) .waitFor(); await result('success'); await dialog().getByRole('button', { name: 'Delete', exact: true }).click(); await closed(); assert.equal(await button('Delete status Building').isDisabled(), true); await button('Delete column In progress').click(); await dialog() .getByText(/statuses move to the backlog/) .waitFor(); await submit( 'Delete', 'DELETE', '/projects/1/settings/board/columns/20', {}, ); await closed(); await page .getByRole('list', { name: 'Statuses in Backlog', exact: true }) .getByText('Building', { exact: true }) .waitFor(); assert.equal(await button('Edit column In progress').count(), 0); assert.deepEqual(errors, []); console.log( 'Workflow browser checks passed: CRUD payloads, server refresh, order, deletion protection, validation, retries, dirty forms, keyboard, responsive widths and both themes.', ); } finally { await browser.close(); }