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:4187'; 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 mutations = []; page.on('request', (request) => { if (['POST', 'DELETE'].includes(request.method())) { mutations.push(request); } }); const dialog = () => page.getByRole('dialog'); const button = (name) => page.getByRole('button', { name, exact: true }); const confirmReset = () => dialog().getByRole('button', { name: 'Reset to template', exact: true }); const result = (value) => context.addCookies([{ name: 'general_test_result', value, url: base }]); const visit = async (query = '?reset') => { await result('success'); await page.goto(base + '/projects/1/settings/general' + query); await page.getByRole('heading', { name: 'General', exact: true }).waitFor(); }; const noOverflow = async () => assert.equal( await page.evaluate( () => document.documentElement.scrollWidth <= window.innerWidth, ), true, ); const formState = (form, state) => form .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 }); await visit(); await noOverflow(); const nav = page.getByRole('navigation', { name: 'Project settings', exact: true, }); assert.equal( await nav .getByRole('link', { name: 'General', exact: true }) .getAttribute('aria-current'), 'page', ); assert.equal( await nav .getByRole('link', { name: 'Ticket settings', exact: true }) .getAttribute('href'), '/projects/1/settings/metadata', ); const sources = page.getByRole('list', { name: 'Setting sources' }); await sources.getByText('Inherited', { exact: true }).waitFor(); await sources .getByText('Local override', { exact: true }) .first() .waitFor(); assert.equal(await button('Switch template').isDisabled(), true); assert.equal(await button('Delete project').isDisabled(), true); if (width === 390 || width === 1440) { await page.screenshot({ path: '/private/tmp/KAN-39-' + width + '-' + theme + '.png', fullPage: true, }); } const trigger = button('Reset Project settings to template'); await trigger.focus(); await page.keyboard.press('Enter'); await dialog().waitFor(); await page.keyboard.press('Tab'); assert.equal( await dialog().evaluate((element) => element.contains(document.activeElement), ), true, ); await noOverflow(); await page.keyboard.press('Escape'); await dialog().waitFor({ state: 'hidden' }); assert.equal( await trigger.evaluate( (element) => document.activeElement === element, ), true, ); assert.equal( mutations.length, 0, 'Opening and cancelling reset must not send a mutation', ); } } await visit('?reset&noTemplate'); await page.getByText('No linked template', { exact: true }).waitFor(); assert.equal(await button('Reset Project settings to template').count(), 0); await visit('?reset&baseline'); assert.equal( await page.getByText('Local override', { exact: true }).count(), 0, ); await visit('?reset&noAlternatives'); await page .getByText('No other published templates', { exact: true }) .waitFor(); assert.equal(await button('Switch template').count(), 0); await visit(); await button('Reset Ticket statuses to template').click(); await dialog().waitFor(); assert.equal(await confirmReset().isDisabled(), true); const replacement = page.locator('#replacement-10'); await replacement.selectOption('1'); await result('validation'); const resetRequest = page.waitForRequest( (request) => request.method() === 'POST', ); await confirmReset().click(); assert.deepEqual((await resetRequest).postDataJSON(), { areas: ['ticket_statuses'], status_replacements: { 10: 1 }, }); await formState(dialog(), 'validation'); await page .getByText('This area could not be reset.', { exact: true }) .waitFor(); assert.equal(await replacement.inputValue(), '1'); await result('server'); await confirmReset().click(); await formState(dialog(), 'server'); await result('success'); await dialog() .getByRole('button', { name: 'Try again', exact: true }) .click(); await dialog().waitFor({ state: 'hidden' }); const sources = page.getByRole('list', { name: 'Setting sources' }); assert.equal( await sources.getByText('Local override', { exact: true }).count(), 1, 'Resetting statuses preserves project settings override', ); assert.equal(await button('Reset Ticket statuses to template').count(), 0); await button('Reset Project settings to template').click(); await confirmReset().click(); await dialog().waitFor({ state: 'hidden' }); assert.equal( await sources.getByText('Local override', { exact: true }).count(), 0, ); assert.equal(await page.getByText('Enabled', { exact: true }).count(), 2); await visit(); const switchForm = page .locator('form') .filter({ has: page.locator('#source_template_id') }); const select = page.locator('#source_template_id'); assert.equal( await select.locator('option[value="1"]').count(), 0, 'Current template must not be selectable', ); await select.selectOption('2'); await page.locator('#switch_confirmation_key').fill('core'); assert.equal(await button('Switch template').isDisabled(), true); const countBefore = mutations.length; await switchForm.evaluate((element) => element.dispatchEvent( new Event('submit', { bubbles: true, cancelable: true }), ), ); assert.equal( mutations.length, countBefore, 'Submit guard must reject a mismatching key', ); await page.locator('#switch_confirmation_key').fill('CORE'); await result('validation'); await button('Switch template').click(); await formState(switchForm, 'validation'); assert.equal( await page.locator('#switch_confirmation_key').inputValue(), 'CORE', ); assert.equal( await page .locator('#switch_confirmation_key') .getAttribute('aria-invalid'), 'true', ); await page.route('**/switch-template', (route) => route.abort('failed')); await button('Switch template').click(); await formState(switchForm, 'network'); await page.unroute('**/switch-template'); await result('success'); const switched = page.waitForRequest( (request) => request.method() === 'POST', ); await switchForm .getByRole('button', { name: 'Try again', exact: true }) .click(); assert.deepEqual((await switched).postDataJSON(), { confirmation_key: 'CORE', source_template_id: 2, }); await page.getByText('Delivery baseline', { exact: true }).waitFor(); assert.equal( await page.locator('#switch_confirmation_key').inputValue(), '', ); assert.equal(await button('Switch template').isDisabled(), true); await visit(); const deleteForm = page .locator('form') .filter({ has: page.locator('#confirmation_key') }); await page.locator('#confirmation_key').fill('core'); assert.equal(await button('Delete project').isDisabled(), true); const beforeDelete = mutations.length; await deleteForm.evaluate((element) => element.dispatchEvent( new Event('submit', { bubbles: true, cancelable: true }), ), ); assert.equal(mutations.length, beforeDelete); await page.locator('#confirmation_key').fill('CORE'); await result('validation'); await button('Delete project').click(); await formState(deleteForm, 'validation'); assert.equal( await page.locator('#confirmation_key').getAttribute('aria-invalid'), 'true', ); assert.equal(await page.locator('#confirmation_key').inputValue(), 'CORE'); await result('success'); const deleted = page.waitForRequest( (request) => request.method() === 'DELETE', ); await button('Delete project').click(); const deletion = await deleted; assert.equal(new URL(deletion.url()).pathname, '/projects/1'); assert.deepEqual(deletion.postDataJSON(), { confirmation_key: 'CORE' }); await page.getByRole('heading', { name: 'Destination reached' }).waitFor(); assert.deepEqual(errors, []); console.log( 'Project general settings browser checks passed: setting sources, scoped resets, confirmations, replacements, errors, retries, keyboard, 320/390/1440px and both themes.', ); } finally { await browser.close(); }