feat(KAN-46): rebuild reusable project template management
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
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 context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 1000 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(10000);
|
||||
const base = 'http://127.0.0.1:4191';
|
||||
const errors = [];
|
||||
const mutations = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
page.on('request', (request) => {
|
||||
if (['POST', 'PATCH', 'DELETE'].includes(request.method())) {
|
||||
mutations.push(request);
|
||||
}
|
||||
});
|
||||
const button = (name) => page.getByRole('button', { name, exact: true });
|
||||
const result = (value) =>
|
||||
context.addCookies([{ name: 'template_test_result', value, url: base }]);
|
||||
const visit = async (path) => {
|
||||
await result('success');
|
||||
await page.goto(base + path);
|
||||
await page.locator('h1').waitFor();
|
||||
};
|
||||
const noOverflow = async () =>
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
true,
|
||||
);
|
||||
const feedback = (state) =>
|
||||
page
|
||||
.locator('[data-form-state="' + state + '"]')
|
||||
.waitFor({ state: 'visible' });
|
||||
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 });
|
||||
for (const path of [
|
||||
'/project-templates?reset',
|
||||
'/projects/1/templates/create?reset',
|
||||
'/project-templates/2/edit?reset',
|
||||
]) {
|
||||
await visit(path);
|
||||
await noOverflow();
|
||||
assert.equal(
|
||||
await page
|
||||
.locator('html')
|
||||
.evaluate((el) => el.classList.contains('dark')),
|
||||
theme === 'dark',
|
||||
);
|
||||
if (width === 390 || width === 1440) {
|
||||
await page.screenshot({
|
||||
path:
|
||||
'/private/tmp/KAN-46-' +
|
||||
width +
|
||||
'-' +
|
||||
theme +
|
||||
'-' +
|
||||
(path.includes('/edit')
|
||||
? 'edit'
|
||||
: path.includes('/create')
|
||||
? 'create'
|
||||
: 'index') +
|
||||
'.png',
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
assert.equal(await page.locator('#slug').isDisabled(), true);
|
||||
assert.equal(
|
||||
await button('Save template changes').isDisabled(),
|
||||
true,
|
||||
);
|
||||
const checkbox = page.locator('#confirm-linked');
|
||||
await checkbox.focus();
|
||||
await page.keyboard.press('Space');
|
||||
assert.equal(await checkbox.isChecked(), true);
|
||||
assert.equal(
|
||||
await button('Save template changes').isEnabled(),
|
||||
true,
|
||||
);
|
||||
await visit('/project-templates?reset');
|
||||
assert.equal(
|
||||
await page
|
||||
.getByText('Internal baseline', { exact: true })
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
const trigger = button('Delete Foundation');
|
||||
await trigger.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.waitFor();
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(
|
||||
await dialog.evaluate((el) =>
|
||||
el.contains(document.activeElement),
|
||||
),
|
||||
true,
|
||||
);
|
||||
await noOverflow();
|
||||
await page.keyboard.press('Escape');
|
||||
await dialog.waitFor({ state: 'hidden' });
|
||||
assert.equal(
|
||||
await trigger.evaluate((el) => document.activeElement === el),
|
||||
true,
|
||||
);
|
||||
assert.equal(mutations.length, 0);
|
||||
}
|
||||
}
|
||||
await visit('/projects/1/templates/create?reset');
|
||||
await page.locator('#name').fill('Release setup');
|
||||
await page.locator('#slug').fill('release-setup');
|
||||
const groups = page.locator('fieldset input[type="checkbox"]');
|
||||
for (const group of await groups.all()) {
|
||||
await group.uncheck();
|
||||
}
|
||||
await page
|
||||
.getByRole('checkbox', { name: 'Project settings', exact: true })
|
||||
.check();
|
||||
await page
|
||||
.getByRole('checkbox', { name: 'Ticket types', exact: true })
|
||||
.check();
|
||||
await result('validation');
|
||||
await button('Save new template').click();
|
||||
await feedback('validation');
|
||||
assert.equal(await page.locator('#name').inputValue(), 'Release setup');
|
||||
assert.equal(
|
||||
await page.locator('#name').getAttribute('aria-invalid'),
|
||||
'true',
|
||||
);
|
||||
await page
|
||||
.getByText('Choose a valid configuration group.', { exact: true })
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
await page
|
||||
.locator('[data-form-state="validation"]')
|
||||
.evaluate((el) => el === document.activeElement),
|
||||
true,
|
||||
);
|
||||
await result('server');
|
||||
await button('Save new template').click();
|
||||
await feedback('server');
|
||||
await result('success');
|
||||
const created = page.waitForRequest(
|
||||
(request) => request.method() === 'POST',
|
||||
);
|
||||
await button('Try again').click();
|
||||
assert.deepEqual((await created).postDataJSON(), {
|
||||
name: 'Release setup',
|
||||
slug: 'release-setup',
|
||||
copy_areas: ['project_settings', 'ticket_types'],
|
||||
});
|
||||
await page
|
||||
.getByRole('heading', { name: 'Project templates', exact: true })
|
||||
.waitFor();
|
||||
|
||||
await visit('/project-templates/2/edit?reset');
|
||||
const before = mutations.length;
|
||||
await page
|
||||
.locator('form')
|
||||
.evaluate((el) =>
|
||||
el.dispatchEvent(
|
||||
new Event('submit', { bubbles: true, cancelable: true }),
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
mutations.length,
|
||||
before,
|
||||
'Linked updates must wait for acknowledgement',
|
||||
);
|
||||
await page.locator('#confirm-linked').check();
|
||||
await page.locator('#name').fill('Updated foundation');
|
||||
await page.route('**/project-templates/2', (route) =>
|
||||
route.abort('failed'),
|
||||
);
|
||||
await button('Save template changes').click();
|
||||
await feedback('network');
|
||||
assert.equal(await page.locator('#confirm-linked').isChecked(), true);
|
||||
await page.unroute('**/project-templates/2');
|
||||
const updated = page.waitForRequest(
|
||||
(request) => request.method() === 'PATCH',
|
||||
);
|
||||
await button('Try again').click();
|
||||
assert.deepEqual((await updated).postDataJSON(), {
|
||||
name: 'Updated foundation',
|
||||
copy_areas: ['project_settings', 'ticket_types'],
|
||||
confirm_linked_projects_update: true,
|
||||
});
|
||||
await page
|
||||
.getByRole('heading', { name: 'Project templates', exact: true })
|
||||
.waitFor();
|
||||
|
||||
await visit('/project-templates/2/edit?reset&unlinked');
|
||||
assert.equal(await page.locator('#confirm-linked').count(), 0);
|
||||
const unlinked = page.waitForRequest(
|
||||
(request) => request.method() === 'PATCH',
|
||||
);
|
||||
await button('Save template changes').click();
|
||||
assert.deepEqual((await unlinked).postDataJSON(), {
|
||||
name: 'Foundation',
|
||||
copy_areas: ['project_settings', 'ticket_types'],
|
||||
});
|
||||
await page
|
||||
.getByRole('heading', { name: 'Project templates', exact: true })
|
||||
.waitFor();
|
||||
await visit('/project-templates/2/edit?reset&missing');
|
||||
await page
|
||||
.getByText('This template cannot be refreshed', { exact: true })
|
||||
.waitFor();
|
||||
assert.equal(await button('Save template changes').isDisabled(), true);
|
||||
|
||||
await visit('/projects/1/templates/create?reset');
|
||||
await page.locator('#name').fill('Unsaved work');
|
||||
let prompted = false;
|
||||
page.once('dialog', async (dialog) => {
|
||||
prompted = true;
|
||||
await dialog.dismiss();
|
||||
});
|
||||
await page.getByRole('link', { name: 'Cancel', exact: true }).click();
|
||||
assert.equal(prompted, true);
|
||||
assert.equal(await page.locator('#name').inputValue(), 'Unsaved work');
|
||||
// Reload starts the next isolated scenario; accept the browser's unload prompt.
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await visit('/project-templates?reset');
|
||||
await button('Delete Foundation').click();
|
||||
await result('server');
|
||||
await button('Delete template').click();
|
||||
await feedback('server');
|
||||
await result('success');
|
||||
const deleted = page.waitForRequest(
|
||||
(request) => request.method() === 'DELETE',
|
||||
);
|
||||
await button('Try again').click();
|
||||
assert.equal(
|
||||
new URL((await deleted).url()).pathname,
|
||||
'/project-templates/2',
|
||||
);
|
||||
await page.getByRole('dialog').waitFor({ state: 'hidden' });
|
||||
await page
|
||||
.getByText('No published templates yet', { exact: true })
|
||||
.waitFor();
|
||||
await visit('/project-templates?reset');
|
||||
await page.getByRole('link', { name: 'Use template', exact: true }).click();
|
||||
await page.getByRole('heading', { name: 'Destination reached' }).waitFor();
|
||||
assert.match(page.url(), /\/projects\/create\?template=2$/);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'Project template browser checks passed: create/edit payloads, confirmations, slug lock, validation/network/server retries, missing source, deletion, navigation, keyboard, 320/390/1440px and both themes.',
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
Reference in New Issue
Block a user