A new selection closed the shared viewer at once, resetting and removing its video even while that video was still starting. That is the WebKit (GStreamer) deadlock D62 guards the inline preview against, and it hung merge-queue run 40903238387. The viewer now stays open, untouched, until its video reports metadata or an error (or 15 s, or the user closes it), and the URL is revoked after that. An open asked for meanwhile runs once, latest wins, only while its selection is still current.
1126 lines
70 KiB
JavaScript
1126 lines
70 KiB
JavaScript
// The native "Inspect a file" page (#249 local Inspect, PR L2 Task 6): one chosen or dropped local file previews
|
||
// inline, and the heading's one action opens the shared viewer with the full inspector on a record read entirely in
|
||
// this browser. The concept is docs/concepts/workspace/app.js renderMetadata (740–748) and its selection (1715–1729);
|
||
// the production differences are D60–D66 in TESTING.md.
|
||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||
import { test, expect } from './fixtures.mjs';
|
||
import { parityPng, localFixture, tinyWebm, sendablePng, corruptPng, disguised, undecodableMp4, POSITIVE_LINES } from './local-inspect-files.mjs';
|
||
|
||
const workspace = '/prompt_manager/workspace';
|
||
const TOAST = 'Local preview only. No file uploaded; metadata is available above.';
|
||
const NO_TOAST = '';
|
||
const ACTION = 'Open file metadata';
|
||
const COPY = {
|
||
intro: 'Read embedded metadata in this browser. The file is not uploaded or added to your library.',
|
||
note: 'Your file stays in this browser. Choosing it opens its metadata.',
|
||
chooser: 'Choose an image or video for a local preview',
|
||
multiple: 'Drop one file at a time.',
|
||
directory: 'Folders cannot be inspected. Choose one image or video file.',
|
||
unsupported: 'This file type cannot be previewed or inspected here. Choose an image or video file.',
|
||
previewFailed: 'The preview could not be displayed. Metadata and the original remain available in the viewer.',
|
||
};
|
||
|
||
test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' });
|
||
|
||
test.beforeEach(async ({ page, request, browserName }) => {
|
||
await startCoverage(page, browserName);
|
||
await expect(await request.post('/__test__/reset')).toBeOK();
|
||
page.inspectErrors = [];
|
||
page.on('pageerror', error => page.inspectErrors.push(error.message));
|
||
});
|
||
|
||
test.afterEach(async ({ page, browserName }) => {
|
||
await page.unrouteAll({ behavior: 'ignoreErrors' });
|
||
await saveCoverage(page, browserName);
|
||
expect(page.inspectErrors).toEqual([]);
|
||
});
|
||
|
||
const main = page => page.locator('#workspace-main');
|
||
const action = page => main(page).locator('.heading-actions').getByRole('button', { name: ACTION, exact: true });
|
||
const chooser = page => page.locator('#local-file');
|
||
const preview = page => page.locator('#local-preview');
|
||
const status = page => preview(page).locator('p.small');
|
||
const viewer = page => page.locator('dialog#viewer-dialog');
|
||
const rail = page => viewer(page).locator('#viewer-details');
|
||
|
||
async function openInspect(page) {
|
||
await page.goto(`${workspace}#/inspect`);
|
||
await expect(main(page).getByRole('heading', { level: 1, name: 'Inspect a file', exact: true })).toBeVisible();
|
||
await expect(chooser(page)).toBeAttached();
|
||
}
|
||
|
||
/** Selects through the real chooser, then waits for the page to classify the file. */
|
||
async function choose(page, file) {
|
||
await chooser(page).setInputFiles(file);
|
||
}
|
||
|
||
/** D155: choosing a file opens the viewer on it with its details; waits for that. */
|
||
async function openViewer(page) {
|
||
await expect(viewer(page)).toBeVisible();
|
||
await expect(rail(page).locator('.panel-heading')).toBeVisible();
|
||
}
|
||
|
||
/** After the viewer was closed, the heading action (D60) opens it again on the current selection. */
|
||
async function reopenViewer(page) {
|
||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||
await action(page).click();
|
||
await openViewer(page);
|
||
}
|
||
|
||
/** Waits for the viewer a selection opened (D155) and closes it, back on the page. */
|
||
async function closeOpened(page) {
|
||
await openViewer(page);
|
||
await page.keyboard.press('Escape');
|
||
await expect(viewer(page)).toHaveCount(0);
|
||
}
|
||
|
||
/** Dispatches dragover + drop on `selector` with Files built in the page (and optional string items). */
|
||
function drop(page, selector, { files = [], strings = [] } = {}) {
|
||
return page.evaluate(async ({ selector, files, strings }) => {
|
||
const transfer = new DataTransfer();
|
||
for (const file of files) transfer.items.add(new File([new Uint8Array(file.bytes)], file.name, { type: file.mimeType }));
|
||
for (const [type, value] of strings) transfer.items.add(value, type);
|
||
const target = document.querySelector(selector);
|
||
const over = new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: transfer });
|
||
const dropped = new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: transfer });
|
||
target.dispatchEvent(over);
|
||
target.dispatchEvent(dropped);
|
||
return { over: over.defaultPrevented, drop: dropped.defaultPrevented };
|
||
}, { selector, files: files.map(file => ({ name: file.name, mimeType: file.mimeType, bytes: [...file.buffer] })), strings });
|
||
}
|
||
|
||
/** The rail's comparable content: prompts, the six facts, the raw records and the two downloads (D66 aside). */
|
||
const railContent = locator => locator.evaluate(node => {
|
||
const raw = [...node.querySelectorAll('details')].find(item => item.querySelector('summary')?.textContent === 'Workflow & raw metadata');
|
||
const facts = node.querySelector(':scope .meta-grid:not(details .meta-grid)');
|
||
return {
|
||
positive: node.querySelector('.prompt-section.positive p.prompt-text')?.textContent ?? null,
|
||
negative: node.querySelector('.prompt-section.negative p.prompt-text')?.textContent ?? null,
|
||
facts: facts ? [...facts.querySelectorAll(':scope > div')].map(item => [item.querySelector('dt').textContent, item.querySelector('dd').textContent]) : null,
|
||
raw: raw ? [...raw.querySelectorAll('p.small, pre')].map(item => item.textContent) : null,
|
||
downloads: raw ? [...raw.querySelectorAll('button')].map(item => [item.textContent, item.getAttribute('aria-disabled')]) : null,
|
||
};
|
||
});
|
||
|
||
/** Counts storage writes and object URLs created and revoked for Files (download blobs are not the session's). */
|
||
async function instrument(page) {
|
||
await page.addInitScript(() => {
|
||
window.__inspect = { writes: [], created: [], revoked: [], files: new Set() };
|
||
for (const name of ['setItem', 'removeItem', 'clear']) {
|
||
const original = Storage.prototype[name];
|
||
Storage.prototype[name] = function (...args) { window.__inspect.writes.push([name, args[0]]); return original.apply(this, args); };
|
||
}
|
||
const open = indexedDB.open.bind(indexedDB);
|
||
indexedDB.open = (...args) => { window.__inspect.writes.push(['indexedDB.open', args[0]]); return open(...args); };
|
||
const create = URL.createObjectURL, revoke = URL.revokeObjectURL;
|
||
URL.createObjectURL = object => {
|
||
const url = create(object);
|
||
if (object instanceof File) { window.__inspect.created.push(url); window.__inspect.files.add(url); }
|
||
return url;
|
||
};
|
||
URL.revokeObjectURL = url => { if (window.__inspect.files.has(url)) window.__inspect.revoked.push(url); return revoke(url); };
|
||
});
|
||
}
|
||
const urls = page => page.evaluate(() => ({ created: [...window.__inspect.created], revoked: [...window.__inspect.revoked], writes: [...window.__inspect.writes] }));
|
||
|
||
test('chooser opens the shared full inspector without a second read', async ({ page, request }) => {
|
||
// The Images viewer's rail for the same bytes, served from the output folder.
|
||
await expect(await request.post('/__test__/add-local-inspect-file', { data: { name: 'f02.png' } })).toBeOK();
|
||
await page.goto(`${workspace}#/images`);
|
||
await page.locator('article[data-image-key="0:local-inspect/f02.png"] .image-open').click();
|
||
await expect(rail(page).locator('.prompt-section.positive p.prompt-text')).toBeVisible();
|
||
const served = await railContent(rail(page));
|
||
await page.keyboard.press('Escape');
|
||
await expect(viewer(page)).toHaveCount(0);
|
||
|
||
await page.locator('aside.sidebar nav').getByRole('link', { name: 'Inspect a file', exact: true }).click();
|
||
await expect(main(page).getByRole('heading', { level: 1, name: 'Inspect a file', exact: true })).toBeVisible();
|
||
await expect(main(page).locator('.heading p.muted')).toHaveText('A standalone file uses the same image viewer and prompt actions.');
|
||
// The concept DOM: one surface, the drop area with its icon, label, input and note, then the preview slot.
|
||
const surface = main(page).locator('section.surface');
|
||
await expect(surface).toHaveCount(1);
|
||
await expect(surface.locator('h2')).toHaveText('Inspect without importing');
|
||
await expect(surface.locator(':scope > p')).toHaveText(COPY.intro);
|
||
const area = surface.locator('div.file-drop.spaced-20');
|
||
await expect(area.locator(':scope > svg')).toHaveCount(1);
|
||
await expect(area.locator('label[for="local-file"]')).toHaveText(COPY.chooser);
|
||
await expect(chooser(page)).toHaveAttribute('type', 'file');
|
||
await expect(chooser(page)).toHaveAttribute('accept', 'image/png,image/jpeg,image/webp,video/*,.mp4,.mov,.m4v,.webm,.mkv,.avi,.wmv');
|
||
await expect(chooser(page)).not.toHaveAttribute('multiple', /.*/);
|
||
await expect(area.locator('span.small#local-file-note')).toHaveText(COPY.note);
|
||
await expect(surface.locator(':scope > #local-preview')).toBeEmpty();
|
||
// D60: the one heading action is described by the note until a file is chosen.
|
||
await expect(main(page).locator('.heading-actions button')).toHaveCount(1);
|
||
await expect(action(page)).toHaveClass(/\bca-primary\b/);
|
||
await expect(action(page)).toHaveAttribute('aria-disabled', 'true');
|
||
await expect(action(page)).toHaveAccessibleDescription(COPY.note);
|
||
await action(page).dispatchEvent('click');
|
||
await expect(viewer(page)).toHaveCount(0);
|
||
|
||
await choose(page, parityPng('f02.png'));
|
||
const image = preview(page).locator('img.local-file-preview');
|
||
await expect(image).toHaveAttribute('alt', 'Local file preview');
|
||
await expect(image).toHaveJSProperty('complete', true);
|
||
expect(await image.evaluate(node => node.naturalWidth)).toBe(64);
|
||
expect(await image.getAttribute('src')).toMatch(/^blob:/);
|
||
const box = await image.evaluate(node => ({ height: node.getBoundingClientRect().height, fit: getComputedStyle(node).objectFit, margin: getComputedStyle(node).marginTop }));
|
||
expect(box.height).toBeLessThanOrEqual(400);
|
||
expect(box).toMatchObject({ fit: 'contain', margin: '20px' });
|
||
// D155: the open viewer is the confirmation, so no toast.
|
||
await expect(page.locator('#workspace-toast')).toHaveText(NO_TOAST);
|
||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||
await expect(action(page)).not.toHaveAttribute('aria-describedby', /.*/);
|
||
|
||
await openViewer(page);
|
||
await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png');
|
||
const position = viewer(page).locator('.media-position');
|
||
await expect(position).toHaveText('1 of 1 loaded: f02.png');
|
||
for (const name of ['Previous media', 'Next media', 'Play slideshow']) {
|
||
await expect(viewer(page).getByRole('button', { name, exact: true })).toHaveAttribute('aria-disabled', 'true');
|
||
}
|
||
await expect(viewer(page).locator('.viewer-canvas img')).toBeVisible();
|
||
for (const name of ['Fit image', 'Rotate image 90 degrees', 'Flip image horizontally']) {
|
||
await expect(viewer(page).getByRole('button', { name, exact: true })).toHaveAttribute('aria-disabled', 'false');
|
||
}
|
||
const canvas = viewer(page).locator('.viewer-canvas img');
|
||
await viewer(page).getByRole('button', { name: 'Rotate image 90 degrees', exact: true }).click();
|
||
await expect(canvas).toHaveAttribute('style', /rotate\(90deg\)/);
|
||
await viewer(page).getByRole('button', { name: 'Flip image horizontally', exact: true }).click();
|
||
await expect(canvas).toHaveAttribute('style', /scaleX\(-1\)/);
|
||
await viewer(page).getByRole('button', { name: 'Fit image', exact: true }).click();
|
||
await expect(canvas).not.toHaveAttribute('style', /rotate\(90deg\)/);
|
||
|
||
// The same rail as Images for the same bytes, except the D66 provenance.
|
||
const local = await railContent(rail(page));
|
||
expect(local).toEqual(served);
|
||
expect(local.positive).toBe('a red fox in snow, detailed fur');
|
||
expect(local.negative).toBe('blurry, lowres');
|
||
expect(local.facts.map(([label]) => label)).toEqual(['Seed', 'Steps', 'CFG', 'Sampler', 'Scheduler', 'Model']);
|
||
expect(local.downloads).toEqual([['Download metadata JSON', null], ['Download workflow JSON', null]]);
|
||
const provenance = rail(page).locator('details').filter({ has: page.locator('summary', { hasText: 'File & provenance' }) });
|
||
await expect(provenance).toContainText('Selected local file · read in this browser');
|
||
await expect(provenance).toContainText('Local file; no saved library association was checked.');
|
||
await expect(provenance.locator('dt', { hasText: /^Path$/ })).toHaveCount(0);
|
||
await expect(rail(page).getByRole('button', { name: 'Show in folder', exact: true })).toHaveCount(0);
|
||
await expect(rail(page).getByRole('button', { name: /Prompt history/ })).toHaveCount(0);
|
||
await expect(rail(page).locator('.chip-row button.chip')).toHaveCount(0);
|
||
const original = viewer(page).getByRole('link', { name: 'Download original', exact: true });
|
||
await expect(original).toHaveAttribute('href', /^blob:/);
|
||
await expect(original).toHaveAttribute('download', 'f02.png');
|
||
|
||
await page.keyboard.press('Escape');
|
||
await expect(viewer(page)).toHaveCount(0);
|
||
// D155: focus returns to the chooser that opened it.
|
||
await expect(chooser(page)).toBeFocused();
|
||
// Opening the viewer again shows the same record without reading the File a second time.
|
||
await page.evaluate(() => {
|
||
window.__slices = 0;
|
||
const slice = Blob.prototype.slice;
|
||
Blob.prototype.slice = function (...args) { if (this.name === 'f02.png') window.__slices++; return slice.apply(this, args); };
|
||
});
|
||
await reopenViewer(page);
|
||
expect(await railContent(rail(page))).toEqual(served);
|
||
expect(await page.evaluate(() => window.__slices)).toBe(0);
|
||
});
|
||
|
||
// #448 (D155): choosing or dropping a file opens the shared viewer with its details showing, in one step. Closing it
|
||
// returns to the page with the file still chosen and focus on the chooser, ready for the next file.
|
||
test('D155: choosing a file opens the viewer with its details in one click', async ({ page }) => {
|
||
await openInspect(page);
|
||
await choose(page, parityPng('f02.png'));
|
||
await expect(viewer(page)).toBeVisible();
|
||
await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png');
|
||
await expect(rail(page)).toBeVisible();
|
||
await expect(rail(page).locator('.prompt-section.positive p.prompt-text')).toHaveText('a red fox in snow, detailed fur');
|
||
await expect(viewer(page).getByRole('button', { name: /^(Show|Hide) details$/ })).toHaveCount(0);
|
||
// The open viewer is the confirmation: no toast says the metadata is "available above".
|
||
await expect(page.locator('#workspace-toast')).toHaveText('');
|
||
await page.keyboard.press('Escape');
|
||
await expect(viewer(page)).toHaveCount(0);
|
||
await expect(chooser(page)).toBeFocused();
|
||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
|
||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||
// A dropped second file replaces the first and opens at once.
|
||
await drop(page, '.file-drop', { files: [sendablePng()] });
|
||
await expect(viewer(page)).toBeVisible();
|
||
await expect(viewer(page).locator('#viewer-title')).toHaveText('send-me.png');
|
||
await page.keyboard.press('Escape');
|
||
await expect(viewer(page)).toHaveCount(0);
|
||
await expect(chooser(page)).toBeFocused();
|
||
// The same file again is a new selection and opens again; the heading action reopens the current one.
|
||
expect(await chooser(page).evaluate(node => { node.dispatchEvent(new MouseEvent('click')); return node.value; })).toBe('');
|
||
await choose(page, parityPng('f02.png'));
|
||
await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png');
|
||
await page.keyboard.press('Escape');
|
||
await action(page).click();
|
||
await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png');
|
||
await expect(rail(page)).toBeVisible();
|
||
await page.keyboard.press('Escape');
|
||
await expect(action(page)).toBeFocused();
|
||
});
|
||
|
||
test('D155: Inspect shows the details even when the viewer collapses them by default (amends D97)', async ({ page }) => {
|
||
await page.addInitScript(() => localStorage.setItem('gallerySettings', JSON.stringify({ sidebarCollapsedByDefault: true })));
|
||
await openInspect(page);
|
||
await choose(page, parityPng('f02.png'));
|
||
await expect(viewer(page)).toBeVisible();
|
||
await expect(rail(page)).toBeVisible();
|
||
const toggle = viewer(page).getByRole('button', { name: 'Hide details', exact: true });
|
||
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||
await toggle.click();
|
||
await expect(rail(page)).toBeHidden();
|
||
await page.keyboard.press('Escape');
|
||
await action(page).click();
|
||
await expect(rail(page)).toBeVisible();
|
||
await expect(viewer(page).getByRole('button', { name: 'Hide details', exact: true })).toHaveAttribute('aria-expanded', 'true');
|
||
});
|
||
|
||
test('D155: an unreadable file stays on the page with its alert', async ({ page }) => {
|
||
await page.addInitScript(() => {
|
||
const slice = Blob.prototype.slice;
|
||
Blob.prototype.slice = function (...args) {
|
||
if (this.name === 'gone.png') return { arrayBuffer: () => Promise.reject(new DOMException('The file is gone', 'NotReadableError')) };
|
||
return slice.apply(this, args);
|
||
};
|
||
});
|
||
await openInspect(page);
|
||
await choose(page, parityPng('f02.png', 'gone.png'));
|
||
await expect(status(page)).toHaveText('Embedded metadata could not be read.');
|
||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||
await expect(viewer(page)).toHaveCount(0);
|
||
});
|
||
|
||
test('D155: an AVI keeps its note and toast and does not open the viewer', async ({ page }) => {
|
||
await openInspect(page);
|
||
// AVI and WMV metadata is never read and the browser cannot play them, so the page keeps the selection's note.
|
||
const avi = { name: 'clip.avi', mimeType: 'video/x-msvideo', buffer: Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('AVI LIST'), Buffer.alloc(64)]) };
|
||
await choose(page, avi);
|
||
await expect(page.locator('#workspace-toast')).toHaveText(TOAST);
|
||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||
await expect(viewer(page)).toHaveCount(0);
|
||
await reopenViewer(page);
|
||
await expect(viewer(page).locator('#viewer-title')).toHaveText('clip.avi');
|
||
});
|
||
|
||
test('D155: a selection that lands while another dialog is open leaves the viewer closed', async ({ page }) => {
|
||
await openInspect(page);
|
||
await page.evaluate(() => { const other = document.createElement('dialog'); other.id = 'other-dialog'; other.textContent = 'Other'; document.body.append(other); other.showModal(); });
|
||
await choose(page, parityPng('f02.png'));
|
||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||
await expect(viewer(page)).toHaveCount(0);
|
||
await page.evaluate(() => document.querySelector('#other-dialog').close());
|
||
await reopenViewer(page);
|
||
await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png');
|
||
});
|
||
|
||
test('D155: at 390px the opened details are reachable below the media', async ({ page }) => {
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await openInspect(page);
|
||
await choose(page, parityPng('f02.png'));
|
||
await expect(viewer(page)).toBeVisible();
|
||
const facts = rail(page).locator('.meta-grid').first();
|
||
await facts.scrollIntoViewIfNeeded();
|
||
await expect(facts).toBeInViewport();
|
||
expect(await viewer(page).evaluate(node => node.scrollWidth <= node.clientWidth + 1)).toBe(true);
|
||
});
|
||
|
||
// PR L3 Task 10: local roles are the server inspector's (local-resolver.mjs), so for every parity PNG the rail shows the
|
||
// prompts, their status lines and the facts Images shows for the same bytes in the output folder.
|
||
const PARITY_PNGS = ['f01b', 'f02', 'f03', 'f06a', 'f06b', 'f06c', 'f06d', 'f06e', 'f07a', 'f07b', 'f08', 'f09', 'f10', 'f11', 'f12',
|
||
'f14', 'f15', 'f16', 'f17', 'f19', 'f20', 'f21', 'f22', 'f23', 'f24'];
|
||
const roleSections = locator => locator.evaluate(node => [...node.querySelectorAll('.prompt-section')].map(item => item.textContent));
|
||
|
||
test('chooser shows the Images roles and resolution for every parity PNG', async ({ page, request }) => {
|
||
test.slow();
|
||
for (const name of PARITY_PNGS) {
|
||
await expect(await request.post('/__test__/add-local-inspect-file', { data: { name: `${name}.png` } })).toBeOK();
|
||
}
|
||
const served = {};
|
||
await page.goto(`${workspace}#/images`);
|
||
for (const name of PARITY_PNGS) {
|
||
await page.locator(`article[data-image-key="0:local-inspect/${name}.png"] .image-open`).click();
|
||
await expect(viewer(page).locator('#viewer-title')).toHaveText(`${name}.png`);
|
||
await expect(rail(page).locator('.panel-heading')).toBeVisible();
|
||
await expect(rail(page).locator('.prompt-section')).not.toHaveCount(0);
|
||
served[name] = { content: await railContent(rail(page)), roles: await roleSections(rail(page)) };
|
||
await page.keyboard.press('Escape');
|
||
await expect(viewer(page)).toHaveCount(0);
|
||
}
|
||
await openInspect(page);
|
||
for (const name of PARITY_PNGS) {
|
||
await choose(page, parityPng(`${name}.png`));
|
||
await openViewer(page);
|
||
await expect(viewer(page).locator('#viewer-title')).toHaveText(`${name}.png`);
|
||
await expect(rail(page).locator('.prompt-section')).not.toHaveCount(0);
|
||
expect({ content: await railContent(rail(page)), roles: await roleSections(rail(page)) }, name).toEqual(served[name]);
|
||
await page.keyboard.press('Escape');
|
||
await expect(viewer(page)).toHaveCount(0);
|
||
}
|
||
});
|
||
|
||
test('Import → Inspect a local image lands here', async ({ page }) => {
|
||
await page.goto(`${workspace}#/images`);
|
||
await page.getByRole('button', { name: 'Import', exact: true }).click();
|
||
const dialog = page.getByRole('dialog', { name: 'Import & inspect', exact: true });
|
||
await dialog.getByRole('button', { name: 'Open inspector', exact: true }).click();
|
||
await expect(page).toHaveURL(/#\/inspect$/);
|
||
await expect(main(page).getByRole('heading', { level: 1, name: 'Inspect a file', exact: true })).toBeVisible();
|
||
await expect(chooser(page)).toBeAttached();
|
||
await expect(main(page).locator('.handoff')).toHaveCount(0);
|
||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||
});
|
||
|
||
test('drop accepts exactly one File and never navigates', async ({ page }) => {
|
||
await openInspect(page);
|
||
// A drop that misses the area is cancelled, so the browser never opens the file in place of the page.
|
||
expect(await drop(page, 'body', { files: [parityPng('f02.png')] })).toEqual({ over: true, drop: true });
|
||
await expect(preview(page)).toBeEmpty();
|
||
expect(await drop(page, '.file-drop', { files: [parityPng('f02.png')] })).toEqual({ over: true, drop: true });
|
||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
|
||
// D155: the drop opened the viewer; drops are ignored while it is open (D64), so close it first.
|
||
await closeOpened(page);
|
||
await expect(page.locator('#workspace-toast')).toHaveText(NO_TOAST);
|
||
// The chooser names the dropped file, never the one it replaced and never "No file chosen".
|
||
expect(await chooser(page).evaluate(node => [...node.files].map(file => file.name))).toEqual(['f02.png']);
|
||
const first = await preview(page).locator('img').getAttribute('src');
|
||
// Two files, a link and markup keep the selection and say why.
|
||
await drop(page, '.file-drop', { files: [parityPng('f02.png'), parityPng('f03.png')] });
|
||
await expect(status(page)).toHaveText(COPY.multiple);
|
||
await expect(status(page)).toHaveAttribute('role', 'alert');
|
||
await expect(preview(page).locator('img')).toHaveAttribute('src', first);
|
||
for (const strings of [[['text/uri-list', 'https://example.invalid/a.png']], [['text/html', '<img src="https://example.invalid/a.png">']]]) {
|
||
await drop(page, '.file-drop', { strings });
|
||
await expect(status(page)).toHaveText(COPY.unsupported);
|
||
await expect(preview(page).locator('img')).toHaveAttribute('src', first);
|
||
}
|
||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||
await reopenViewer(page);
|
||
await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png');
|
||
await expect(page).toHaveURL(/#\/inspect$/);
|
||
});
|
||
|
||
test('cancel and same-file reselect', async ({ page }) => {
|
||
await instrument(page);
|
||
await openInspect(page);
|
||
await choose(page, parityPng('f02.png'));
|
||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
|
||
await closeOpened(page);
|
||
const first = await preview(page).locator('img').getAttribute('src');
|
||
// The chooser still names the chosen file, as in the concept; a cancel (no files) keeps the page as it is.
|
||
expect(await chooser(page).evaluate(node => node.files[0]?.name)).toBe('f02.png');
|
||
await chooser(page).setInputFiles([]);
|
||
await expect(preview(page).locator('img')).toHaveAttribute('src', first);
|
||
await expect(status(page)).toHaveCount(0);
|
||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||
// Opening the picker and cancelling it keeps naming the live selection (modern engines fire `cancel`, not `change`).
|
||
await chooser(page).dispatchEvent('click');
|
||
await chooser(page).dispatchEvent('cancel');
|
||
expect(await chooser(page).evaluate(node => [...node.files].map(file => file.name))).toEqual(['f02.png']);
|
||
await expect(preview(page).locator('img')).toHaveAttribute('src', first);
|
||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||
// The same file again is a new selection with its own URL; the old one is released. Opening the picker clears
|
||
// the chooser, so the browser reports the same file as a change.
|
||
// (Read in the same task: a picker the click opens is cancelled by the harness, which names the file again.)
|
||
expect(await chooser(page).evaluate(node => { node.dispatchEvent(new MouseEvent('click')); return node.value; })).toBe('');
|
||
await choose(page, parityPng('f02.png'));
|
||
await expect(preview(page).locator('img')).not.toHaveAttribute('src', first);
|
||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
|
||
const state = await urls(page);
|
||
expect(state.created).toHaveLength(2);
|
||
expect(state.revoked).toEqual([first]);
|
||
// The new selection opened the viewer again (D155).
|
||
await openViewer(page);
|
||
await expect(viewer(page).locator('.media-position')).toHaveText('1 of 1 loaded: f02.png');
|
||
});
|
||
|
||
test('preview does not imply metadata support', async ({ page }) => {
|
||
await instrument(page);
|
||
await openInspect(page);
|
||
// JPEG and WebP preview and report no supported metadata, exactly as Images reports them.
|
||
for (const [file, format] of [[localFixture('tiny.jpg', 'image/jpeg'), 'JPEG'], [localFixture('tiny.webp', 'image/webp'), 'WEBP']]) {
|
||
await choose(page, file);
|
||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
|
||
await openViewer(page);
|
||
await expect(rail(page)).toContainText('No supported embedded metadata was recorded in this file.');
|
||
await expect(rail(page).locator('details').first()).toContainText(format);
|
||
await page.keyboard.press('Escape');
|
||
await expect(viewer(page)).toHaveCount(0);
|
||
}
|
||
// A PNG whose text chunk fails its CRC: unreadable metadata, the original still downloads.
|
||
await choose(page, corruptPng());
|
||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||
await openViewer(page);
|
||
await expect(rail(page)).toContainText('Embedded metadata could not be read.');
|
||
const [download] = await Promise.all([page.waitForEvent('download'), viewer(page).getByRole('link', { name: 'Download original', exact: true }).click()]);
|
||
expect(download.suggestedFilename()).toBe('corrupt.png');
|
||
await page.keyboard.press('Escape');
|
||
await expect(viewer(page)).toHaveCount(0);
|
||
// Text, markup, SVG and a playlist under media names are unsupported and never get a URL.
|
||
const before = (await urls(page)).created.length;
|
||
for (const file of disguised()) {
|
||
// Unsupported: nothing opens (D155).
|
||
await choose(page, file);
|
||
await expect(status(page)).toHaveText(COPY.unsupported);
|
||
await expect(status(page)).toHaveAttribute('role', 'alert');
|
||
await expect(preview(page).locator('img, video')).toHaveCount(0);
|
||
await expect(action(page)).toHaveAttribute('aria-disabled', 'true');
|
||
}
|
||
expect((await urls(page)).created).toHaveLength(before);
|
||
});
|
||
|
||
test('local video plays inline and in the viewer with real tags', async ({ page, browserName }) => {
|
||
await openInspect(page);
|
||
await choose(page, localFixture('core-tags.webm', 'video/webm'));
|
||
const video = preview(page).locator('video.local-file-preview');
|
||
await expect(video).toBeVisible();
|
||
expect(await video.evaluate(node => ({ controls: node.controls, playsInline: node.playsInline, preload: node.preload, muted: node.muted, autoplay: node.autoplay, paused: node.paused })))
|
||
.toEqual({ controls: true, playsInline: true, preload: 'metadata', muted: true, autoplay: false, paused: true });
|
||
// WebKit may not decode VP8: whichever state it reports, metadata is still read.
|
||
await expect.poll(() => video.evaluate(node => node.readyState >= 1 || !!node.error)).toBe(true);
|
||
await openViewer(page);
|
||
await expect(rail(page).locator('.prompt-section.positive p.prompt-text')).toHaveText('a red fox in snow, detailed fur');
|
||
await expect(rail(page).locator('.meta-grid').first().locator('dd').first()).toHaveText('7');
|
||
await expect(viewer(page).locator('#viewer-stage video')).toHaveCount(1);
|
||
await page.keyboard.press('Escape');
|
||
await expect(viewer(page)).toHaveCount(0);
|
||
await choose(page, tinyWebm());
|
||
await expect(preview(page).locator('video.local-file-preview')).toBeVisible();
|
||
await openViewer(page);
|
||
await expect(rail(page)).toContainText('No supported embedded metadata was recorded in this file.');
|
||
if (browserName !== 'webkit') await expect.poll(() => viewer(page).locator('#viewer-stage video').evaluate(node => node.readyState >= 1)).toBe(true);
|
||
});
|
||
|
||
test('the toast and a rejected drop never contradict the status line', async ({ page }) => {
|
||
// A file whose bytes are gone after choosing: the status says its metadata cannot be read, so no toast says it is
|
||
// available.
|
||
await page.addInitScript(() => {
|
||
const slice = Blob.prototype.slice;
|
||
Blob.prototype.slice = function (...args) {
|
||
if (this.name === 'gone.png') return { arrayBuffer: () => Promise.reject(new DOMException('The file is gone', 'NotReadableError')) };
|
||
return slice.apply(this, args);
|
||
};
|
||
});
|
||
await openInspect(page);
|
||
await choose(page, parityPng('f02.png', 'gone.png'));
|
||
await expect(status(page)).toHaveText('Embedded metadata could not be read.');
|
||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||
await expect(page.locator('#workspace-toast')).toHaveText('');
|
||
// A rejected drop is its own alert beside the selection's status, which it never replaces, and it ends with the
|
||
// concept's toast interval.
|
||
await choose(page, undecodableMp4());
|
||
const failed = status(page).filter({ hasText: COPY.previewFailed });
|
||
// D155: the selection opened the viewer; its media failed there too, and the page's alert waits under it.
|
||
await expect(failed).toHaveCount(1);
|
||
await closeOpened(page);
|
||
await expect(failed).toHaveCount(1);
|
||
await drop(page, '.file-drop', { files: [parityPng('f02.png'), parityPng('f03.png')] });
|
||
const rejected = status(page).filter({ hasText: COPY.multiple });
|
||
await expect(rejected).toHaveAttribute('role', 'alert');
|
||
await expect(failed).toHaveCount(1);
|
||
await expect(rejected).toHaveCount(0, { timeout: 15000 });
|
||
await expect(status(page)).toHaveText([COPY.previewFailed]);
|
||
// The next selection attempt ends a rejection at once.
|
||
await drop(page, '.file-drop', { files: [parityPng('f02.png'), parityPng('f03.png')] });
|
||
await expect(rejected).toHaveCount(1);
|
||
await chooser(page).dispatchEvent('click');
|
||
await chooser(page).dispatchEvent('cancel');
|
||
await expect(status(page)).toHaveText([COPY.previewFailed]);
|
||
});
|
||
|
||
test('undecodable video keeps container metadata', async ({ page }) => {
|
||
await openInspect(page);
|
||
// A real MP4 whose sample entry names a codec no engine decodes; its container tags are intact.
|
||
await choose(page, undecodableMp4());
|
||
await expect(status(page)).toHaveText(COPY.previewFailed);
|
||
await expect(status(page)).toHaveAttribute('role', 'alert');
|
||
await expect(preview(page).locator('video')).toHaveCount(0);
|
||
await openViewer(page);
|
||
await expect(rail(page).locator('.prompt-section.positive p.prompt-text')).toHaveText('a red fox in snow, detailed fur');
|
||
await expect(rail(page).locator('.meta-grid').first().locator('dd').first()).toHaveText('7');
|
||
});
|
||
|
||
/** Records, for each revoked URL, how many elements (inline or in the viewer) still held it, and counts resets. */
|
||
const trackReleases = page => page.evaluate(() => {
|
||
window.__released = [];
|
||
const revoke = URL.revokeObjectURL;
|
||
URL.revokeObjectURL = url => {
|
||
const live = [...document.querySelectorAll('img, video')].filter(node => node.getAttribute('src') === url);
|
||
window.__released.push({ url, live: live.length });
|
||
return revoke(url);
|
||
};
|
||
const load = HTMLMediaElement.prototype.load;
|
||
window.__loads = 0;
|
||
HTMLMediaElement.prototype.load = function () { if (!this.hasAttribute('src')) window.__loads++; return load.call(this); };
|
||
});
|
||
|
||
test('URLs and media end with their owning session', async ({ page }) => {
|
||
await instrument(page);
|
||
await openInspect(page);
|
||
// Every release pauses the media and drops its src before the URL is revoked.
|
||
await trackReleases(page);
|
||
await choose(page, localFixture('core-tags.webm', 'video/webm'));
|
||
await expect(preview(page).locator('video')).toBeVisible();
|
||
// A settled video (metadata read, or failed) is reset with load() when it is released.
|
||
await expect.poll(() => preview(page).locator('video').evaluate(node => node.readyState >= 1 || !!node.error)).toBe(true);
|
||
// Replacement.
|
||
await choose(page, parityPng('f02.png'));
|
||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
|
||
let state = await urls(page);
|
||
expect(state.revoked).toEqual([state.created[0]]);
|
||
expect(await page.evaluate(() => window.__loads)).toBeGreaterThanOrEqual(1);
|
||
// Route change, from the page (D155: the selection opened the viewer, which makes the sidebar inert).
|
||
await closeOpened(page);
|
||
await page.locator('aside.sidebar nav').getByRole('link', { name: 'Overview', exact: true }).click();
|
||
await expect(main(page).getByRole('heading', { level: 1, name: 'Overview', exact: true })).toBeVisible();
|
||
state = await urls(page);
|
||
expect(state.revoked).toEqual(state.created);
|
||
// Five mount/unmount cycles with a selection and an opened viewer leave no live URL and no dialog.
|
||
for (let cycle = 0; cycle < 5; cycle++) {
|
||
await page.locator('aside.sidebar nav').getByRole('link', { name: 'Inspect a file', exact: true }).click();
|
||
await choose(page, parityPng('f02.png', `cycle-${cycle}.png`));
|
||
await openViewer(page);
|
||
// The modal viewer makes the sidebar inert, so the route changes underneath it.
|
||
await page.evaluate(() => { location.hash = '#/overview'; });
|
||
await expect(main(page).getByRole('heading', { level: 1, name: 'Overview', exact: true })).toBeVisible();
|
||
}
|
||
state = await urls(page);
|
||
expect(state.created).toHaveLength(7);
|
||
expect([...state.revoked].sort()).toEqual([...state.created].sort());
|
||
expect(new Set(state.revoked).size).toBe(state.revoked.length);
|
||
expect((await page.evaluate(() => window.__released)).every(entry => entry.live === 0)).toBe(true);
|
||
await expect(page.locator('dialog')).toHaveCount(0);
|
||
// Page leave releases the selection once.
|
||
await page.locator('aside.sidebar nav').getByRole('link', { name: 'Inspect a file', exact: true }).click();
|
||
await choose(page, parityPng('f02.png', 'leave.png'));
|
||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
|
||
await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: false })));
|
||
state = await urls(page);
|
||
expect(state.created).toHaveLength(8);
|
||
expect(state.revoked.filter(url => url === state.created[7])).toHaveLength(1);
|
||
// Page leave with the viewer open, on a page loaded at Inspect (so its listeners precede the shell's): the viewer
|
||
// closes and releases its media before the URL is revoked, and a page restored from the back-forward cache shows
|
||
// an empty chooser beside the empty selection.
|
||
await page.reload();
|
||
await expect(chooser(page)).toBeAttached();
|
||
await trackReleases(page);
|
||
await choose(page, parityPng('f02.png', 'leave-open.png'));
|
||
await openViewer(page);
|
||
await expect(viewer(page).getByRole('button', { name: 'Zoom in', exact: true })).toHaveAttribute('aria-disabled', 'false');
|
||
await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: true })));
|
||
await expect(page.locator('dialog')).toHaveCount(0);
|
||
state = await urls(page);
|
||
expect(state.created).toHaveLength(1);
|
||
expect(state.revoked).toEqual(state.created);
|
||
expect(await page.evaluate(() => window.__released)).toEqual([{ url: state.created[0], live: 0 }]);
|
||
await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true })));
|
||
await expect(chooser(page)).toBeAttached();
|
||
expect(await chooser(page).evaluate(node => ({ value: node.value, files: node.files.length }))).toEqual({ value: '', files: 0 });
|
||
await expect(preview(page).locator('img, video')).toHaveCount(0);
|
||
await expect(action(page)).toHaveAttribute('aria-disabled', 'true');
|
||
});
|
||
|
||
test('replacing a video that just started loading never blocks the page', async ({ context, browserName }) => {
|
||
test.setTimeout(120000);
|
||
// WebKit (GStreamer) blocked the page when a video whose pipeline was still starting was reset with load(): the
|
||
// next chooser selection, or the steps after it, never completed. Each fresh page replaces its first video as
|
||
// soon as it shows.
|
||
for (let attempt = 0; attempt < 15; attempt++) {
|
||
const page = await context.newPage();
|
||
try {
|
||
if (attempt === 0) await startCoverage(page, browserName);
|
||
await openInspect(page);
|
||
await choose(page, localFixture('core-tags.webm', 'video/webm'));
|
||
await expect(preview(page).locator('video.local-file-preview')).toBeVisible();
|
||
await chooser(page).setInputFiles(parityPng('f02.png'), { timeout: 8000 });
|
||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
|
||
await expect(preview(page).locator('video')).toHaveCount(0);
|
||
if (attempt === 0) await saveCoverage(page, browserName);
|
||
} finally {
|
||
await page.close();
|
||
}
|
||
}
|
||
});
|
||
|
||
test('a video replaced while still starting is reset once it settles', async ({ context, browserName }) => {
|
||
test.setTimeout(180000);
|
||
// The video is replaced from its own `loadstart` (readyState HAVE_NOTHING), or a few milliseconds after it. On
|
||
// WebKit (GStreamer) a video that left the page or the viewport before its pipeline had started deadlocked the
|
||
// page (TESTING.md D62), so it stays in place, inert and still playing its URL, with the new preview after it.
|
||
// Once it reports metadata or an error it is paused, loses its src, is reset (load() with no src, which frees its
|
||
// decoder) and leaves the page, and only then is its URL revoked. Fresh pages share one browser process.
|
||
for (let attempt = 0; attempt < 15; attempt++) {
|
||
const page = await context.newPage();
|
||
try {
|
||
if (attempt === 0) await startCoverage(page, browserName);
|
||
await page.addInitScript(() => {
|
||
const load = HTMLMediaElement.prototype.load;
|
||
HTMLMediaElement.prototype.load = function () { if (!this.hasAttribute('src')) this.__resets = (this.__resets ?? 0) + 1; return load.call(this); };
|
||
const revoke = URL.revokeObjectURL;
|
||
URL.revokeObjectURL = url => {
|
||
const starting = window.__starting;
|
||
if (url === starting?.url) starting.revoked = { src: starting.node.hasAttribute('src'), connected: starting.node.isConnected, resets: starting.node.__resets ?? 0 };
|
||
return revoke(url);
|
||
};
|
||
});
|
||
await openInspect(page);
|
||
await page.evaluate(() => {
|
||
const input = document.querySelector('#local-file'), slot = document.querySelector('#local-preview');
|
||
new MutationObserver((records, observer) => {
|
||
const video = slot.querySelector('video');
|
||
if (!video) return;
|
||
observer.disconnect();
|
||
const replace = () => {
|
||
window.__starting = { node: video, url: video.getAttribute('src'), readyState: video.readyState };
|
||
input.files = window.__pngFiles; input.dispatchEvent(new Event('change', { bubbles: true }));
|
||
window.__starting.replaced = { connected: video.isConnected, src: video.hasAttribute('src'), inert: video.inert, resets: video.__resets ?? 0 };
|
||
};
|
||
video.addEventListener('loadstart', () => { if (window.__delay) setTimeout(replace, window.__delay); else replace(); }, { once: true });
|
||
}).observe(slot, { childList: true });
|
||
});
|
||
const png = parityPng('f02.png');
|
||
await page.evaluate(bytes => {
|
||
const transfer = new DataTransfer();
|
||
transfer.items.add(new File([new Uint8Array(bytes)], 'f02.png', { type: 'image/png' }));
|
||
window.__pngFiles = transfer.files;
|
||
}, [...png.buffer]);
|
||
await page.evaluate(delay => { window.__delay = delay; }, attempt % 5);
|
||
await choose(page, localFixture('core-tags.webm', 'video/webm'));
|
||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible({ timeout: 8000 });
|
||
await expect(preview(page).locator('video')).toHaveCount(0);
|
||
// Replaced while starting (always so at `loadstart` itself): kept in place and untouched. A video that had
|
||
// settled is released at once. Either way it is reset once, and its URL goes last.
|
||
await expect.poll(() => page.evaluate(() => window.__starting.revoked ?? null)).toEqual({ src: false, connected: false, resets: 1 });
|
||
const { readyState, replaced } = await page.evaluate(() => ({ readyState: window.__starting.readyState, replaced: window.__starting.replaced }));
|
||
if (attempt % 5 === 0) expect(readyState).toBe(0);
|
||
expect(replaced).toEqual(readyState === 0 ? { connected: true, src: true, inert: true, resets: 0 } : { connected: false, src: false, inert: false, resets: 1 });
|
||
expect(await page.evaluate(() => window.__starting.node.__resets)).toBe(1);
|
||
// The page still answers.
|
||
await chooser(page).setInputFiles(parityPng('f02.png', 'after.png'), { timeout: 8000 });
|
||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible({ timeout: 8000 });
|
||
if (attempt === 0) await saveCoverage(page, browserName);
|
||
} finally {
|
||
await page.close();
|
||
}
|
||
}
|
||
});
|
||
|
||
test('a new selection while the viewer\'s own video is still starting leaves it until it settles (#483)', async ({ context, browserName }) => {
|
||
test.setTimeout(180000);
|
||
// D155 opens the viewer once the inline preview has settled; the viewer then starts a video of its own. A new
|
||
// selection from that video's own `loadstart` (readyState HAVE_NOTHING), or a few milliseconds after it, must not
|
||
// reset it or take it off the page while it starts (WebKit, GStreamer: TESTING.md D62; merge-queue run
|
||
// 40903238387). The viewer stays open until it reports metadata or an error, then closes; the URL goes last.
|
||
for (let attempt = 0; attempt < 10; attempt++) {
|
||
const page = await context.newPage();
|
||
try {
|
||
if (attempt === 0) await startCoverage(page, browserName);
|
||
await page.addInitScript(() => {
|
||
const load = HTMLMediaElement.prototype.load;
|
||
HTMLMediaElement.prototype.load = function () { if (!this.hasAttribute('src')) { this.__resets = (this.__resets ?? 0) + 1; this.__resetReadyState ??= this.readyState; } return load.call(this); };
|
||
const revoke = URL.revokeObjectURL;
|
||
URL.revokeObjectURL = url => {
|
||
const starting = window.__starting;
|
||
if (url === starting?.url) starting.revoked = { src: starting.node.hasAttribute('src'), connected: starting.node.isConnected, resets: starting.node.__resets ?? 0 };
|
||
return revoke(url);
|
||
};
|
||
});
|
||
await openInspect(page);
|
||
await page.evaluate(() => {
|
||
const input = document.querySelector('#local-file');
|
||
new MutationObserver((records, observer) => {
|
||
const video = document.querySelector('#viewer-dialog video');
|
||
if (!video) return;
|
||
observer.disconnect();
|
||
const replace = () => {
|
||
window.__starting = { node: video, url: video.getAttribute('src'), readyState: video.readyState };
|
||
input.files = window.__pngFiles; input.dispatchEvent(new Event('change', { bubbles: true }));
|
||
window.__starting.replaced = { connected: video.isConnected, src: video.hasAttribute('src'), resets: video.__resets ?? 0 };
|
||
};
|
||
video.addEventListener('loadstart', () => { if (window.__delay) setTimeout(replace, window.__delay); else replace(); }, { once: true });
|
||
}).observe(document.body, { childList: true, subtree: true });
|
||
});
|
||
const png = parityPng('f02.png');
|
||
await page.evaluate(bytes => {
|
||
const transfer = new DataTransfer();
|
||
transfer.items.add(new File([new Uint8Array(bytes)], 'f02.png', { type: 'image/png' }));
|
||
window.__pngFiles = transfer.files;
|
||
}, [...png.buffer]);
|
||
await page.evaluate(delay => { window.__delay = delay; }, attempt % 5);
|
||
await choose(page, localFixture('core-tags.webm', 'video/webm'));
|
||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible({ timeout: 8000 });
|
||
await expect.poll(() => page.evaluate(() => window.__starting?.revoked ?? null)).toEqual({ src: false, connected: false, resets: 1 });
|
||
const { readyState, replaced } = await page.evaluate(() => ({ readyState: window.__starting.readyState, replaced: window.__starting.replaced }));
|
||
if (attempt % 5 === 0) expect(readyState).toBe(0);
|
||
// Starting when replaced: still on the page with its URL, never reset; settled: released at once.
|
||
expect(replaced).toEqual(readyState === 0 ? { connected: true, src: true, resets: 0 } : { connected: false, src: false, resets: 1 });
|
||
expect(await page.evaluate(() => window.__starting.node.__resets)).toBe(1);
|
||
// Its one reset came after it had metadata, never while it was starting.
|
||
expect(await page.evaluate(() => window.__starting.node.__resetReadyState)).toBeGreaterThanOrEqual(1);
|
||
// The new file's viewer opens after the old one closed, and the page still answers.
|
||
await expect(viewer(page).getByRole('img', { name: 'f02.png', exact: true })).toBeVisible({ timeout: 8000 });
|
||
await expect(viewer(page).locator('video')).toHaveCount(0);
|
||
if (attempt === 0) await saveCoverage(page, browserName);
|
||
} finally {
|
||
await page.close();
|
||
}
|
||
}
|
||
});
|
||
|
||
/** #483: holds the viewer's video in HAVE_NOTHING (its `loadedmetadata` never fires) so a hold can be driven by hand. */
|
||
async function stallViewerVideo(page) {
|
||
await page.addInitScript(() => {
|
||
const describe = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'readyState');
|
||
Object.defineProperty(HTMLMediaElement.prototype, 'readyState', { configurable: true, get() {
|
||
return this.closest?.('#viewer-dialog') && window.__stallViewer ? 0 : describe.get.call(this);
|
||
} });
|
||
window.addEventListener('loadedmetadata', event => {
|
||
if (window.__stallViewer && event.target.closest?.('#viewer-dialog')) event.stopImmediatePropagation();
|
||
}, true);
|
||
});
|
||
}
|
||
|
||
test('a held viewer closed by hand, or replaced twice, opens only the latest file\'s viewer at once (#483)', async ({ page }) => {
|
||
await stallViewerVideo(page);
|
||
await openInspect(page);
|
||
await page.evaluate(() => { window.__stallViewer = true; });
|
||
await choose(page, localFixture('core-tags.webm', 'video/webm'));
|
||
await expect(viewer(page).locator('video')).toBeAttached();
|
||
// Two selections while the viewer's video is (made to look) still starting: the viewer stays, untouched.
|
||
await chooser(page).setInputFiles(parityPng('f02.png', 'first.png'));
|
||
await chooser(page).setInputFiles(parityPng('f02.png', 'second.png'));
|
||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
|
||
await expect(viewer(page).locator('video')).toBeAttached();
|
||
// Open file metadata pressed meanwhile (the page behind the viewer is inert, so by script): the latest request.
|
||
await action(page).evaluate(node => node.click());
|
||
// Closing it by hand ends the hold at once: the latest file's viewer opens, once, well before D62's 15 s.
|
||
await page.keyboard.press('Escape');
|
||
await expect(viewer(page).getByRole('img', { name: 'second.png', exact: true })).toBeVisible({ timeout: 5000 });
|
||
await expect(viewer(page).getByRole('img', { name: 'first.png', exact: true })).toHaveCount(0);
|
||
await expect(viewer(page).locator('video')).toHaveCount(0);
|
||
await expect(page.locator('dialog[open]')).toHaveCount(1);
|
||
});
|
||
|
||
test('a held viewer revokes its URL only once it is gone, drops a stale open, and ends on page leave (#483)', async ({ page }) => {
|
||
await stallViewerVideo(page);
|
||
await openInspect(page);
|
||
await trackReleases(page);
|
||
await page.evaluate(() => { window.__stallViewer = true; });
|
||
const videoUrl = () => page.evaluate(() => document.querySelector('#viewer-dialog video')?.getAttribute('src') ?? null);
|
||
const released = url => page.evaluate(url => window.__released.filter(item => item.url === url), url);
|
||
await choose(page, localFixture('core-tags.webm', 'video/webm'));
|
||
await expect(viewer(page).locator('video')).toBeAttached();
|
||
const first = await videoUrl();
|
||
await chooser(page).setInputFiles(parityPng('f02.png', 'held.png'));
|
||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
|
||
// The session asked to revoke the video's URL; it waits while the held viewer still plays it.
|
||
expect(await released(first)).toEqual([]);
|
||
// An Open asked for during the hold, then an AVI (which never opens the viewer by itself): the open for the earlier
|
||
// file is stale and is dropped when the hold ends.
|
||
await action(page).evaluate(node => node.click());
|
||
const avi = { name: 'clip.avi', mimeType: 'video/x-msvideo', buffer: Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('AVI LIST'), Buffer.alloc(64)]) };
|
||
await chooser(page).setInputFiles(avi);
|
||
await expect(page.locator('#workspace-toast')).toHaveText(TOAST);
|
||
await page.keyboard.press('Escape');
|
||
await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||
await expect.poll(() => released(first)).toEqual([{ url: first, live: 0 }]);
|
||
// Page leave while held: the viewer goes and the deferred URL is revoked with nothing holding it.
|
||
await choose(page, localFixture('core-tags.webm', 'video/webm'));
|
||
await expect(viewer(page).locator('video')).toBeAttached();
|
||
const second = await videoUrl();
|
||
await chooser(page).setInputFiles(parityPng('f02.png', 'leave.png'));
|
||
await expect(viewer(page).locator('video')).toBeAttached();
|
||
expect(await released(second)).toEqual([]);
|
||
await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: true })));
|
||
await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||
await expect.poll(() => released(second)).toEqual([{ url: second, live: 0 }]);
|
||
// Another route while held: the page's dispose ends the hold and revokes the URL with nothing holding it.
|
||
await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true })));
|
||
await choose(page, localFixture('core-tags.webm', 'video/webm'));
|
||
await expect(viewer(page).locator('video')).toBeAttached();
|
||
const third = await videoUrl();
|
||
await chooser(page).setInputFiles(parityPng('f02.png', 'route.png'));
|
||
await expect(viewer(page).locator('video')).toBeAttached();
|
||
await page.evaluate(() => { window.location.hash = '#/library'; });
|
||
await expect(main(page).getByRole('heading', { level: 1, name: 'Prompt library', exact: true })).toBeVisible();
|
||
await expect.poll(() => released(third)).toEqual([{ url: third, live: 0 }]);
|
||
});
|
||
|
||
test('leaving for another route while a video starts waits for it to settle', async ({ context, browserName }) => {
|
||
test.setTimeout(180000);
|
||
// A WebKit (GStreamer) video detached before its pipeline had started deadlocked the page (TESTING.md D62); a
|
||
// route change detaches the whole page, so the shell renders the next route once the video reports metadata or an
|
||
// error. The route changes from the video's own `loadstart`, or a few milliseconds after it.
|
||
for (let attempt = 0; attempt < 15; attempt++) {
|
||
const page = await context.newPage();
|
||
try {
|
||
if (attempt === 0) await startCoverage(page, browserName);
|
||
await page.addInitScript(() => {
|
||
const load = HTMLMediaElement.prototype.load;
|
||
HTMLMediaElement.prototype.load = function () {
|
||
if (!this.hasAttribute('src') && window.__starting?.node === this) window.__starting.reset = { readyState: this.readyState, failed: this.error !== null, connected: this.isConnected };
|
||
return load.call(this);
|
||
};
|
||
});
|
||
await openInspect(page);
|
||
await page.evaluate(delay => {
|
||
const slot = document.querySelector('#local-preview');
|
||
new MutationObserver((records, observer) => {
|
||
const video = slot.querySelector('video');
|
||
if (!video) return;
|
||
observer.disconnect();
|
||
const leave = () => {
|
||
window.__starting = { node: video, readyState: video.readyState };
|
||
location.hash = '#/overview';
|
||
};
|
||
video.addEventListener('loadstart', () => { if (delay) setTimeout(leave, delay); else leave(); }, { once: true });
|
||
}).observe(slot, { childList: true });
|
||
}, attempt % 5);
|
||
await choose(page, localFixture('core-tags.webm', 'video/webm'));
|
||
await expect(main(page).getByRole('heading', { level: 1, name: 'Overview', exact: true })).toBeVisible({ timeout: 8000 });
|
||
const { readyState, reset } = await page.evaluate(() => ({ readyState: window.__starting.readyState, reset: window.__starting.reset }));
|
||
if (attempt % 5 === 0) expect(readyState).toBe(0);
|
||
// The video was reset and left the page only once it had settled.
|
||
expect(reset.readyState >= 1 || reset.failed).toBe(true);
|
||
expect(reset.connected).toBe(true);
|
||
// The page still answers.
|
||
await page.locator('aside.sidebar nav').getByRole('link', { name: 'Inspect a file', exact: true }).click();
|
||
await chooser(page).setInputFiles(parityPng('f02.png'), { timeout: 8000 });
|
||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible({ timeout: 8000 });
|
||
if (attempt === 0) await saveCoverage(page, browserName);
|
||
} finally {
|
||
await page.close();
|
||
}
|
||
}
|
||
});
|
||
|
||
test('closing the viewer on a video that just started never blocks the page', async ({ context, browserName }) => {
|
||
test.setTimeout(180000);
|
||
// The viewer's video teardown resets its element; on WebKit a reset of a still-starting pipeline blocked the page.
|
||
for (let attempt = 0; attempt < 15; attempt++) {
|
||
const page = await context.newPage();
|
||
try {
|
||
if (attempt === 0) await startCoverage(page, browserName);
|
||
await openInspect(page);
|
||
await choose(page, localFixture('core-tags.webm', 'video/webm'));
|
||
await openViewer(page);
|
||
await page.keyboard.press('Escape');
|
||
await expect(viewer(page)).toHaveCount(0, { timeout: 8000 });
|
||
await chooser(page).setInputFiles(parityPng('f02.png'), { timeout: 8000 });
|
||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible({ timeout: 8000 });
|
||
if (attempt === 0) await saveCoverage(page, browserName);
|
||
} finally {
|
||
await page.close();
|
||
}
|
||
}
|
||
});
|
||
|
||
test('replacing a file mid-read never shows or sends the old record', async ({ page, context }) => {
|
||
const host = await bridgeHost(context);
|
||
try {
|
||
await openInspect(page);
|
||
// Hold every read of held.png after its classification prefix.
|
||
await page.evaluate(() => {
|
||
let release;
|
||
window.__gate = new Promise(resolve => { release = resolve; });
|
||
window.__releaseGate = release;
|
||
window.__held = 0;
|
||
const slice = Blob.prototype.slice;
|
||
Blob.prototype.slice = function (...args) {
|
||
const part = slice.apply(this, args);
|
||
if (this.name !== 'held.png' || (args[0] ?? 0) === 0 && (args[1] ?? this.size) <= 32) return part;
|
||
const read = part.arrayBuffer.bind(part);
|
||
part.arrayBuffer = async () => { window.__held++; await window.__gate; return read(); };
|
||
return part;
|
||
};
|
||
});
|
||
await choose(page, parityPng('f03.png', 'held.png'));
|
||
await expect.poll(() => page.evaluate(() => window.__held)).toBeGreaterThan(0);
|
||
await choose(page, sendablePng('fresh.png'));
|
||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
|
||
await page.evaluate(() => window.__releaseGate());
|
||
await openViewer(page);
|
||
await expect(viewer(page).locator('#viewer-title')).toHaveText('fresh.png');
|
||
await expect(rail(page).locator('.prompt-section.positive p.prompt-text')).toHaveText(POSITIVE_LINES);
|
||
await sendPair(page);
|
||
expect(await host.evaluate(() => window.inspectApplied)).toEqual([{ positive: POSITIVE_LINES, negative: '' }]);
|
||
} finally {
|
||
await host.evaluate(() => window.inspectHost.dispose());
|
||
await host.close();
|
||
}
|
||
});
|
||
|
||
/** A second tab acting as a ComfyUI host with one Prompt Manager pair destination. */
|
||
async function bridgeHost(context) {
|
||
const host = await context.newPage();
|
||
await host.goto(`${workspace}#/overview`);
|
||
await host.evaluate(async () => {
|
||
const { createBridgeHost } = await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
|
||
const target = { id: 'pair-1', label: 'Inspect destination', type: 'PromptManagerPair', roles: ['positive', 'negative'] };
|
||
window.inspectApplied = [];
|
||
window.inspectPreviews = [];
|
||
window.inspectHost = createBridgeHost({ origin: location.origin, namespace: '/', label: 'Inspect ComfyUI', adapter: {
|
||
listTargets: () => [target],
|
||
preview: (_id, roles) => {
|
||
window.inspectPreviews.push(roles);
|
||
return { preview: { target, fields: Object.entries(roles).map(([role, after]) => ({ role, before: `Previous ${role}`, after })),
|
||
composition: { prepend: '', append: '', prependConnected: false, appendConnected: false, before: 'Previous positive', after: roles.positive ?? 'Previous positive' } }, snapshot: roles };
|
||
},
|
||
apply: roles => { window.inspectApplied.push(roles); return { targetId: target.id, values: roles }; },
|
||
} });
|
||
});
|
||
return host;
|
||
}
|
||
|
||
async function sendPair(page) {
|
||
await rail(page).getByRole('button', { name: /^Send prompt pair to ComfyUI: / }).click();
|
||
const sender = page.getByRole('dialog', { name: 'Send to ComfyUI', exact: true });
|
||
await sender.getByRole('combobox', { name: 'ComfyUI window', exact: true }).selectOption({ label: 'Inspect ComfyUI' });
|
||
await sender.getByRole('combobox', { name: 'Destination node', exact: true }).selectOption({ label: 'Inspect destination' });
|
||
await sender.getByRole('button', { name: 'Preview changes', exact: true }).click();
|
||
await sender.getByRole('button', { name: 'Apply to ComfyUI', exact: true }).click();
|
||
await expect(sender).toContainText('Sent to ComfyUI.');
|
||
await sender.getByRole('button', { name: 'Close', exact: true }).click();
|
||
}
|
||
|
||
test('local roles send exactly', async ({ page, context }) => {
|
||
const host = await bridgeHost(context);
|
||
try {
|
||
await openInspect(page);
|
||
await choose(page, sendablePng());
|
||
await openViewer(page);
|
||
await expect(rail(page).locator('.prompt-section.positive p.prompt-text')).toHaveText(POSITIVE_LINES);
|
||
await expect(rail(page).locator('.prompt-section.negative p.prompt-text')).toHaveText('No negative prompt (explicitly empty)');
|
||
await sendPair(page);
|
||
const applied = await host.evaluate(() => window.inspectApplied);
|
||
expect(applied).toEqual([{ positive: POSITIVE_LINES, negative: '' }]);
|
||
const payload = JSON.stringify([applied, await host.evaluate(() => window.inspectPreviews)]);
|
||
expect(payload).not.toContain('blob:');
|
||
expect(payload).not.toContain('send-me');
|
||
// The viewer is still open, focus came back to the send action.
|
||
await expect(viewer(page)).toBeVisible();
|
||
} finally {
|
||
await host.evaluate(() => window.inspectHost.dispose());
|
||
await host.close();
|
||
}
|
||
});
|
||
|
||
test('local inspection makes no content request or storage write', async ({ page }) => {
|
||
await instrument(page);
|
||
const requests = [];
|
||
page.on('request', request => requests.push({ url: request.url(), method: request.method(), body: request.postData() }));
|
||
page.on('websocket', socket => requests.push({ url: socket.url(), method: 'WS', body: null }));
|
||
const table = /\/prompt_manager\/js\/inspection-semantics\.json$/;
|
||
const tableRead = page.waitForResponse(response => table.test(response.url()));
|
||
await openInspect(page);
|
||
// The one static file Inspect reads, its node table, is fetched as the page mounts: before any selection.
|
||
expect((await tableRead).ok()).toBe(true);
|
||
// The shell's own reads (counts, Activity) settle before the baseline.
|
||
await expect(page.locator('aside.sidebar nav [data-route="images"] .nav-count')).toBeAttached();
|
||
const baseline = (await urls(page)).writes.length;
|
||
const before = requests.length;
|
||
expect(requests.filter(entry => table.test(entry.url))).toHaveLength(1);
|
||
for (const file of [sendablePng('private-name.png'), localFixture('core-tags.webm', 'video/webm', 'private-clip.webm')]) {
|
||
await choose(page, file);
|
||
await openViewer(page);
|
||
await rail(page).locator('summary', { hasText: 'Workflow & raw metadata' }).click();
|
||
await page.keyboard.press('Escape');
|
||
await expect(viewer(page)).toHaveCount(0);
|
||
}
|
||
const network = requests.slice(before).filter(entry => !entry.url.startsWith('blob:') && !entry.url.startsWith('data:'));
|
||
expect(requests.filter(entry => table.test(entry.url)), 'the table is read once per page').toHaveLength(1);
|
||
expect(network.filter(entry => /private|lighthouse|fox/.test(`${entry.url} ${entry.body ?? ''}`))).toEqual([]);
|
||
expect(network.filter(entry => entry.method !== 'GET')).toEqual([]);
|
||
expect(network.filter(entry => !/\/prompt_manager\/(activity|images\/output|library|vocabulary)/.test(entry.url))).toEqual([]);
|
||
expect((await urls(page)).writes.slice(baseline)).toEqual([]);
|
||
});
|
||
|
||
test('touch layout at 390 has no horizontal overflow at 200% zoom', async ({ page }) => {
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await openInspect(page);
|
||
await choose(page, parityPng('f02.png'));
|
||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
|
||
for (const zoom of ['1', '2']) {
|
||
await page.evaluate(value => { document.documentElement.style.zoom = value; }, zoom);
|
||
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||
const area = await page.locator('.file-drop').evaluate(node => node.getBoundingClientRect().right <= document.documentElement.clientWidth + 0.5);
|
||
expect(area).toBe(true);
|
||
}
|
||
});
|
||
|
||
// LI-4 (D117): "Copy settings summary" after the six facts is the legacy metadata page's COPY ALL, in the shared full
|
||
// inspector, so a file served in Images and the same bytes chosen here copy the same summary of what the rail shows.
|
||
/** Records clipboard writes in the page, or (`clipboard: false`) leaves the page without a clipboard. */
|
||
async function stubClipboard(page, { clipboard = true } = {}) {
|
||
await page.addInitScript(available => {
|
||
window.__copied = [];
|
||
Object.defineProperty(navigator, 'clipboard', { configurable: true,
|
||
value: available ? { writeText: async text => { window.__copied.push(text); } } : undefined });
|
||
}, clipboard);
|
||
}
|
||
/** The summary the rail's own prompts and facts make, in the legacy COPY ALL lines and placeholders. */
|
||
const expectedSummary = locator => locator.evaluate(node => {
|
||
const facts = Object.fromEntries([...node.querySelectorAll(':scope .meta-grid:not(details .meta-grid) > div')]
|
||
.map(item => [item.querySelector('dt').textContent, item.querySelector('dd').textContent]));
|
||
// A confirmed prompt has its Copy action; an explicitly empty one takes the legacy placeholder too.
|
||
const prompt = role => {
|
||
const text = node.querySelector(`.prompt-section.${role} [data-copy-role]`) ? node.querySelector(`.prompt-section.${role} p.prompt-text`).textContent : null;
|
||
return text === `No ${role} prompt (explicitly empty)` ? null : text;
|
||
};
|
||
return [`Checkpoint: ${facts.Model}`, `Positive Prompt: ${prompt('positive') ?? 'No prompt found'}`,
|
||
`Negative Prompt: ${prompt('negative') ?? 'No negative prompt found'}`, `Steps: ${facts.Steps}`, `CFG Scale: ${facts.CFG}`,
|
||
`Sampler: ${facts.Sampler}`, `Seed: ${facts.Seed}`].join('\n');
|
||
});
|
||
async function copySummary(page) {
|
||
const button = rail(page).getByRole('button', { name: 'Copy settings summary', exact: true });
|
||
// It follows the six facts, before "File & provenance".
|
||
expect(await button.evaluate(node => [node.previousElementSibling?.localName, node.nextElementSibling?.localName])).toEqual(['dl', 'details']);
|
||
await button.click();
|
||
}
|
||
|
||
test('Copy settings summary copies the same legacy summary in Images and Inspect', async ({ page, request }) => {
|
||
await stubClipboard(page);
|
||
await expect(await request.post('/__test__/add-local-inspect-file', { data: { name: 'f02.png' } })).toBeOK();
|
||
await page.goto(`${workspace}#/images`);
|
||
await page.locator('article[data-image-key="0:local-inspect/f02.png"] .image-open').click();
|
||
await expect(rail(page).locator('.prompt-section.positive p.prompt-text')).toBeVisible();
|
||
const served = await expectedSummary(rail(page));
|
||
expect(served).toMatch(/^Checkpoint: .+\nPositive Prompt: [\s\S]+\nNegative Prompt: [\s\S]+\nSteps: .+\nCFG Scale: .+\nSampler: .+\nSeed: .+$/);
|
||
expect(served).not.toContain('Unknown');
|
||
await copySummary(page);
|
||
await expect(page.locator('#workspace-toast')).toHaveText('Settings summary copied.');
|
||
expect(await page.evaluate(() => window.__copied)).toEqual([served]);
|
||
await page.keyboard.press('Escape');
|
||
await expect(viewer(page)).toHaveCount(0);
|
||
|
||
await openInspect(page);
|
||
await choose(page, parityPng('f02.png'));
|
||
await openViewer(page);
|
||
expect(await expectedSummary(rail(page))).toBe(served);
|
||
await copySummary(page);
|
||
await expect.poll(() => page.evaluate(() => window.__copied)).toEqual([served, served]);
|
||
// The panel variant (the Images side panel) keeps the concept's footer: no summary action there.
|
||
await page.keyboard.press('Escape');
|
||
await page.goto(`${workspace}#/images`);
|
||
await expect(page.locator('#image-inspector .panel-heading')).toBeVisible();
|
||
await expect(page.locator('#image-inspector').getByRole('button', { name: 'Copy settings summary', exact: true })).toHaveCount(0);
|
||
});
|
||
|
||
test('Copy settings summary without a clipboard opens the select-to-copy dialog', async ({ page }) => {
|
||
await stubClipboard(page, { clipboard: false });
|
||
await openInspect(page);
|
||
await choose(page, sendablePng());
|
||
await openViewer(page);
|
||
const summary = await expectedSummary(rail(page));
|
||
expect(summary).toContain(`Positive Prompt: ${POSITIVE_LINES}`);
|
||
await copySummary(page);
|
||
const fallback = page.getByRole('dialog', { name: 'Copy settings summary', exact: true });
|
||
await expect(fallback).toBeVisible();
|
||
await expect(fallback.getByRole('textbox', { name: 'Select and copy this text', exact: true })).toHaveValue(summary);
|
||
await expect(fallback.getByRole('textbox', { name: 'Select and copy this text', exact: true })).toBeFocused();
|
||
await page.keyboard.press('Escape');
|
||
await expect(fallback).toHaveCount(0);
|
||
// Escape closes only the copy dialog; focus returns to the action in the still-open viewer.
|
||
await expect(viewer(page)).toBeVisible();
|
||
await expect(rail(page).getByRole('button', { name: 'Copy settings summary', exact: true })).toBeFocused();
|
||
});
|