fix(inspect): abortable, stepped local reads with a busy state
The wait for the node table now ends with the selection's signal. A local read resolves and projects in bounded steps with a turn of the event loop between them, and the inspector carries aria-busy while it loads. A budget test covers maximal 2 MiB graphs and checks the stepped read is byte-identical to one synchronous build. The D117 wrap rule now targets only the summary button, and TESTING.md scopes the restart line to PRs L1 and L2.
This commit is contained in:
@@ -240,8 +240,9 @@ test('viewer details hold provenance, raw records and downloads',async({page,req
|
||||
// The panel content without the cover, D117's Copy settings summary after the facts, then the two disclosures,
|
||||
// then the pair send and history buttons (D20).
|
||||
expect(await viewerStructure(details)).toEqual({outer:['div','button.send','button'],
|
||||
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid','button','details','details']});
|
||||
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid','button.settings-summary','details','details']});
|
||||
await expect(details.locator(':scope > div > button')).toHaveText('Copy settings summary');
|
||||
await expect(details.locator(':scope > div > button.settings-summary')).toHaveCSS('white-space','normal');
|
||||
await expect(details.locator('.detail-cover,.detail-inner,.detail-footer')).toHaveCount(0);
|
||||
await expect(details.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);await expect(details.locator('.panel-heading .chip')).toHaveText('PNG');
|
||||
await expect(details.locator(':scope > div > p.small')).toHaveText('complete.png');
|
||||
|
||||
@@ -119,7 +119,7 @@ async function plainViewerChips(page) {
|
||||
});
|
||||
const summary=document.querySelector('#viewer-details [data-action="download-workflow"]').cloneNode(false);
|
||||
for(const key of Object.keys(summary.dataset))delete summary.dataset[key];
|
||||
summary.textContent='Copy settings summary';
|
||||
summary.textContent='Copy settings summary';summary.classList.add('settings-summary');
|
||||
document.querySelector('#viewer-details > div > dl.meta-grid').after(summary);window.__summaryButton=summary;
|
||||
});
|
||||
await settle(page);
|
||||
|
||||
@@ -303,6 +303,8 @@ test('Copy settings summary follows the facts in the full inspector only', async
|
||||
const inner = full.button.parent, index = inner.children.indexOf(full.button);
|
||||
assert.equal(inner.children[index - 1].tagName, 'dl', 'it follows the six facts');
|
||||
assert.equal(inner.children[index + 1].tagName, 'details', 'File & provenance follows it');
|
||||
// M7: the D117 wrap rule targets this button by its class, never any button of a details block.
|
||||
assert.equal(full.button.className, 'settings-summary');
|
||||
} finally { full.done(); }
|
||||
const panel = renderSummary(record(), {variant:'panel'});
|
||||
try { assert.equal(panel.button, null, 'the Images side panel keeps the concept footer'); } finally { panel.done(); }
|
||||
@@ -333,3 +335,31 @@ test('Copy settings summary without a clipboard opens the select-to-copy fallbac
|
||||
} finally { view.done(); }
|
||||
}
|
||||
});
|
||||
|
||||
test('the inspector is busy while a read is loading, then not', async () => {
|
||||
const f = mountedDom(), scope = createScope();
|
||||
const view = mountFileInspector(f.container, {sender:{open() {}}, parentScope:scope, variant:'full'});
|
||||
try {
|
||||
view.setState({loading:true, error:false, record:null});
|
||||
assert.equal(f.container.getAttribute('aria-busy'), 'true');
|
||||
assert.ok(f.walk(f.container, n => n.textContent === 'Loading file details…').length);
|
||||
view.setState({loading:false, error:false, record:record()});
|
||||
assert.equal(f.container.getAttribute('aria-busy'), null);
|
||||
view.setState({loading:true, error:false, record:null});
|
||||
view.setState({loading:false, error:true, record:null});
|
||||
assert.equal(f.container.getAttribute('aria-busy'), null);
|
||||
view.setState({loading:true, error:false, record:null});
|
||||
} finally {
|
||||
view.dispose();
|
||||
assert.equal(f.container.getAttribute('aria-busy'), null, 'dispose clears the busy state');
|
||||
scope.dispose(); f.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('the D117 wrap rule targets only the settings summary button', async () => {
|
||||
const fs = await import('node:fs');
|
||||
const css = fs.readFileSync(new URL('../../web/js/workspace/workspace.css', import.meta.url), 'utf8');
|
||||
const rules = css.split('\n').filter(line => line.includes('D117'));
|
||||
assert.deepEqual(rules.map(line => line.trim()), ['.ca-tools #viewer-details .settings-summary { white-space: normal; /* D117 */ }']);
|
||||
assert.doesNotMatch(css, /#viewer-details > div > button/);
|
||||
});
|
||||
|
||||
@@ -23,8 +23,11 @@ globalThis.ComfyMetadata = ComfyMetadata;
|
||||
// The page's API client and the node table it serves (local-semantics.mjs fetches it once).
|
||||
const SEMANTICS = fs.readFileSync(path.join(here, "..", "..", "web", "js", "inspection-semantics.json"), "utf8");
|
||||
globalThis.window = { ApiClient: { resolveUrl: (value) => value } };
|
||||
// A test can hold the table request (`tableGate`) to stand for a slow or stalled proxy.
|
||||
let tableGate = null;
|
||||
globalThis.fetch = async (url) => {
|
||||
if (url !== "/prompt_manager/js/inspection-semantics.json") throw new Error(`unexpected request ${url}`);
|
||||
if (tableGate) await tableGate;
|
||||
return new Response(SEMANTICS, { status: 200 });
|
||||
};
|
||||
const m = await import("../../web/js/workspace/local-inspection.mjs").catch((error) => {
|
||||
@@ -66,6 +69,37 @@ test("contract", () => {
|
||||
assert.ok(Object.isFrozen(m.localFileAdapter));
|
||||
});
|
||||
|
||||
// M3: the first read waits for the page's one table request; an aborted, replaced or cleared selection must not.
|
||||
test("a read waiting for the node table ends with its selection", async () => {
|
||||
let release;
|
||||
tableGate = new Promise((resolve) => { release = resolve; });
|
||||
try {
|
||||
const file = pngFile("slow.png", [tEXt("prompt", PROMPT)]);
|
||||
const aborted = new AbortController();
|
||||
const reading = m.localFileAdapter.read(await handleFor(file), { signal: aborted.signal });
|
||||
const settled = (promise) => Promise.race([promise.then(() => "resolved", (error) => error.name), new Promise((resolve) => setTimeout(() => resolve("pending"), 200))]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
aborted.abort();
|
||||
assert.equal(await settled(reading), "AbortError");
|
||||
// Through the controller: clearing the selection ends its read; nothing is published for it.
|
||||
const controller = createInspectionController({ api: null, adapter: m.localFileAdapter, extractFacts: extractGenerationFacts });
|
||||
const states = [];
|
||||
controller.subscribe((state) => states.push(state));
|
||||
const selected = controller.select(await handleFor(file));
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
controller.clear();
|
||||
assert.equal(await settled(selected), "resolved");
|
||||
assert.ok(states.every((state) => state.record === null && state.error === false), "no record or error for a cleared selection");
|
||||
controller.dispose();
|
||||
} finally {
|
||||
tableGate = null;
|
||||
release();
|
||||
}
|
||||
// The shared request still lands for the next selection.
|
||||
const { wire } = await recordFor(pngFile("next.png", [tEXt("prompt", PROMPT)]));
|
||||
assert.equal(wire.resolution.state, "resolved");
|
||||
});
|
||||
|
||||
test("a PNG File becomes the server-shaped wire with local provenance", async () => {
|
||||
const file = pngFile("Fox Render.PNG", [tEXt("prompt", PROMPT), tEXt("workflow", WORKFLOW)]);
|
||||
const { wire, record, handle } = await recordFor(file);
|
||||
@@ -345,3 +379,89 @@ test("the parity corpus PNGs read into renderable records", async () => {
|
||||
assert.ok(["parsed", "limited", "malformed"].includes(wire.metadata.prompt_graph.status));
|
||||
}
|
||||
});
|
||||
|
||||
// M4: resolution runs on the page's main thread, so a read yields between its bounded steps (the busy state paints
|
||||
// and input is handled) and no step may exceed a budget on the largest record the readers keep (2 MiB of roles).
|
||||
const ROLE_BYTES = 2 * 1024 * 1024;
|
||||
// Generous for a shared CI runner: a desktop resolves these in 130-455 ms.
|
||||
const STEP_BUDGET_MS = 2500;
|
||||
const ENCODE = (text) => ({ class_type: "CLIPTextEncode", inputs: { text, clip: ["4", 1] } });
|
||||
const KSAMPLER = (positive, negative, latent) => ({ class_type: "KSampler", inputs: { seed: 1, steps: 20, cfg: 8.0, sampler_name: "euler", scheduler: "normal",
|
||||
denoise: 1.0, model: ["4", 0], positive, negative, latent_image: latent } });
|
||||
const LOADERS = { "4": { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "m.safetensors" } }, "5": { class_type: "EmptyLatentImage", inputs: { width: 64, height: 64, batch_size: 1 } } };
|
||||
/** The largest graph of this shape whose JSON fits the role budget. */
|
||||
function maximal(build, high) {
|
||||
let low = 1;
|
||||
while (low < high) {
|
||||
const middle = (low + high + 1) >> 1;
|
||||
if (Buffer.byteLength(JSON.stringify(build(middle)), "utf8") <= ROLE_BYTES) low = middle;
|
||||
else high = middle - 1;
|
||||
}
|
||||
return JSON.stringify(build(low));
|
||||
}
|
||||
const MAXIMAL_GRAPHS = {
|
||||
// Many candidates: every sampler is a final stage with its own pair.
|
||||
independent: maximal((count) => {
|
||||
const graph = { ...LOADERS };
|
||||
for (let index = 0; index < count; index++) {
|
||||
graph[`s${index}`] = KSAMPLER([`p${index}`, 0], [`n${index}`, 0], ["5", 0]);
|
||||
graph[`p${index}`] = ENCODE(`fox ${index}`);
|
||||
graph[`n${index}`] = ENCODE(`blur ${index}`);
|
||||
}
|
||||
return graph;
|
||||
}, 6666),
|
||||
// One long stage chain through the latent: the deepest stage walk.
|
||||
chain: maximal((count) => {
|
||||
const graph = { ...LOADERS, "6": ENCODE("fox"), "7": ENCODE("blur") };
|
||||
for (let index = 0; index < count; index++) graph[`s${index}`] = KSAMPLER(["6", 0], ["7", 0], index ? [`s${index - 1}`, 0] : ["5", 0]);
|
||||
return graph;
|
||||
}, 19996),
|
||||
};
|
||||
|
||||
test("resolution of a maximal 2 MiB graph stays within the step budget", async () => {
|
||||
const { resolveLocalEvidence } = await import("../../web/js/workspace/local-resolver.mjs");
|
||||
const semantics = await (await import("../../web/js/workspace/local-semantics.mjs")).loadInspectionSemantics();
|
||||
for (const [name, prompt] of Object.entries(MAXIMAL_GRAPHS)) {
|
||||
assert.ok(Buffer.byteLength(prompt, "utf8") > ROLE_BYTES - 1024, `${name} fills the role budget`);
|
||||
const started = performance.now();
|
||||
const evidence = resolveLocalEvidence({ prompt }, semantics);
|
||||
const elapsed = performance.now() - started;
|
||||
assert.ok(elapsed < STEP_BUDGET_MS, `${name}: ${Math.round(elapsed)} ms`);
|
||||
assert.notEqual(evidence.resolution.reason_code, "malformed", name);
|
||||
}
|
||||
});
|
||||
|
||||
test("a read yields between bounded steps and answers byte for byte as one synchronous build", async () => {
|
||||
const semantics = await (await import("../../web/js/workspace/local-semantics.mjs")).loadInspectionSemantics();
|
||||
const file = pngFile("maximal.png", [tEXt("prompt", MAXIMAL_GRAPHS.independent)]);
|
||||
const handle = await handleFor(file);
|
||||
const marks = [];
|
||||
const yieldControl = async (signal) => {
|
||||
signal?.throwIfAborted();
|
||||
marks.push(performance.now());
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
};
|
||||
const started = performance.now();
|
||||
const wire = await m.localFileAdapter.read(handle, { signal: new AbortController().signal, yieldControl });
|
||||
marks.push(performance.now());
|
||||
// Resolution and the projection each run in a turn of their own after the table is in.
|
||||
assert.ok(marks.length >= 3, `yields: ${marks.length - 1}`);
|
||||
const gaps = marks.map((mark, index) => mark - (index ? marks[index - 1] : started));
|
||||
assert.ok(Math.max(...gaps) < STEP_BUDGET_MS, `longest step ${Math.round(Math.max(...gaps))} ms`);
|
||||
const { result } = await m.readLocalFile(file, { classification: handle.classification });
|
||||
const direct = await m.fitLocalPayload(m.buildLocalPayload({ read: result, file: { name: handle.filename, size: file.size, lastModified: file.lastModified },
|
||||
classification: handle.classification, semantics }));
|
||||
assert.equal(JSON.stringify(wire), JSON.stringify(direct));
|
||||
assert.equal(wire.resolution.state, "ambiguous");
|
||||
// An abort at any yield ends the read there.
|
||||
for (let stop = 0; stop < marks.length - 1; stop++) {
|
||||
const controller = new AbortController();
|
||||
let seen = 0;
|
||||
const stopping = async (signal) => {
|
||||
if (seen++ === stop) controller.abort();
|
||||
signal.throwIfAborted();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
};
|
||||
await assert.rejects(m.localFileAdapter.read(handle, { signal: controller.signal, yieldControl: stopping }), { name: "AbortError" }, `yield ${stop}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -193,8 +193,8 @@ test("unclassified input creates no object URL", async () => {
|
||||
// declares a supported type it opens the inspector's unreadable state, with no preview and no URL (ledger ruling).
|
||||
const gone = pngFile("gone.png", "fox", { gate: () => Promise.reject(new DOMException("The file was removed", "NotReadableError")) });
|
||||
assert.equal(value.select([gone]), "accepted");
|
||||
await settle();
|
||||
const unreadable = value.controller.state;
|
||||
// The read yields between its steps (local-inspection.mjs): wait for the answer, not a fixed number of turns.
|
||||
const unreadable = await recorded(value.controller);
|
||||
assert.ok(unreadable.record, "the unreadable record is published");
|
||||
assert.deepEqual([value.state.phase, value.state.message, value.state.preview], ["ready", m.LOCAL_SESSION_MESSAGES.unreadable, null]);
|
||||
assert.equal(m.LOCAL_SESSION_MESSAGES.unreadable, "Embedded metadata could not be read.");
|
||||
|
||||
Reference in New Issue
Block a user