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:4176'; const context = await browser.newContext({ viewport: { width: 1280, height: 900 }, }); const page = await context.newPage(); page.setDefaultTimeout(10000); const errors = []; page.on('pageerror', (error) => errors.push(error.message)); const state = (value) => page .locator(`[data-form-state="${value}"]`) .first() .waitFor({ state: 'visible' }); const setResult = (value) => context.addCookies([{ name: 'feedback_test_result', value, url: base }]); const verify = async (condition) => { assert.equal(await condition(), true); }; const confirmNavigation = (accepted) => page.once('dialog', (dialog) => accepted ? dialog.accept() : dialog.dismiss(), ); try { await page.goto(`${base}/feedback`); await page.getByLabel('Title', { exact: true }).fill('Unsaved draft'); await verify(() => page.evaluate(() => { const event = new Event('beforeunload', { cancelable: true }); window.dispatchEvent(event); return event.defaultPrevented; }), ); confirmNavigation(false); await page.getByRole('link', { name: 'Leave page' }).click(); assert.equal(new URL(page.url()).pathname, '/feedback'); assert.equal( await page.getByLabel('Title', { exact: true }).inputValue(), 'Unsaved draft', ); await setResult('validation'); await page.getByRole('button', { name: 'Save draft' }).click(); await state('validation'); assert.equal( await page.getByLabel('Title', { exact: true }).inputValue(), 'Unsaved draft', ); assert.equal( await page .getByLabel('Title', { exact: true }) .getAttribute('aria-invalid'), 'true', ); assert.equal( await page.getByRole('button', { name: 'Try again' }).count(), 0, ); let failedRequests = 0; await page.route('**/projects/7/tickets', (route) => { failedRequests++; return route.abort('failed'); }); await page.getByRole('button', { name: 'Save draft' }).click(); await state('network'); await page.waitForFunction( () => document.activeElement?.getAttribute('data-form-state') === 'network', ); assert.equal( await page.getByLabel('Title', { exact: true }).inputValue(), 'Unsaved draft', ); assert.equal(failedRequests, 1); await page.unroute('**/projects/7/tickets'); await setResult('server'); await page.getByRole('button', { name: 'Try again' }).click(); await state('server'); assert.equal( await page.getByLabel('Title', { exact: true }).inputValue(), 'Unsaved draft', ); await setResult('success'); let pending; let submissions = 0; await page.route('**/projects/7/tickets', (route) => { submissions++; pending = route; }); await page.getByRole('button', { name: 'Try again' }).click(); await state('loading'); await verify(() => page.evaluate(() => { const event = new CustomEvent('inertia:exception', { cancelable: true, detail: { exception: new Error('Unrelated runtime error') }, }); document.dispatchEvent(event); return !event.defaultPrevented; }), ); await verify(() => page.getByRole('button', { name: 'Save draft' }).isDisabled(), ); await page.locator('form').evaluate((form) => form.requestSubmit()); assert.equal(submissions, 1); await page.getByRole('button', { name: 'Cancel request' }).click(); await state('cancelled'); assert.equal( await page.getByLabel('Title', { exact: true }).inputValue(), 'Unsaved draft', ); await pending.abort(); await page.unroute('**/projects/7/tickets'); await page.getByRole('button', { name: 'Try again' }).click(); await state('success'); await verify(() => page.evaluate(() => { const event = new Event('beforeunload', { cancelable: true }); window.dispatchEvent(event); return !event.defaultPrevented; }), ); await page.getByLabel('Title', { exact: true }).fill('History draft'); confirmNavigation(true); await page.getByRole('link', { name: 'Leave page' }).click(); await page.getByRole('heading', { name: 'Destination' }).waitFor(); await page.goBack(); await page.getByLabel('Title', { exact: true }).waitFor(); assert.equal( await page.getByLabel('Title', { exact: true }).inputValue(), 'History draft', ); await page.goForward(); await page.getByRole('heading', { name: 'Destination' }).waitFor(); await page.goBack(); await page.getByLabel('Title', { exact: true }).waitFor(); assert.equal( await page.getByLabel('Title', { exact: true }).inputValue(), 'History draft', ); // Native history cannot be cancelled; remembered state preserves the draft. await page.getByRole('button', { name: 'Save draft' }).click(); await state('success'); for (const width of [1280, 390]) { await page.setViewportSize({ width, height: 844 }); for (const theme of ['Light', 'Dark']) { console.log(`Checking dialogs: ${width}px, ${theme}`); await page .getByRole('button', { name: theme, exact: true }) .click(); await page.waitForFunction( (dark) => document.documentElement.classList.contains('dark') === dark, theme === 'Dark', ); const opener = page.getByRole('button', { name: 'Open dialog', exact: true, }); await opener.focus(); await page.keyboard.press('Enter'); await page.getByRole('dialog', { name: 'Edit details' }).waitFor(); await page .getByRole('dialog', { name: 'Edit details' }) .evaluate((element) => Promise.all( [ element, ...document.querySelectorAll( '[data-slot="dialog-overlay"]', ), ] .flatMap((surface) => surface.getAnimations()) .map((animation) => animation.finished), ), ); assert.equal( await page .getByRole('dialog', { name: 'Edit details' }) .evaluate((element) => getComputedStyle(element).opacity), '1', ); await page.waitForFunction(() => document.activeElement?.closest('[role="dialog"]'), ); for (let step = 0; step < 7; step++) { await page.keyboard.press('Tab'); await verify(() => page.evaluate(() => Boolean( document.activeElement?.closest('[role="dialog"]'), ), ), ); } await page.keyboard.press('Shift+Tab'); await verify(() => page.evaluate(() => Boolean(document.activeElement?.closest('[role="dialog"]')), ), ); await page.screenshot({ path: `/private/tmp/kan20-dialog-${width}-${theme.toLowerCase()}.png`, }); await page .getByRole('button', { name: 'Open nested dialog' }) .click(); await page .getByRole('dialog', { name: 'Nested details' }) .waitFor(); await page.keyboard.press('Escape'); await page .getByRole('dialog', { name: 'Nested details' }) .waitFor({ state: 'hidden' }); await page.waitForFunction(() => document.activeElement?.textContent.includes( 'Open nested dialog', ), ); await page.keyboard.press('Escape'); await page .getByRole('dialog', { name: 'Edit details' }) .waitFor({ state: 'hidden' }); await page.waitForFunction( () => document.activeElement?.id === 'programmatic-trigger', ); await opener.click(); await page .getByRole('dialog', { name: 'Edit details' }) .getByRole('button', { name: 'Cancel', exact: true }) .click(); await page.waitForFunction( () => document.activeElement?.id === 'programmatic-trigger', ); const longOpener = page.getByRole('button', { name: 'Open long dialog', }); await longOpener.click(); const longDialog = page.getByRole('dialog', { name: 'Long details', }); await longDialog.waitFor(); const box = await longDialog.boundingBox(); assert.ok(box.x >= 0 && box.x + box.width <= width); await longDialog .getByRole('button', { name: 'Finish reading' }) .click(); await longDialog.waitFor({ state: 'hidden' }); await page.waitForFunction(() => document.activeElement?.textContent.includes( 'Open long dialog', ), ); await page.getByRole('button', { name: 'Open sheet' }).click(); await page.getByRole('dialog', { name: 'Sheet details' }).waitFor(); await page.keyboard.press('Escape'); await page .getByRole('dialog', { name: 'Sheet details' }) .waitFor({ state: 'hidden' }); await page.waitForFunction(() => document.activeElement?.textContent.includes('Open sheet'), ); const actions = page.getByRole('button', { name: 'Open actions' }); await actions.focus(); await page.keyboard.press('Enter'); await page.getByRole('menu').waitFor(); await page .getByRole('menu') .evaluate((element) => Promise.all( element .getAnimations() .map((animation) => animation.finished), ), ); const menuItem = await page .getByRole('menuitem', { name: 'Select action' }) .boundingBox(); assert.ok(menuItem.height >= 44); await page.keyboard.press('ArrowDown'); await page.keyboard.press('Enter'); await page.getByRole('menu').waitFor({ state: 'hidden' }); await page.waitForFunction(() => document.activeElement?.textContent.includes('Open actions'), ); assert.equal( await page.getByText('selected', { exact: true }).count(), 1, ); await verify(() => page.evaluate( () => document.documentElement.scrollWidth <= innerWidth, ), ); await page.screenshot({ path: `/private/tmp/kan20-feedback-${width}-${theme.toLowerCase()}.png`, fullPage: true, }); } } // Exercise the real ticket pages as well as the isolated components. await page.goto(`${base}/projects/7/tickets/create`); await page.locator('#title').fill('New ticket draft'); await page.locator('#description').fill('Keep this description'); await page.getByRole('button', { name: 'Add criterion' }).click(); await page .getByRole('textbox', { name: 'Criterion 1', exact: true }) .fill('Keep this checklist'); await setResult('validation'); await page.locator('form button[type="submit"]').first().click(); await state('validation'); assert.equal( await page .getByRole('textbox', { name: 'Criterion 1', exact: true }) .inputValue(), 'Keep this checklist', ); await page.route('**/projects/7/tickets', (route) => route.abort('failed')); await page.locator('form button[type="submit"]').first().click(); await state('network'); assert.equal(await page.locator('#title').inputValue(), 'New ticket draft'); assert.equal( await page.locator('#description').inputValue(), 'Keep this description', ); await page.unroute('**/projects/7/tickets'); confirmNavigation(true); await page .getByRole('link', { name: 'Back to backlog', exact: true }) .first() .click(); await page.getByRole('heading', { name: 'Destination' }).waitFor(); await page.goBack(); await page.locator('#title').waitFor(); assert.equal(await page.locator('#title').inputValue(), 'New ticket draft'); assert.equal( await page .getByRole('textbox', { name: 'Criterion 1', exact: true }) .inputValue(), 'Keep this checklist', ); await setResult('success'); await page.locator('form button[type="submit"]').first().click(); await page.getByRole('heading', { name: 'Destination' }).waitFor(); await page.goto(`${base}/projects/7/tickets/31`); await page.locator('#ticket-edit > summary').click(); await page.locator('#title').fill('Updated ticket draft'); await page.getByRole('button', { name: 'Add criterion' }).click(); await page .getByRole('textbox', { name: 'Criterion 1', exact: true }) .fill('Edited checklist'); await setResult('server'); await page.locator('form button[type="submit"]').first().click(); await state('server'); assert.equal( await page.locator('#title').inputValue(), 'Updated ticket draft', ); assert.equal( await page .getByRole('textbox', { name: 'Criterion 1', exact: true }) .inputValue(), 'Edited checklist', ); await setResult('success'); await page.getByRole('button', { name: 'Try again' }).click(); await state('success'); await verify(() => page.evaluate(() => { const event = new Event('beforeunload', { cancelable: true }); window.dispatchEvent(event); return !event.defaultPrevented; }), ); assert.deepEqual(errors, []); console.log( 'Feedback browser behavior checks passed (desktop/mobile, light/dark, real ticket forms).', ); } catch (error) { console.error( 'Active element:', await page.evaluate(() => document.activeElement?.outerHTML.slice(0, 500), ), ); console.error('Browser errors:', errors); await page.screenshot({ path: '/private/tmp/kan20-feedback-failure.png', fullPage: true, }); throw error; } finally { await browser.close(); }