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(); page.setDefaultTimeout(10000); const base = 'http://127.0.0.1:4185/projects/7/tickets/42'; const section = () => page.locator('#ticket-relationships'); const errors = []; page.on('pageerror', (error) => errors.push(error.message)); async function visit(query = '') { await page.goto(base + query); await section().waitFor(); } const control = (label) => section().getByLabel(label, { exact: true }); const save = () => section().getByRole('button', { name: 'Save link', exact: true }); const remove = () => section().getByRole('button', { name: 'Remove link to ENG-41', exact: true, }); 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, reducedMotion: 'reduce', }); await visit('?parent&incoming'); assert.equal( await page .locator('html') .evaluate((el) => el.classList.contains('dark')), theme === 'dark', ); for (const text of [ 'Platform delivery', 'Child tickets', 'Nested child with a long title that must wrap on mobile', ]) { assert.equal( await section() .getByText(text, { exact: true }) .isVisible(), true, text, ); } assert.equal( await section() .locator('[data-ticket-link]') .getByText('Blocked by', { exact: true }) .isVisible(), true, ); const collapse = section().getByRole('button', { name: 'Collapse children of ENG-41', }); await collapse.focus(); await page.keyboard.press('Enter'); assert.equal( await section() .getByText( 'Nested child with a long title that must wrap on mobile', { exact: true }, ) .count(), 0, ); await page.keyboard.press('Space'); assert.equal(await collapse.getAttribute('aria-expanded'), 'true'); await control('Action').focus(); await page.keyboard.press('Tab'); assert.equal( await control('Linked ticket').evaluate( (el) => el === document.activeElement && el.matches(':focus-visible'), ), true, ); await control('Linked ticket').selectOption('41'); await section() .locator('form') .last() .getByText('ENG-42', { exact: true }) .waitFor(); assert.equal( await page.evaluate( () => document.documentElement.scrollWidth <= innerWidth, ), true, `Overflow ${width}/${theme}`, ); await section().screenshot({ path: `/private/tmp/kan33-relationships-${width}-${theme}.png`, }); await section() .getByRole('button', { name: 'Change parent', exact: true }) .click(); assert.equal( await page .locator('#parent_ticket_id') .evaluate((el) => el === document.activeElement), true, ); assert.equal( await page.locator('#ticket-edit').evaluate((el) => el.open), true, ); } } await visit('?noLinks'); assert.equal(await save().isDisabled(), true); await control('Linked ticket').selectOption('41'); let request = page.waitForRequest( (r) => r.method() === 'POST' && r.url().endsWith('/links'), ); await save().click(); assert.deepEqual((await request).postDataJSON(), { ticket_link_type_id: 1, link_direction: 'source', linked_ticket_id: 41, }); await section() .locator('[data-ticket-link]') .getByText('Blocks', { exact: true }) .waitFor(); await page.waitForFunction( () => document.querySelector('#linked_ticket_id')?.value === '', ); await page.waitForFunction(() => document.activeElement?.textContent.includes('Linked tickets'), ); request = page.waitForRequest( (r) => r.method() === 'DELETE' && r.url().endsWith('/links/3'), ); await remove().click(); await request; await section() .getByText('No ticket links are configured for this ticket yet.', { exact: true, }) .waitFor(); await page.waitForFunction(() => document.activeElement?.textContent.includes('Linked tickets'), ); await visit('?noLinks'); await control('Action').selectOption('1:counterpart'); await control('Linked ticket').selectOption('41'); await section() .locator('p[aria-live="polite"]') .getByText('Blocked by', { exact: true }) .waitFor(); request = page.waitForRequest( (r) => r.method() === 'POST' && r.url().endsWith('/links'), ); await save().click(); assert.deepEqual((await request).postDataJSON(), { ticket_link_type_id: 1, link_direction: 'counterpart', linked_ticket_id: 41, }); await section() .locator('[data-ticket-link]') .getByText('Blocked by', { exact: true }) .waitFor(); await visit('?rejectLink'); await control('Linked ticket').selectOption('41'); await save().click(); await section() .getByText('Choose another ticket.', { exact: true }) .waitFor(); assert.equal(await control('Linked ticket').inputValue(), '41'); for (const label of ['Action', 'Linked ticket']) { assert.equal(await control(label).getAttribute('aria-invalid'), 'true'); } await page.route('**/tickets/42/links', (route) => route.abort('failed')); await save().click(); await section().locator('[data-form-state="network"]').waitFor(); assert.equal(await control('Linked ticket').inputValue(), '41'); await page.unrouteAll(); await visit('?serverError'); await remove().click(); await section().locator('[data-form-state="server"]').waitFor(); assert.equal(await section().locator('[data-ticket-link]').count(), 1); let pending; await page.route('**/tickets/42/links/3', (route) => { pending = route; }); await section() .getByRole('button', { name: 'Try again', exact: true }) .click(); await section().locator('[data-form-state="loading"]').waitFor(); assert.equal(await remove().isDisabled(), true); await pending.abort(); await section().locator('[data-form-state="network"]').waitFor(); await page.unrouteAll(); await visit(); await section() .getByRole('button', { name: 'Change parent', exact: true }) .click(); await page.getByLabel('Parent ticket', { exact: true }).selectOption(''); request = page.waitForRequest( (r) => r.method() === 'PATCH' && r.url().endsWith('/tickets/42'), ); await page .getByRole('button', { name: 'Save changes', exact: true }) .click(); assert.equal((await request).postDataJSON().parent_ticket_id, ''); await page.getByText('Ticket updated.', { exact: true }).waitFor(); await visit('?readonly&parent&incoming'); assert.equal(await section().locator('form').count(), 0); assert.equal( await section() .getByRole('button', { name: 'Change parent', exact: true }) .count(), 0, ); assert.equal( await section().getByText('Blocked by', { exact: true }).isVisible(), true, ); for (const [query, message] of [ ['?noTypes', 'No link types are configured for this project.'], [ '?noTargets', 'No other tickets are available to link in this project.', ], ]) { await visit(query); assert.equal( await section().getByText(message, { exact: true }).isVisible(), true, ); assert.equal(await save().count(), 0); } assert.deepEqual(errors, []); console.log( 'Ticket relationships browser checks passed: hierarchy, direction, add/remove, validation, retries, permissions, keyboard, mobile and themes.', ); } finally { await browser.close(); }