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:4195'; 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: 'notification_test_result', value, url: base }, ]); const visit = async (query = '?reset') => { await result('success'); await page.goto(base + '/projects/1/settings/notifications' + query); await page .getByRole('heading', { name: 'Notifications', exact: true }) .waitFor(); }; const closed = () => dialog().waitFor({ state: 'hidden' }); const state = (value) => dialog().locator(`[data-form-state="${value}"]`).waitFor(); const noOverflow = async () => assert.equal( await page.evaluate( () => document.documentElement.scrollWidth <= window.innerWidth, ), true, ); const submit = async (label, method, path, 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, path); assert.deepEqual(request.postDataJSON(), expected); }; 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(); const trigger = button('Edit Release alerts'); await trigger.focus(); await page.keyboard.press('Enter'); await dialog().waitFor(); assert.equal( await page.getByLabel('Name', { exact: true }).inputValue(), 'Release alerts', ); const secret = page.getByLabel('Replace delivery URL (optional)'); assert.equal(await secret.inputValue(), ''); assert.equal(await secret.getAttribute('type'), 'password'); assert.equal( await page .getByLabel('Values (comma or newline separated)') .inputValue(), 'CORE-1, CORE-2', ); assert.equal( await page .getByLabel('User mentions (@username)', { exact: true }) .isChecked(), true, ); await page .getByText('Available template variables', { exact: true }) .click(); await dialog().getByText('$url', { exact: true }).waitFor(); await noOverflow(); assert.equal( await dialog().evaluate( (el) => el.scrollWidth <= el.clientWidth + 1, ), true, ); if ( process.env.NOTIFICATION_SCREENSHOTS && [1440, 390].includes(width) ) { await page.screenshot({ path: `${process.env.NOTIFICATION_SCREENSHOTS}/kan45-${width}-${theme}.png`, }); } await page.keyboard.press('Escape'); await closed(); assert.equal( await trigger.evaluate((el) => el === document.activeElement), true, ); } } await page.setViewportSize({ width: 1280, height: 1000 }); await visit(); await button('Add notification').click(); assert.equal( await page.getByLabel('Channel', { exact: true }).inputValue(), 'webhook', ); assert.equal( await page .getByLabel('Discord message template', { exact: true }) .count(), 0, ); await page.getByLabel('Name', { exact: true }).fill('New webhook'); await page .getByLabel('Delivery URL', { exact: true }) .fill('https://hooks.example.test/new-secret'); await page .getByLabel('Trigger', { exact: true }) .selectOption('ticket_assignee_changed'); await page.getByLabel('Enabled', { exact: true }).uncheck(); await button('Add filter').click(); await page .getByLabel('Attribute', { exact: true }) .selectOption('actor.name'); await page.getByLabel('Operator', { exact: true }).selectOption('exists'); assert.equal(await page.getByLabel('Value', { exact: true }).count(), 0); const payload = { name: 'New webhook', event_type: 'ticket_assignee_changed', channel: 'webhook', enabled: false, delivery_url: 'https://hooks.example.test/new-secret', channel_configuration: {}, message_template: null, filters: [{ attribute: 'actor.name', operator: 'exists', value: null }], }; await submit('Save', 'POST', '/projects/1/settings/notifications', payload); await closed(); await button('Edit New webhook').waitFor(); assert.equal( await page .locator('body') .textContent() .then((text) => text.includes('new-secret')), false, ); await button('Edit New webhook').click(); assert.equal( await page.getByLabel('Replace delivery URL (optional)').inputValue(), '', ); await button('Remove filter 1').click(); await page.getByLabel('Channel', { exact: true }).selectOption('discord'); await page .getByRole('textbox', { name: 'Discord message template', exact: true }) .fill('$ticket_key: $url'); await page.getByLabel('Role mentions (@role)', { exact: true }).check(); await button('Add filter').click(); await page .getByLabel('Attribute', { exact: true }) .selectOption('ticket.key'); await page.getByLabel('Operator', { exact: true }).selectOption('in'); await page .getByLabel('Values (comma or newline separated)') .fill(' CORE-1,\n CORE-3, '); const edited = { ...payload, channel: 'discord', delivery_url: null, channel_configuration: { allowed_mentions: ['roles'] }, message_template: '$ticket_key: $url', filters: [ { attribute: 'ticket.key', operator: 'in', value: ['CORE-1', 'CORE-3'], }, ], }; await result('validation'); await submit( 'Save', 'PATCH', '/projects/1/settings/notifications/100', edited, ); await state('validation'); await page.getByText('Enter a valid URL.', { exact: true }).waitFor(); await page .getByText('Choose at least one ticket.', { exact: true }) .waitFor(); await page .getByText('This mention type is not supported.', { exact: true }) .waitFor(); assert.equal( await page.getByLabel('Name', { exact: true }).inputValue(), 'New webhook', ); await result('server'); await submit( 'Save', 'PATCH', '/projects/1/settings/notifications/100', edited, ); await state('server'); await result('forbidden'); await submit( 'Try again', 'PATCH', '/projects/1/settings/notifications/100', edited, ); await state('server'); await result('success'); await submit( 'Try again', 'PATCH', '/projects/1/settings/notifications/100', edited, ); await closed(); await button('Edit New webhook').click(); await page.getByLabel('Role mentions (@role)', { exact: true }).uncheck(); await button('Remove filter 1').click(); await page .getByLabel('Replace delivery URL (optional)') .fill('https://hooks.example.test/replacement'); await page.route('**/notifications/100', (route) => route.abort('failed')); await button('Save').click(); await state('network'); await page.unroute('**/notifications/100'); await submit( 'Try again', 'PATCH', '/projects/1/settings/notifications/100', { ...edited, delivery_url: 'https://hooks.example.test/replacement', channel_configuration: { allowed_mentions: [] }, filters: [], }, ); await closed(); await button('Edit New webhook').click(); await page.getByLabel('Channel', { exact: true }).selectOption('webhook'); await submit('Save', 'PATCH', '/projects/1/settings/notifications/100', { ...edited, channel: 'webhook', channel_configuration: {}, message_template: null, filters: [], }); await closed(); // Dismissing a dirty dialog asks before clearing a secret; reopening starts clean. await button('Edit New webhook').click(); await page .getByLabel('Replace delivery URL (optional)') .fill('discard-this-secret'); page.once('dialog', (prompt) => prompt.dismiss()); await button('Cancel').click(); assert.equal(await dialog().isVisible(), true); page.once('dialog', (prompt) => prompt.accept()); await button('Cancel').click(); await closed(); await button('Edit Release alerts').click(); assert.equal( await page.getByLabel('Replace delivery URL (optional)').inputValue(), '', ); assert.equal( await page.getByLabel('Name', { exact: true }).inputValue(), 'Release alerts', ); await button('Cancel').click(); await closed(); const before = requests.length; await button('Delete New webhook').click(); await dialog() .getByText(/This cannot be undone/) .waitFor(); await button('Cancel').click(); await closed(); assert.equal(requests.length, before); await button('Delete New webhook').click(); await submit( 'Delete notification', 'DELETE', '/projects/1/settings/notifications/100', {}, ); await closed(); assert.equal(await button('Edit New webhook').count(), 0); await visit('?reset&empty'); await page.getByRole('heading', { name: 'No notifications yet' }).waitFor(); assert.deepEqual(errors, []); console.log( 'Notification browser checks passed: CRUD, filters, channels, secrets, validation, retries, keyboard, responsive layouts and themes.', ); } finally { await browser.close(); }