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 context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, }); const page = await context.newPage(); page.setDefaultTimeout(10000); const base = 'http://127.0.0.1:4191'; const errors = []; const mutations = []; page.on('pageerror', (error) => errors.push(error.message)); page.on('request', (request) => { if (['POST', 'PATCH', 'DELETE'].includes(request.method())) { mutations.push(request); } }); const button = (name) => page.getByRole('button', { name, exact: true }); const result = (value) => context.addCookies([{ name: 'template_test_result', value, url: base }]); const visit = async (path) => { await result('success'); await page.goto(base + path); await page.locator('h1').waitFor(); }; const noOverflow = async () => assert.equal( await page.evaluate( () => document.documentElement.scrollWidth <= window.innerWidth, ), true, ); const feedback = (state) => page .locator('[data-form-state="' + state + '"]') .waitFor({ state: 'visible' }); try { for (const width of [1440, 390, 320]) { await page.setViewportSize({ width, height: 1000 }); for (const theme of ['light', 'dark']) { await page.emulateMedia({ colorScheme: theme }); for (const path of [ '/project-templates?reset', '/projects/1/templates/create?reset', '/project-templates/2/edit?reset', ]) { await visit(path); await noOverflow(); assert.equal( await page .locator('html') .evaluate((el) => el.classList.contains('dark')), theme === 'dark', ); if (width === 390 || width === 1440) { await page.screenshot({ path: '/private/tmp/KAN-46-' + width + '-' + theme + '-' + (path.includes('/edit') ? 'edit' : path.includes('/create') ? 'create' : 'index') + '.png', fullPage: true, }); } } assert.equal(await page.locator('#slug').isDisabled(), true); assert.equal( await button('Save template changes').isDisabled(), true, ); const checkbox = page.locator('#confirm-linked'); await checkbox.focus(); await page.keyboard.press('Space'); assert.equal(await checkbox.isChecked(), true); assert.equal( await button('Save template changes').isEnabled(), true, ); await visit('/project-templates?reset'); assert.equal( await page .getByText('Internal baseline', { exact: true }) .count(), 0, ); const trigger = button('Delete Foundation'); await trigger.focus(); await page.keyboard.press('Enter'); const dialog = page.getByRole('dialog'); await dialog.waitFor(); await page.keyboard.press('Tab'); assert.equal( await dialog.evaluate((el) => el.contains(document.activeElement), ), true, ); await noOverflow(); await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'hidden' }); assert.equal( await trigger.evaluate((el) => document.activeElement === el), true, ); assert.equal(mutations.length, 0); } } await visit('/projects/1/templates/create?reset'); await page.locator('#name').fill('Release setup'); await page.locator('#slug').fill('release-setup'); const groups = page.locator('fieldset input[type="checkbox"]'); for (const group of await groups.all()) { await group.uncheck(); } await page .getByRole('checkbox', { name: 'Project settings', exact: true }) .check(); await page .getByRole('checkbox', { name: 'Ticket types', exact: true }) .check(); await result('validation'); await button('Save new template').click(); await feedback('validation'); assert.equal(await page.locator('#name').inputValue(), 'Release setup'); assert.equal( await page.locator('#name').getAttribute('aria-invalid'), 'true', ); await page .getByText('Choose a valid configuration group.', { exact: true }) .waitFor(); assert.equal( await page .locator('[data-form-state="validation"]') .evaluate((el) => el === document.activeElement), true, ); await result('server'); await button('Save new template').click(); await feedback('server'); await result('success'); const created = page.waitForRequest( (request) => request.method() === 'POST', ); await button('Try again').click(); assert.deepEqual((await created).postDataJSON(), { name: 'Release setup', slug: 'release-setup', copy_areas: ['project_settings', 'ticket_types'], }); await page .getByRole('heading', { name: 'Project templates', exact: true }) .waitFor(); await visit('/project-templates/2/edit?reset'); const before = mutations.length; await page .locator('form') .evaluate((el) => el.dispatchEvent( new Event('submit', { bubbles: true, cancelable: true }), ), ); assert.equal( mutations.length, before, 'Linked updates must wait for acknowledgement', ); await page.locator('#confirm-linked').check(); await page.locator('#name').fill('Updated foundation'); await page.route('**/project-templates/2', (route) => route.abort('failed'), ); await button('Save template changes').click(); await feedback('network'); assert.equal(await page.locator('#confirm-linked').isChecked(), true); await page.unroute('**/project-templates/2'); const updated = page.waitForRequest( (request) => request.method() === 'PATCH', ); await button('Try again').click(); assert.deepEqual((await updated).postDataJSON(), { name: 'Updated foundation', copy_areas: ['project_settings', 'ticket_types'], confirm_linked_projects_update: true, }); await page .getByRole('heading', { name: 'Project templates', exact: true }) .waitFor(); await visit('/project-templates/2/edit?reset&unlinked'); assert.equal(await page.locator('#confirm-linked').count(), 0); const unlinked = page.waitForRequest( (request) => request.method() === 'PATCH', ); await button('Save template changes').click(); assert.deepEqual((await unlinked).postDataJSON(), { name: 'Foundation', copy_areas: ['project_settings', 'ticket_types'], }); await page .getByRole('heading', { name: 'Project templates', exact: true }) .waitFor(); await visit('/project-templates/2/edit?reset&missing'); await page .getByText('This template cannot be refreshed', { exact: true }) .waitFor(); assert.equal(await button('Save template changes').isDisabled(), true); await visit('/projects/1/templates/create?reset'); await page.locator('#name').fill('Unsaved work'); let prompted = false; page.once('dialog', async (dialog) => { prompted = true; await dialog.dismiss(); }); await page.getByRole('link', { name: 'Cancel', exact: true }).click(); assert.equal(prompted, true); assert.equal(await page.locator('#name').inputValue(), 'Unsaved work'); // Reload starts the next isolated scenario; accept the browser's unload prompt. page.once('dialog', (dialog) => dialog.accept()); await visit('/project-templates?reset'); await button('Delete Foundation').click(); await result('server'); await button('Delete template').click(); await feedback('server'); await result('success'); const deleted = page.waitForRequest( (request) => request.method() === 'DELETE', ); await button('Try again').click(); assert.equal( new URL((await deleted).url()).pathname, '/project-templates/2', ); await page.getByRole('dialog').waitFor({ state: 'hidden' }); await page .getByText('No published templates yet', { exact: true }) .waitFor(); await visit('/project-templates?reset'); await page.getByRole('link', { name: 'Use template', exact: true }).click(); await page.getByRole('heading', { name: 'Destination reached' }).waitFor(); assert.match(page.url(), /\/projects\/create\?template=2$/); assert.deepEqual(errors, []); console.log( 'Project template browser checks passed: create/edit payloads, confirmations, slug lock, validation/network/server retries, missing source, deletion, navigation, keyboard, 320/390/1440px and both themes.', ); } finally { await browser.close(); }