The page workflows started one global webServer and ran with one worker. Each worker now owns its own tests/browser/server.py on an OS-chosen port, with its own temp root and database, through a shared `test` in tests/browser/fixtures.mjs. baseURL, and so page, request and browser.newContext, point at that worker's server. A server that dies fails the running test with its stderr tail and the next test gets a fresh one. The worker count comes from PM_TEST_WORKERS, else 1 under CI, else a quarter of the CPUs up to 8. Chromium workflows in the pinned image: 510 tests in 606 s with one worker on main, 511 (one new isolation spec) in 119 s with eight.
297 lines
20 KiB
JavaScript
297 lines
20 KiB
JavaScript
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||
import { test, expect } from './fixtures.mjs';
|
||
import { THEMES, seedConcept, openConcept, openProduction, diffInventory, mutate } from './concept-inventory.mjs';
|
||
|
||
// Activity-1 Task 3. The review dialog is compared with the concept's own `scan-review` ("Review metadata scan",
|
||
// app.js 1428–1432) and its backup box with the concept's System `.notice`; the differences are D55. The job view
|
||
// has no concept counterpart: it is compared with the approved mockup `activity-scan.html` (a local, ignored file),
|
||
// whose structure and CSS values are restated below (D56). Each comparison is shown to notice a mutation.
|
||
const workspace = '/prompt_manager/workspace';
|
||
|
||
test.beforeEach(async ({ page, request, browserName }) => {
|
||
await startCoverage(page, browserName);
|
||
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
|
||
page.conceptErrors = [];
|
||
page.on('pageerror', error => page.conceptErrors.push(error.message));
|
||
});
|
||
|
||
test.afterEach(async ({ page, request, browserName }) => {
|
||
await saveCoverage(page, browserName);
|
||
expect(page.conceptErrors).toEqual([]);
|
||
await request.post('/__test__/jobs/restart');
|
||
});
|
||
|
||
/** The open review dialog: its body's element sequence, the meta grid, the sentence, the notice and the actions. */
|
||
function measureReview() {
|
||
const dialog = document.querySelector('dialog#modal[open]');
|
||
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
|
||
const name = node => [node.localName, ...[...node.classList].sort()].join('.');
|
||
const grid = dialog.querySelector('#modal-body > .meta-grid');
|
||
const sentence = dialog.querySelector('#modal-body > p.small');
|
||
const actions = dialog.querySelector('#modal-body > .dialog-actions');
|
||
return {
|
||
title: dialog.querySelector('#modal-title').textContent,
|
||
body: [...dialog.querySelector('#modal-body').children].map(name),
|
||
grid: {
|
||
tag: grid.localName,
|
||
...css(grid, 'display', 'gridTemplateColumns', 'columnGap', 'rowGap', 'marginTop', 'marginBottom', 'fontSize'),
|
||
entries: [...grid.children].map(entry => ({
|
||
tag: entry.localName, dt: entry.querySelector('dt').textContent, dd: entry.querySelector('dd').textContent,
|
||
dtStyle: css(entry.querySelector('dt'), 'color', 'fontSize'),
|
||
ddStyle: css(entry.querySelector('dd'), 'marginTop', 'marginLeft', 'fontSize', 'color'),
|
||
})),
|
||
},
|
||
sentence: { text: sentence.textContent, ...css(sentence, 'fontSize', 'color', 'lineHeight', 'marginTop') },
|
||
actions: {
|
||
...css(actions, 'display', 'justifyContent', 'columnGap', 'marginTop'),
|
||
buttons: [...actions.children].map(button => ({ text: button.textContent, className: button.className,
|
||
...css(button, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft', 'borderTopLeftRadius') })),
|
||
},
|
||
};
|
||
}
|
||
|
||
/** A `.notice` box: its frame and its text and actions columns, without the copy. */
|
||
function measureNotice(selector) {
|
||
const notice = document.querySelector(selector);
|
||
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
|
||
return {
|
||
frame: css(notice, 'display', 'alignItems', 'justifyContent', 'columnGap', 'borderTopWidth', 'borderTopStyle',
|
||
'borderTopColor', 'backgroundColor', 'borderTopLeftRadius', 'paddingTop', 'paddingLeft'),
|
||
columns: [...notice.children].map(node => node.localName + (node.className ? `.${node.className}` : '')),
|
||
text: css(notice.querySelector(':scope > div > p'), 'fontSize', 'color', 'marginTop'),
|
||
actions: css(notice.querySelector(':scope > .row-actions'), 'display', 'flexShrink'),
|
||
};
|
||
}
|
||
|
||
/** A concept `.setting-row` switch: its frame, its label and hint, and its checkbox (not its place-dependent margin). */
|
||
function measureToggle(selector) {
|
||
const row = document.querySelector(selector);
|
||
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
|
||
return {
|
||
children: [...row.children].map(node => [node.localName, ...node.classList].join('.')),
|
||
frame: css(row, 'display', 'alignItems', 'justifyContent', 'columnGap', 'paddingTop', 'paddingBottom',
|
||
'borderTopWidth', 'borderTopStyle', 'borderTopColor'),
|
||
label: css(row.querySelector('label'), 'fontSize', 'fontWeight', 'color'),
|
||
hint: css(row.querySelector('p'), 'fontSize', 'color', 'marginTop'),
|
||
box: css(row.querySelector('input[type="checkbox"]'), 'accentColor', 'width', 'height', 'appearance',
|
||
'backgroundColor', 'borderTopLeftRadius'),
|
||
role: row.querySelector('input').getAttribute('role'),
|
||
labelled: row.querySelector('label').htmlFor === row.querySelector('input').id,
|
||
};
|
||
}
|
||
|
||
/** D55 and D58: the concept's dialog with the production differences applied. */
|
||
function mapReviewConcept(reference) {
|
||
return {
|
||
...reference,
|
||
// No sample-plan note (prototype copy); the D58 "All files" toggle follows the grid; the backup box sits
|
||
// before the actions.
|
||
body: [...reference.body.filter(item => item !== 'p.demo-note').slice(0, -1), 'div.notice', reference.body.at(-1)]
|
||
.flatMap(item => item === 'div.meta-grid' ? ['dl.meta-grid', 'div.setting-row'] : [item]),
|
||
// A `dl`, as in the concept's maintenance plan; real sources. The scope keeps the concept's
|
||
// "New and unresolved files" until "All files" is checked.
|
||
grid: { ...reference.grid, tag: 'dl', entries: reference.grid.entries.map(entry => ({ ...entry,
|
||
dd: { Sources: '2 output folders', Scope: 'New and unresolved files' }[entry.dt] })) },
|
||
actions: { ...reference.actions, buttons: reference.actions.buttons.map(button => button.text === 'Simulate scan'
|
||
? { ...button, text: 'Start scan' } : button) },
|
||
};
|
||
}
|
||
|
||
async function conceptReview(page, theme) {
|
||
await openConcept(page, 'images', theme);
|
||
await page.locator('#main .heading-actions').getByRole('button', { name: 'Import', exact: true }).click();
|
||
await page.locator('dialog#modal[open] .operation').filter({ hasText: 'Scan an output folder' })
|
||
.getByRole('button', { name: 'Review scan', exact: true }).click();
|
||
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Review metadata scan');
|
||
await page.mouse.move(0, 0);
|
||
const review = await page.evaluate(measureReview);
|
||
await page.keyboard.press('Escape');
|
||
// The concept's System → Sources notice ("Bring older images into your prompt history").
|
||
await page.goto(`/__test__/canonical/workspace#system`);
|
||
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
|
||
await page.locator('#main').getByRole('button', { name: 'Manage sources', exact: true }).click();
|
||
await expect(page.locator('#main .notice')).toBeVisible();
|
||
return { review, notice: await page.evaluate(measureNotice, '#main .notice'),
|
||
toggle: await page.evaluate(measureToggle, '#main .setting-row:has(input[type="checkbox"])') };
|
||
}
|
||
|
||
async function productionReview(page, theme) {
|
||
await openProduction(page, 'images', theme);
|
||
await page.locator('#workspace-main .heading-actions').getByRole('button', { name: 'Import', exact: true }).click();
|
||
await page.locator('dialog#modal[open] .operation').filter({ hasText: 'Scan for metadata' })
|
||
.getByRole('button', { name: 'Review scan', exact: true }).click();
|
||
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Review metadata scan');
|
||
// The swapped dialog can open under the pointer that pressed Review scan; measure it at rest.
|
||
await page.mouse.move(0, 0);
|
||
const review = await page.evaluate(measureReview);
|
||
const notice = await page.evaluate(measureNotice, 'dialog#modal[open] .notice');
|
||
// The concept's switch is on; compare ours on too, then turn it off again (the default).
|
||
const allFiles = page.getByRole('switch', { name: 'All files', exact: true });
|
||
await allFiles.check();
|
||
await page.mouse.move(0, 0);
|
||
const toggle = await page.evaluate(measureToggle, 'dialog#modal[open] .setting-row');
|
||
await allFiles.uncheck();
|
||
await page.mouse.move(0, 0);
|
||
return { review, notice, toggle };
|
||
}
|
||
|
||
for (const theme of THEMES) test(`review dialog matches the concept scan-review ${theme}`, async ({ page, request }) => {
|
||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||
await seedConcept(request);
|
||
const concept = await conceptReview(page, theme);
|
||
expect(concept.review.body).toEqual(['p.demo-note', 'div.meta-grid', 'p.small', 'div.dialog-actions']);
|
||
const production = await productionReview(page, theme);
|
||
expect(diffInventory(mapReviewConcept(concept.review), production.review)).toEqual([]);
|
||
expect(diffInventory(concept.notice, production.notice)).toEqual([]);
|
||
expect(diffInventory(concept.toggle, production.toggle)).toEqual([]);
|
||
await expect(page.locator('dialog#modal[open] .setting-row > div > label')).toHaveText('All files');
|
||
await expect(page.locator('dialog#modal[open] .setting-row input')).not.toBeChecked();
|
||
await expect(page.getByRole('switch', { name: 'All files', exact: true })).toBeVisible();
|
||
await expect(page.locator('dialog#modal[open] .notice h3')).toHaveText('Back up the database first (recommended)');
|
||
await expect(page.locator('dialog#modal[open] .notice .row-actions button')).toHaveText(['Back up now']);
|
||
|
||
// Mutation check: one CSS change and one removed element are both noticed, then undone to an exact match again.
|
||
const undoCss = await mutate(page, { css: '#modal .meta-grid { gap: 3px; } #modal .notice { padding: 2px; } #modal .setting-row { gap: 3px; }' });
|
||
const cssMutated = await page.evaluate(measureReview);
|
||
const noticeMutated = await page.evaluate(measureNotice, 'dialog#modal[open] .notice');
|
||
await page.getByRole('switch', { name: 'All files', exact: true }).check();
|
||
await page.mouse.move(0, 0);
|
||
const toggleMutated = await page.evaluate(measureToggle, 'dialog#modal[open] .setting-row');
|
||
await page.getByRole('switch', { name: 'All files', exact: true }).uncheck();
|
||
await page.mouse.move(0, 0);
|
||
expect(diffInventory(concept.toggle, toggleMutated).map(item => item.path)).toEqual(['frame.columnGap']);
|
||
expect(diffInventory(mapReviewConcept(concept.review), cssMutated).map(item => item.path)).toEqual(expect.arrayContaining(['grid.columnGap', 'grid.rowGap']));
|
||
expect(diffInventory(concept.notice, noticeMutated).map(item => item.path)).toEqual(['frame.paddingTop', 'frame.paddingLeft']);
|
||
await undoCss();
|
||
const undoDom = await mutate(page, { remove: ['#modal-body > p.small'] });
|
||
const domMutated = await page.evaluate(() => [...document.querySelectorAll('#modal-body > *')].map(node => node.localName));
|
||
expect(domMutated).toEqual(['dl', 'div', 'div', 'div']);
|
||
await undoDom();
|
||
expect(diffInventory(mapReviewConcept(concept.review), await page.evaluate(measureReview))).toEqual([]);
|
||
});
|
||
|
||
// The approved mockup's job view, restated: structure first, then the values of its CSS rules.
|
||
const MOCKUP_TREE = ['div.heading', 'section.surface', 'section.surface', 'details.term'];
|
||
const MOCKUP_PROGRESS = ['div.panel-heading(h2,span.small)', 'progress', 'dl.stat-row(div.added.stat,div.stat,div.stat.warn,div.stat,div.bad.stat)'];
|
||
const MOCKUP_RESULTS = ['div.panel-heading(h2,label.search-field)', 'div.outcomes(div.outcome-list,div.outcome-files)'];
|
||
const MOCKUP_STYLES = {
|
||
statRow: { display: 'grid', columnGap: '10px', columns: 5, marginTop: '16px', marginBottom: '16px' },
|
||
stat: { borderTopWidth: '1px', borderTopLeftRadius: '7px', paddingTop: '12px', paddingLeft: '14px' },
|
||
dt: { fontSize: '11px' },
|
||
dd: { fontSize: '20px', fontWeight: '600', marginTop: '6px', marginLeft: '0px' },
|
||
outcomes: { display: 'grid', columnGap: '16px', first: '220px', marginTop: '6px' },
|
||
outcomeButton: { display: 'flex', justifyContent: 'space-between', marginBottom: '6px', textAlign: 'left' },
|
||
fileRow: { display: 'grid', columnGap: '12px', paddingTop: '9px', paddingLeft: '4px', borderBottomWidth: '1px', fontSize: '12px' },
|
||
path: { monospace: true, overflowWrap: 'anywhere' },
|
||
search: { maxWidth: '320px' },
|
||
term: { marginTop: '16px', borderTopWidth: '1px', borderTopLeftRadius: '7px', overflow: 'hidden' },
|
||
summary: { paddingTop: '11px', paddingLeft: '14px', fontSize: '12px', cursor: 'pointer' },
|
||
pre: { marginTop: '0px', paddingTop: '12px', paddingLeft: '14px', maxHeight: '180px', fontSize: '11px', lineHeight: '18.7px', overflow: 'auto' },
|
||
};
|
||
|
||
function measureJobView() {
|
||
const main = document.querySelector('#workspace-main');
|
||
const css = (node, ...keys) => Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]]));
|
||
const name = node => [node.localName, ...[...node.classList].sort()].join('.');
|
||
// The rows and sections the mockup nests are listed with their children.
|
||
const shape = node => node.matches('.panel-heading, .outcomes, dl.stat-row')
|
||
? `${name(node)}(${[...node.children].map(name).join(',')})` : name(node);
|
||
const [progress, results] = main.querySelectorAll(':scope > section.surface');
|
||
const statRow = progress.querySelector('dl.stat-row');
|
||
const stat = statRow.querySelector('.stat');
|
||
const outcomes = results.querySelector('.outcomes');
|
||
const button = outcomes.querySelector('.outcome-list > button');
|
||
const row = outcomes.querySelector('.file-row');
|
||
const term = main.querySelector(':scope > details.term');
|
||
const pre = term.querySelector('pre');
|
||
const columns = getComputedStyle(statRow).gridTemplateColumns.split(' ');
|
||
return {
|
||
tree: [...main.children].map(name),
|
||
progress: [...progress.children].map(shape),
|
||
results: [...results.children].map(shape),
|
||
h2: [...main.querySelectorAll(':scope > section.surface > .panel-heading > h2')].map(node => node.textContent),
|
||
summary: term.querySelector('summary').textContent,
|
||
styles: {
|
||
statRow: { ...css(statRow, 'display', 'columnGap', 'marginTop', 'marginBottom'), columns: columns.length },
|
||
stat: css(stat, 'borderTopWidth', 'borderTopLeftRadius', 'paddingTop', 'paddingLeft'),
|
||
dt: css(stat.querySelector('dt'), 'fontSize'),
|
||
dd: css(stat.querySelector('dd'), 'fontSize', 'fontWeight', 'marginTop', 'marginLeft'),
|
||
outcomes: { ...css(outcomes, 'display', 'columnGap', 'marginTop'), first: getComputedStyle(outcomes).gridTemplateColumns.split(' ')[0] },
|
||
outcomeButton: css(button, 'display', 'justifyContent', 'marginBottom', 'textAlign'),
|
||
fileRow: css(row, 'display', 'columnGap', 'paddingTop', 'paddingLeft', 'borderBottomWidth', 'fontSize'),
|
||
path: { monospace: /monospace/.test(getComputedStyle(row.querySelector('.path')).fontFamily),
|
||
overflowWrap: getComputedStyle(row.querySelector('.path')).overflowWrap },
|
||
search: css(results.querySelector('.search-field'), 'maxWidth'),
|
||
term: css(term, 'marginTop', 'borderTopWidth', 'borderTopLeftRadius', 'overflow'),
|
||
summary: css(term.querySelector('summary'), 'paddingTop', 'paddingLeft', 'fontSize', 'cursor'),
|
||
// WebKit reports the 1.7 line height of 11px as 18.700001px; compare it to the hundredth of a pixel.
|
||
pre: { ...css(pre, 'marginTop', 'paddingTop', 'paddingLeft', 'maxHeight', 'fontSize', 'overflow'),
|
||
lineHeight: `${Math.round(parseFloat(getComputedStyle(pre).lineHeight) * 100) / 100}px` },
|
||
},
|
||
// Mockup colours: the pressed outcome takes the accent border on the soft blue, the tiles their tone.
|
||
pressed: (() => {
|
||
const probe = document.createElement('span');
|
||
main.append(probe);
|
||
const tone = name => { probe.style.color = `var(${name})`; return getComputedStyle(probe).color; };
|
||
const values = { accent: tone('--ca-accent'), soft: tone('--soft-blue'), green: tone('--green'), red: tone('--red'), warning: tone('--warning') };
|
||
probe.remove();
|
||
const on = outcomes.querySelector('.outcome-list > button[aria-pressed="true"]');
|
||
const dd = selector => getComputedStyle(statRow.querySelector(`${selector} dd`)).color;
|
||
return { border: getComputedStyle(on).borderTopColor === values.accent, background: getComputedStyle(on).backgroundColor === values.soft,
|
||
added: dd('.stat.added') === values.green, bad: dd('.stat.bad') === values.red, warn: dd('.stat.warn') === values.warning };
|
||
})(),
|
||
};
|
||
}
|
||
|
||
const EXPECTED_JOB_VIEW = {
|
||
tree: MOCKUP_TREE, progress: MOCKUP_PROGRESS, results: MOCKUP_RESULTS, h2: ['Progress', 'Results'],
|
||
summary: '● Scan log — raw detail, click to show', styles: MOCKUP_STYLES,
|
||
pressed: { border: true, background: true, added: true, bad: true, warn: true },
|
||
};
|
||
|
||
async function openRunningJob(page, request, theme, viewport = { width: 1600, height: 1100 }) {
|
||
await page.setViewportSize(viewport);
|
||
const started = await request.post('/__test__/jobs/start', { data: { total: 19622 } });
|
||
await expect(started).toBeOK();
|
||
const { job } = await started.json();
|
||
await expect(await request.post(`/__test__/jobs/${job.id}/step`, { data: { processed: 8240,
|
||
counters: { known: 6511, review: 37, no_metadata: 254, errors: 18 },
|
||
records: [{ group: 'added', path: 'CCC/mabes9sans-dataset-v1/IMG_GEN_00369_.png', detail: 'Added to the Library as “Mabes portrait”' }] } })).toBeOK();
|
||
await openProduction(page, `activity/${job.id}`, theme);
|
||
await expect(page.locator('#workspace-main .file-row')).toHaveCount(1);
|
||
return job;
|
||
}
|
||
|
||
for (const theme of THEMES) test(`job view follows the approved mockup ${theme}`, async ({ page, request }) => {
|
||
await seedConcept(request);
|
||
await openRunningJob(page, request, theme);
|
||
expect(await page.evaluate(measureJobView)).toEqual(EXPECTED_JOB_VIEW);
|
||
|
||
// Mutation check: a CSS change (tile gap) and a removed element (the outcome list) are both noticed.
|
||
const undoCss = await mutate(page, { css: '#workspace-main .stat-row { gap: 3px; }' });
|
||
expect(diffInventory(EXPECTED_JOB_VIEW, await page.evaluate(measureJobView)).map(item => item.path)).toEqual(['styles.statRow.columnGap']);
|
||
await undoCss();
|
||
const undoDom = await mutate(page, { remove: ['#workspace-main details.term'] });
|
||
expect(await page.evaluate(() => [...document.querySelector('#workspace-main').children].map(node => node.localName)))
|
||
.toEqual(['div', 'section', 'section']);
|
||
await undoDom();
|
||
expect(await page.evaluate(measureJobView)).toEqual(EXPECTED_JOB_VIEW);
|
||
});
|
||
|
||
test('job view fits a phone without page scroll', async ({ page, request }) => {
|
||
await seedConcept(request);
|
||
await openRunningJob(page, request, 'dark', { width: 390, height: 844 });
|
||
const layout = await page.evaluate(() => {
|
||
const main = document.querySelector('#workspace-main');
|
||
const columns = node => getComputedStyle(node).gridTemplateColumns.split(' ').length;
|
||
return {
|
||
scroll: document.documentElement.scrollWidth <= document.documentElement.clientWidth,
|
||
tiles: columns(main.querySelector('dl.stat-row')),
|
||
outcomes: columns(main.querySelector('.outcomes')),
|
||
inside: [...main.querySelectorAll('.stat, .outcome-list button, .file-row, .search-field')]
|
||
.every(node => node.getBoundingClientRect().right <= main.getBoundingClientRect().right + 0.5),
|
||
};
|
||
});
|
||
expect(layout).toEqual({ scroll: true, tiles: 2, outcomes: 1, inside: true });
|
||
});
|