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 page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); page.setDefaultTimeout(10000); const base = 'http://127.0.0.1:4185/projects/7/tickets'; const errors = []; page.on('pageerror', (error) => errors.push(error.message)); const field = (label) => page.getByLabel(label, { exact: true }); const form = () => page.locator('form').first(); const state = (value) => form().locator(`[data-form-state="${value}"]`).waitFor(); const button = (label) => form().getByRole('button', { name: label, exact: true }); async function visit(create, query = '') { await page.goto(`${base}/${create ? 'create' : '42'}${query}`); await page.getByRole('heading', { level: 1 }).waitFor(); if (!create) { await page.locator('#ticket-edit > summary').click(); } } async function fill(create) { await field('Title').fill('A complete ticket'); await field('Description').fill('Description with context.'); await field('Assignee').selectOption('2'); await field('Parent ticket').selectOption('40'); await field('Estimated story points').fill('0'); await field('Acceptance criteria checklist').fill( '[ ] Main criterion\n [x] Nested criterion', ); if (create) { await field('Status').selectOption('4'); } else { await field('Author').selectOption('1'); await field('Blocker comment').fill('Waiting for review'); await field('Impact score').fill('2.5'); await field('Reviewer').selectOption('2'); } } async function submit(create) { const request = page.waitForRequest( (r) => r.method() === (create ? 'POST' : 'PATCH') && r.url().split('?')[0] === (create ? base : `${base}/42`), ); await button(create ? 'Create ticket' : 'Save changes').click(); return (await request).postDataJSON(); } try { for (const create of [true, false]) { for (const width of [1440, 390, 320]) { await page.setViewportSize({ width, height: 1000 }); for (const theme of ['light', 'dark']) { await page.emulateMedia({ colorScheme: theme, reducedMotion: 'reduce', }); await visit(create, '?admin'); assert.equal( await page .locator('html') .evaluate((el) => el.classList.contains('dark')), theme === 'dark', ); assert.equal( await page.evaluate( () => document.documentElement.scrollWidth <= innerWidth, ), true, `Overflow ${create}/${width}/${theme}`, ); await field('Title').focus(); await page.keyboard.press('Tab'); assert.equal( await field('Description').evaluate( (el) => el === document.activeElement && el.matches(':focus-visible'), ), true, ); await fill(create); await page.screenshot({ path: `/private/tmp/kan31-${create ? 'create' : 'edit'}-${width}-${theme}.png`, fullPage: true, }); page.once('dialog', (dialog) => dialog.dismiss()); await button('Discard changes').click(); assert.equal( await field('Title').inputValue(), 'A complete ticket', ); page.once('dialog', (dialog) => dialog.accept()); await button('Discard changes').click(); assert.equal( await button('Discard changes').isDisabled(), true, ); assert.equal( await field('Title').inputValue(), create ? '' : 'Implement a resilient caching layer for the project API', ); } } await visit(create, '?admin&reject&allErrors'); await fill(create); const data = await submit(create); assert.equal(data.title, 'A complete ticket'); assert.equal(data.description, 'Description with context.'); assert.equal(Number(data.ticket_type_id), 1); assert.equal(Number(data.ticket_priority_id), 2); assert.equal(Number(data.assignee_user_id), 2); assert.equal(Number(data.parent_ticket_id), 40); assert.equal(Number(data.story_points), 0); assert.deepEqual( data.acceptance_criteria.map(({ text, checked, indent }) => ({ text, checked, indent, })), [ { text: 'Main criterion', checked: false, indent: 0 }, { text: 'Nested criterion', checked: true, indent: 1 }, ], ); if (create) { assert.equal(Number(data.ticket_status_id), 4); assert.equal('author_user_id' in data, false); } else { assert.equal(Number(data.author_user_id), 1); assert.equal(data.is_blocked, true); assert.equal(data.blocked_comment, 'Waiting for review'); assert.equal(Number(data.field_values[0].value_number), 2.5); assert.equal(Number(data.field_values[1].value_user_id), 2); assert.deepEqual( data.acceptance_criteria.map((item) => item.id), [10, 11], ); assert.equal('ticket_status_id' in data, false); } await state('validation'); const fields = [ 'Title', 'Description', 'Type', 'Priority', 'Assignee', 'Parent ticket', 'Estimated story points', 'Acceptance criteria checklist', ...(create ? ['Status'] : ['Author', 'Blocker comment', 'Impact score', 'Reviewer']), ]; for (const label of fields) { const control = field(label); assert.equal( await control.getAttribute('aria-invalid'), 'true', label, ); const errorId = (await control.getAttribute('aria-describedby')) .split(' ') .at(-1); assert.ok( (await page.locator(`[id="${errorId}"]`).innerText()).length, `Missing associated error: ${label}`, ); } assert.equal(await field('Title').inputValue(), 'A complete ticket'); await page.route(create ? `${base}` : `${base}/42`, (route) => route.abort('failed'), ); await button(create ? 'Create ticket' : 'Save changes').click(); await state('network'); assert.equal( await field('Description').inputValue(), 'Description with context.', ); await page.unrouteAll(); await visit(create, '?admin&serverError'); await fill(create); await submit(create); await state('server'); assert.equal(await field('Title').inputValue(), 'A complete ticket'); let pending; let requests = 0; await page.route(create ? `${base}` : `${base}/42`, (route) => { requests++; pending = route; }); await button('Try again').click(); await state('loading'); assert.equal(await field('Title').isDisabled(), true); assert.equal( await button( create ? 'Creating ticket…' : 'Saving changes…', ).isDisabled(), true, ); await form().evaluate((el) => el.dispatchEvent( new Event('submit', { bubbles: true, cancelable: true }), ), ); assert.equal(requests, 1); await pending.abort(); await state('network'); await page.unrouteAll(); await visit(create, '?admin'); await fill(create); page.once('dialog', (dialog) => dialog.dismiss()); await page .locator('#main-content') .getByRole('link', { name: create ? 'Back to backlog' : 'Open backlog', exact: true, }) .click(); assert.ok(page.url().includes('/tickets/')); const successData = await submit(create); await page .getByRole('heading', { name: successData.title, exact: true }) .waitFor(); if (create) { await page.goBack(); await field('Title').waitFor(); assert.equal(await field('Title').inputValue(), ''); assert.equal( await field('Acceptance criteria checklist').inputValue(), '', ); } else { await form() .getByText('All changes saved', { exact: true }) .waitFor(); await field('Title').fill('Another draft'); page.once('dialog', (dialog) => dialog.accept()); await button('Discard changes').click(); assert.equal( await field('Title').inputValue(), 'A complete ticket', ); assert.equal(await field('Impact score').inputValue(), '2.5'); } } await visit(false, '?hideAuthor&hidePoints'); assert.equal(await field('Author').count(), 0); assert.equal(await field('Estimated story points').count(), 0); await visit(false); assert.equal(await field('Author').isDisabled(), true); await visit(true, '?hidePoints'); assert.equal(await field('Estimated story points').count(), 0); await visit(true, '?noMetadata'); assert.equal(await page.locator('form').count(), 0); assert.deepEqual(errors, []); console.log( 'Ticket form browser checks passed: create/edit payloads, nested validation, retries, drafts, resets, permissions, keyboard, mobile and both themes.', ); } finally { await browser.close(); }