feat(KAN-20): unify dialogs feedback and protect ticket drafts
This commit is contained in:
@@ -0,0 +1,404 @@
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user