Merge remote-tracking branch 'origin/main' into feat/249-activity-jobs
This commit is contained in:
@@ -109,6 +109,13 @@ the baseline updater. Raise floors with `npm run --prefix tools/vendor
|
||||
coverage:update`; it reruns the suites and cannot lower an existing floor.
|
||||
Review baseline changes like code changes.
|
||||
|
||||
`npm run --prefix tools/vendor coverage:baseline-check` runs in well under a
|
||||
second without a browser and fails, naming the files, when a measured
|
||||
`web/js` file has no baseline entry or the baseline lists a file that no longer
|
||||
exists. It uses the gate's own `discoverSources`, and `npm test` runs the same
|
||||
check, so run it before pushing a new or deleted frontend file instead of
|
||||
waiting for the `frontend coverage` job.
|
||||
|
||||
The `viewerjs` check runs on every PR, including backend-only changes. It is an
|
||||
aggregate job that passes only when the `frontend coverage (chromium and touch)`
|
||||
job and both `page workflows (firefox)` and `page workflows (webkit)` jobs
|
||||
|
||||
@@ -158,6 +158,10 @@ async function filteredOf(page,apply,undo) {
|
||||
await apply();
|
||||
await expect.poll(()=>captionOf(page)).not.toBe(before);
|
||||
await expect.poll(()=>page.locator(CARDS).count()).toBeLessThan(CONCEPT_PROMPTS);
|
||||
// Production empties the shelf when the filter applies, writes the answer's caption at once and places its cards
|
||||
// in the next animation frame (one paint for a viewer), so the empty shelf already counts as "fewer": read the
|
||||
// titles only after that frame, as the Images and Inspector comparisons do.
|
||||
await settle(page);
|
||||
const result={caption:await captionOf(page),titles:await titlesOf(page)};
|
||||
await undo();
|
||||
await expect.poll(()=>captionOf(page)).toBe(before);
|
||||
|
||||
@@ -1487,6 +1487,30 @@ test('a star press leaves the continuation status, count and caption date untouc
|
||||
expect(await page.evaluate(() => window.__statusMutations)).toEqual([]);
|
||||
});
|
||||
|
||||
// Opened before the source and folder discovery answered (a slow server), Refine lists them once it does.
|
||||
test('Refine opened before discovery settles lists the discovered sources and folders', async ({ page }) => {
|
||||
let release, started;
|
||||
const held = new Promise(resolve => { release = resolve; }), hit = new Promise(resolve => { started = resolve; });
|
||||
await page.route('**/prompt_manager/gallery/subfolders?*', async route => { started(); await held; await route.continue(); }, { times: 1 });
|
||||
await page.goto(workspace);
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
await hit;
|
||||
await filtersButton(page).click();
|
||||
const source = refineDialog(page).getByLabel('Source', { exact: true });
|
||||
const folder = refineDialog(page).getByLabel('Folder', { exact: true });
|
||||
const values = select => select.locator('option').evaluateAll(options => options.map(option => option.value));
|
||||
expect(await values(source)).toEqual(['all']);
|
||||
expect(await values(folder)).toEqual(['']);
|
||||
release();
|
||||
await expect.poll(() => values(source)).toEqual(['all', '0', '1']);
|
||||
expect(await values(folder)).toEqual(['', 'shared']);
|
||||
await expect(source).toHaveValue('all');
|
||||
await source.selectOption('1');
|
||||
await refineDialog(page).getByRole('button', { name: 'Apply filters', exact: true }).click();
|
||||
await expect(cards(page)).toHaveCount(1);
|
||||
await expect(cards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
|
||||
});
|
||||
|
||||
// #249 final review m1: as in the old field row, a Source offers only its own folders.
|
||||
test('Refine offers only the chosen source folders', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/add-nested-image')).toBeOK();
|
||||
|
||||
@@ -342,6 +342,56 @@ test('refine dialog round-trips values and clear resets them', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('Refine lists options discovered after it opened and keeps the wanted values', async () => {
|
||||
const { openRefineDialog, openImportDialog, closeWorkspaceDialog } = await dialogsModule;
|
||||
const dom = fakeDOM();
|
||||
const scope = (await coreModule).createScope();
|
||||
try {
|
||||
let bySource = {};
|
||||
const optionsFor = source => [['', 'All output folders'], ...(bySource[source] ?? []).map(path => [path, path])];
|
||||
const fields = [{ key: 'source', options: [['all', 'All sources']] }, { key: 'folder', options: optionsFor('all'), optionsFor },
|
||||
REFINE_FIELDS[2]];
|
||||
const applied = [];
|
||||
const refine = openRefineDialog({ scope, fields, values: { source: '1', folder: 'temp-only', media: 'video' },
|
||||
onApply: value => applied.push(value), onClear() {} });
|
||||
const [source, folder, media] = byId(dom, 'modal-body').children.filter(child => child.className === 'field')
|
||||
.map(field => field.children[1]);
|
||||
// Opened before discovery: only the cleared values.
|
||||
assert.deepEqual(optionPairs(source), [['all', 'All sources']]);
|
||||
assert.equal(source.value, 'all');
|
||||
assert.equal(folder.value, '');
|
||||
bySource = { all: ['portraits', 'temp-only'], 0: ['portraits'], 1: ['temp-only'] };
|
||||
refine.setOptions('source', [['all', 'All sources'], ['0', 'output'], ['1', 'archive']]);
|
||||
// The values it opened with, now offered, and the chosen Source's folders.
|
||||
assert.deepEqual(optionPairs(source).map(([value]) => value), ['all', '0', '1']);
|
||||
assert.equal(source.value, '1');
|
||||
assert.deepEqual(optionPairs(folder), [['', 'All output folders'], ['temp-only', 'temp-only']]);
|
||||
assert.equal(folder.value, 'temp-only');
|
||||
// A choice made since wins over the opening value; tags and unknown keys are ignored.
|
||||
source.value = '0';
|
||||
source.dispatchEvent(new Event('change'));
|
||||
refine.setOptions('source', [['all', 'All sources'], ['0', 'output'], ['1', 'archive']]);
|
||||
refine.setOptions('tags', [['x', 'x']]);
|
||||
assert.equal(source.value, '0');
|
||||
assert.deepEqual(optionPairs(folder).map(([value]) => value), ['', 'portraits']);
|
||||
assert.equal(media.value, 'video');
|
||||
button(byId(dom, 'modal-body'), 'Apply filters').click();
|
||||
assert.deepEqual(applied, [{ source: '0', folder: '', media: 'video' }]);
|
||||
// Closed: a late discovery leaves the shell alone.
|
||||
refine.setOptions('source', [['all', 'All sources']]);
|
||||
assert.equal(source.options.length, 3);
|
||||
// Replaced by another dialog: the same.
|
||||
const again = openRefineDialog({ scope, fields, values: {}, onApply() {}, onClear() {} });
|
||||
openImportDialog({ scope, onInspect() {}, onRescan() {} });
|
||||
again.setOptions('source', [['all', 'All sources'], ['9', 'late']]);
|
||||
assert.equal(byId(dom, 'modal-title').textContent, 'Import & inspect');
|
||||
} finally {
|
||||
closeWorkspaceDialog();
|
||||
scope.dispose();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('the Folder list follows the chosen Source', async () => {
|
||||
const { openRefineDialog, closeWorkspaceDialog } = await dialogsModule;
|
||||
const dom = fakeDOM();
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { baselineGaps, measuredSources, readBaseline } from '../../tools/vendor/coverage-baseline-check.mjs';
|
||||
|
||||
const floor = { lines: 80, branches: 80, functions: 80, statements: 80 };
|
||||
|
||||
test('baseline gaps name measured files without a baseline entry', () => {
|
||||
const gaps = baselineGaps(['a.js', 'nested/new.mjs'], { 'a.js': floor });
|
||||
assert.deepEqual(gaps.missing, ['nested/new.mjs']);
|
||||
assert.deepEqual(gaps.stale, []);
|
||||
});
|
||||
|
||||
test('baseline gaps name baseline entries whose file no longer exists', () => {
|
||||
const gaps = baselineGaps(['a.js'], { 'a.js': floor, 'gone.js': floor });
|
||||
assert.deepEqual(gaps.missing, []);
|
||||
assert.deepEqual(gaps.stale, ['gone.js']);
|
||||
});
|
||||
|
||||
test('baseline gaps are empty when files and baseline agree', () => {
|
||||
assert.deepEqual(baselineGaps(['a.js'], { 'a.js': floor }), { missing: [], stale: [] });
|
||||
});
|
||||
|
||||
test('the committed baseline covers exactly the measured frontend sources', () => {
|
||||
const gaps = baselineGaps(measuredSources(), readBaseline());
|
||||
assert.deepEqual(gaps.missing, [], `Missing baseline (run coverage:update): ${gaps.missing.join(', ')}`);
|
||||
assert.deepEqual(gaps.stale, [], `Stale baseline entries (file removed): ${gaps.stale.join(', ')}`);
|
||||
});
|
||||
|
||||
test('the real gate and this check select files through the same function', () => {
|
||||
const gate = readFileSync(new URL('../../tools/vendor/frontend-coverage.mjs', import.meta.url), 'utf8');
|
||||
assert.match(gate, /discoverSources\(sourceRoot\)/);
|
||||
});
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
// Fast, browser-free check that frontend-coverage-baseline.json lists exactly the
|
||||
// files the coverage gate measures. CI's `frontend coverage` job reports a missing
|
||||
// entry only after ~20 minutes; this reports it in milliseconds.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { repositoryRoot } from './vendor-viewerjs.mjs';
|
||||
import { discoverSources } from './frontend-coverage.mjs';
|
||||
|
||||
const baselinePath = join(dirname(fileURLToPath(import.meta.url)), 'frontend-coverage-baseline.json');
|
||||
|
||||
export function measuredSources() {
|
||||
return discoverSources(join(repositoryRoot, 'web/js'));
|
||||
}
|
||||
|
||||
export function readBaseline() {
|
||||
return JSON.parse(readFileSync(baselinePath, 'utf8'));
|
||||
}
|
||||
|
||||
export function baselineGaps(files, baseline) {
|
||||
const measured = new Set(files);
|
||||
return {
|
||||
missing: files.filter(file => !baseline[file]),
|
||||
stale: Object.keys(baseline).filter(file => !measured.has(file)).sort(),
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { missing, stale } = baselineGaps(measuredSources(), readBaseline());
|
||||
if (!missing.length && !stale.length) {
|
||||
console.log('Coverage baseline matches the measured frontend files.');
|
||||
return;
|
||||
}
|
||||
if (missing.length) console.error(`Missing baseline for:\n ${missing.join('\n ')}\nRun: npm run --prefix tools/vendor coverage:update`);
|
||||
if (stale.length) console.error(`Baseline lists files that no longer exist:\n ${stale.join('\n ')}\nRemove them from tools/vendor/frontend-coverage-baseline.json`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();
|
||||
Vendored
+1
@@ -15,6 +15,7 @@
|
||||
"test:comfyui": "playwright test --config comfyui.config.mjs",
|
||||
"test:executions": "playwright test --config playwright.execution.config.mjs",
|
||||
"test:coverage": "node frontend-coverage.mjs",
|
||||
"coverage:baseline-check": "node coverage-baseline-check.mjs",
|
||||
"coverage:update": "node frontend-coverage.mjs --update-baseline",
|
||||
"test:compat": "playwright test --config pack-compat.config.mjs"
|
||||
},
|
||||
|
||||
@@ -134,6 +134,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
let state, geometry = { positions: [], columns: 1, height: 0, maxHeight: 0 }, galleryWidth = 0, optionsRequest, categoryRequest, recovering = false, rescanning = false;
|
||||
// Discovered sources (each with its own folders) and every folder, for "Refine this view" and the scope chip.
|
||||
let roots = [], folders = [];
|
||||
// The open "Refine this view", so a discovery that settles after it opened still lists its sources and folders.
|
||||
let refine = null;
|
||||
const rendered = new Map();
|
||||
const itemIndexes = new Map();
|
||||
const filters = { root: 'all', q: '', category: '', tags: [], subfolder: '', media: 'all', sort: 'newest', ...visit?.filters, ...saved,
|
||||
@@ -646,8 +648,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
/** "Refine this view": Source, Folder, Media and Tags, the filters the old field row held (D14). */
|
||||
function openFilters(trigger) {
|
||||
if (recovering) return;
|
||||
openRefineDialog({scope, trigger, fields:[
|
||||
{key:'source', options:[['all', 'All sources'], ...roots.map(root => [String(root.index), root.label])]},
|
||||
refine = openRefineDialog({scope, trigger, fields:[
|
||||
{key:'source', options:sourceOptions()},
|
||||
{key:'folder', options:folderOptions('all'), optionsFor:folderOptions},
|
||||
{key:'media', options:MEDIA_OPTIONS},
|
||||
{key:'tags'},
|
||||
@@ -657,6 +659,9 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
applyRefinement(cleared);
|
||||
}});
|
||||
}
|
||||
function sourceOptions() {
|
||||
return [['all', 'All sources'], ...roots.map(root => [String(root.index), root.label])];
|
||||
}
|
||||
/** The chosen Source's folders, as the old field row reloaded them; every folder under All sources. */
|
||||
function folderOptions(source) {
|
||||
const root = source === 'all' ? null : roots.find(item => String(item.index) === source);
|
||||
@@ -753,6 +758,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
roots = data.roots.filter(root => Number.isSafeInteger(root?.index) && typeof root.label === 'string')
|
||||
.map(root => ({index:root.index, label:root.label, subfolders:paths(root.subfolders)}));
|
||||
folders = paths(data.subfolders);
|
||||
refine?.setOptions('source', sourceOptions());
|
||||
updateScope();
|
||||
sourcesRetry = null;
|
||||
return true;
|
||||
|
||||
@@ -155,6 +155,9 @@ function choose(select, value) {
|
||||
* Tags; Folder may add `optionsFor(source)`, the options of one Source, so its list follows the chosen Source and a
|
||||
* folder that Source lacks returns to the first option. `values` holds the current value per key. Apply reports the edited values; Clear resets every field and
|
||||
* reports the cleared values plus `category: ''`, so the page clears its toolbar category too. Both close first.
|
||||
* Returns `{setOptions(key, options)}` for options discovered after the dialog opened (a slow folder discovery): the
|
||||
* select is refilled and shows the value the field opened with, or the one chosen since, once it is offered; a Folder
|
||||
* that follows its Source is refilled from `optionsFor` again. It does nothing once this dialog closed or was replaced.
|
||||
*/
|
||||
export function openRefineDialog({ scope, fields, values = {}, onApply, onClear, trigger = null }) {
|
||||
requireScope(scope);
|
||||
@@ -175,16 +178,21 @@ export function openRefineDialog({ scope, fields, values = {}, onApply, onClear,
|
||||
choose(select, values[key]);
|
||||
return [{ key, control: select, cleared: item.options[0][0], optionsFor: item.optionsFor }];
|
||||
});
|
||||
// Each select's wanted value: the one it opened with until the person picks another (see setOptions).
|
||||
const wanted = new Map(controls.map(({ key }) => [key, values[key]]));
|
||||
for (const { key, control } of controls) {
|
||||
if (key !== 'tags') control.addEventListener('change', () => wanted.set(key, control.value));
|
||||
}
|
||||
const source = controls.find(item => item.key === 'source');
|
||||
const folder = controls.find(item => item.key === 'folder' && typeof item.optionsFor === 'function');
|
||||
const followSource = () => {
|
||||
const chosen = folder.control.value;
|
||||
const followSource = (chosen = folder.control.value) => {
|
||||
fillSelect(folder.control, 'folder', folder.optionsFor(source.control.value));
|
||||
choose(folder.control, chosen);
|
||||
};
|
||||
if (source && folder) {
|
||||
followSource();
|
||||
source.control.addEventListener('change', followSource);
|
||||
followSource(values.folder);
|
||||
// A Source that lacks the chosen folder clears it, and that cleared folder is the wanted one from then on.
|
||||
source.control.addEventListener('change', () => { followSource(); wanted.set('folder', folder.control.value); });
|
||||
}
|
||||
const read = () => Object.fromEntries(controls.map(({ key, control }) =>
|
||||
[key, key === 'tags' ? control.value.trim() : control.value]));
|
||||
@@ -210,6 +218,15 @@ export function openRefineDialog({ scope, fields, values = {}, onApply, onClear,
|
||||
...controls.map(({ key, control }) => field(FIELD_LABELS[key], control, `filter-${key}`)),
|
||||
actions,
|
||||
], { scope, trigger });
|
||||
const opened = session;
|
||||
function setOptions(key, options) {
|
||||
const item = controls.find(candidate => candidate.key === key);
|
||||
if (session !== opened || !item || key === 'tags') return;
|
||||
fillSelect(item.control, key, options);
|
||||
choose(item.control, wanted.get(key));
|
||||
if (source && folder && (item === source || item === folder)) followSource(wanted.get('folder'));
|
||||
}
|
||||
return { setOptions };
|
||||
}
|
||||
|
||||
/** One operation row; a press on its busy (aria-disabled) action is ignored and keeps the dialog open. */
|
||||
|
||||
Reference in New Issue
Block a user