405 lines
15 KiB
JavaScript
405 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('#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();
|
|
}
|