Files
KanbanGit/tests/Fixtures/feedback-browser.mjs
T

406 lines
15 KiB
JavaScript

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
.locator('#acceptance_criteria_checklist')
.fill('[ ] Keep this checklist');
await setResult('validation');
await page.locator('form button[type="submit"]').first().click();
await state('validation');
assert.equal(
await page.locator('#acceptance_criteria_checklist').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.locator('#acceptance_criteria_checklist').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
.locator('#acceptance_criteria_checklist')
.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.locator('#acceptance_criteria_checklist').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();
}