Files
ComfyAssets-ComfyUI_PromptM…/tests/comfyui/execution-text-link.spec.mjs
T
Vito d9f168c317 feat(nodes): per-node Save to library switch (#90) (#427)
* feat(nodes): per-node Save to library switch (#90)

Every PromptManager node (Classic, Text, Focused, Split, Pair) gets an
optional save_to_library BOOLEAN, default on and declared last. Off, the
node still returns its normal outputs but PromptManager ignores the run:

- the node saves, counts, tracks and records nothing;
- the queue hook reads the switch from the queued graph (literal, or a
  linked PrimitiveBoolean), so a cached node is not counted either; a
  switch wired from an unreadable node is left to the node itself;
- an opted-out node is no run prompt, so no tracker entry, run key,
  embedded-metadata match or history attribution links its images, and
  a run with any opted-out positive node links none of its files;
- the scan reports such files as not_saved (never stored or queued for
  Needs review), and a file whose only opted-out node is its negative is
  stored positive-only, without the pair;
- execution history records no prompt text for an opted-out role.

PromptSearchList only reads the library and gets no switch.

The switch takes the widget index older workflows used for the editor's
own DOM slot (''), so a configure step turns any non-boolean value on:
workflows saved before the switch load with it on and no earlier widget
value moves. The Focused, Split and Pair editors draw it as a "Save to
library" checkbox in their toolbar; Classic and Text show the native
toggle.

* fix(nodes): Save to library review fixes (#90)

- A switch wired from a node the graph cannot read and off at run time
  marks the node's run key, so an earlier run's tracker entry never
  links this run's images.
- The scan reports such files as not_saved_wired ("only the run itself
  can link it") instead of not_saved.
- Workflow-only metadata ignores muted and bypassed PromptManager nodes
  and reads nodes inside subgraph definitions.
- The Focused, Split and Pair footer says when the switch comes from a
  link.

* fix(usage): count cached re-runs of runtime-linked prompt text (#347)

A PromptManager-family node whose text comes from a runtime link
(PreviewAny, an LLM, a list node) counted only the job that executed it;
later jobs served from ComfyUI's output cache counted nothing.

Each execution now records a receipt (digests of its STRING outputs and
hashes of the text it stores) per run key and queue item. On
execution_cached the receipt whose digests equal the cache entry's text is
replayed once for the job, with the node's role and what the queue hook
already saw recomputed from the job's own graph. The executing node and the
replay count through one shared own_counts. Unmatched cache entries count
nothing, as before; a node that executes after its replay in the same job
skips its own count. No schema change, no backfill.

* fix(usage): never replay a node twice in one job; dedupe receipts once complete (#347)

A repeated execution_cached id or message for the same job now counts the
node once. Receipts replace equal older ones only when their job finishes
(or the next job starts), so a list still recording [A] of [A, B] no longer
drops an older [A] receipt. Node-level tests check the receipt each
execution records.

* test(coverage): baseline for the Save to library module (#90)

* test(usage): disabled node then cached jobs counts 0 (#90, #347)

A PromptManager node with Save to library off records no cached-run
receipt and is no run prompt in the replayed job's graph, so ComfyUI
serving it from its cache on later jobs counts nothing. The prompt is
saved first by an enabled run, so a replayed count would show. Covers
PromptManager, Focused, Split, Text and the Pair.

* fix(nodes): hide the editor's native widgets through one helper (#90)

The Save to library switch copied the text fallbacks' zero-height computeSize
arrow, and neither copy was ever called, so focused-node.js function coverage
fell to 87.5 (floor 90). One hideNativeWidget helper now hides both, and the
spec checks that the hidden switch and a text fallback take no node height.
2026-10-28 18:09:16 +00:00

400 lines
25 KiB
JavaScript

// #343: a link drives a PromptManager node's text (and the Pair's negative) on the real pinned host.
// The workflow is built by the real frontend through the nodes' sockets and queued as the frontend serializes it;
// only inference is replaced (fixtures/ledger_nodes.py). The linked text must be saved, counted, recorded in the
// execution history, and the run's image must link to it.
import { execFile } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { promisify } from 'node:util';
import { expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import { test, readExecutionRuns } from './execution-fixture.mjs';
import { installHostHooks } from './host-fixture.mjs';
// The worker's owned host must be up before the shared host hooks open the page.
test.beforeEach(async ({ execution }) => { expect(execution.databasePath).toBeTruthy(); });
installHostHooks(test, ['PromptManager', 'PromptManagerText', 'PromptManagerFocused', 'PromptManagerPair', 'PMHistoryInputs', 'PMCycledText']);
// `primitive`: a core string node the queue-time resolver follows. `legacy`: the frontend's virtual Primitive node,
// whose value is queued as literal text. `preview`: PrimitiveString -> PreviewAny, the shape of the maintainer's
// TextGenerate -> PreviewAny chain, whose text is only known when the node runs.
function build(page, { kind, positive, negative, prefix, saveToLibrary }) {
return page.evaluate(async ({ kind, positive, negative, prefix, saveToLibrary }) => {
const { app } = await import('/scripts/app.js');
app.graph.clear();
const add = (type, x, y) => { const node = LiteGraph.createNode(type); app.graph.add(node); node.pos = [x, y]; return node; };
const slot = (node, name) => { const index = node.inputs.findIndex(input => input.name === name); if (index < 0) throw new Error(`${node.type} has no ${name} socket`); return index; };
const connect = (from, output, to, name) => { if (!from.connect(output, to, slot(to, name))) throw new Error(`${from.type} could not link to ${to.type}.${name}`); };
const inputs = add('PMHistoryInputs', 0, 0);
const prompt = add(kind, 700, 0);
const set = (name, value) => { const widget = prompt.widgets.find(item => item.name === name); widget.value = value; if (widget.inputEl) widget.inputEl.value = value; };
set('text', positive.typed);
if (negative) set('negative_text', negative.typed);
if (saveToLibrary !== undefined) prompt.widgets.find(item => item.name === 'save_to_library').value = saveToLibrary;
const drive = (name, side, y) => {
if (!side.via) return;
if (side.via === 'legacy') {
// The frontend's own Primitive: a virtual node whose value is applied when the workflow is queued.
const primitive = add('PrimitiveNode', 0, y);
connect(primitive, 0, prompt, name);
primitive.widgets[0].value = side.text;
return;
}
if (side.via === 'cycled') { connect(add('PMCycledText', 0, y), 0, prompt, name); return; }
const text = add('PrimitiveString', 0, y);
text.widgets.find(widget => widget.name === 'value').value = side.text;
let source = text;
if (side.via === 'preview') { source = add('PreviewAny', 350, y); connect(text, 0, source, 'source'); }
connect(source, 0, prompt, name);
};
drive('text', positive, 300);
if (negative) drive('negative_text', negative, 500);
if (kind !== 'PromptManagerText') connect(inputs, 0, prompt, 'clip');
const sampler = add('KSampler', 1400, 0);
connect(inputs, 1, sampler, 'model');
connect(inputs, 2, sampler, 'latent_image');
if (kind === 'PromptManagerText') {
const encode = add('CLIPTextEncode', 1100, 0);
encode.convertWidgetToInput?.(encode.widgets.find(widget => widget.name === 'text'));
connect(inputs, 0, encode, 'clip');
connect(prompt, 0, encode, 'text');
connect(encode, 0, sampler, 'positive');
} else connect(prompt, 0, sampler, 'positive');
if (kind === 'PromptManagerPair') connect(prompt, 1, sampler, 'negative');
else {
const empty = add('CLIPTextEncode', 1100, 300);
connect(inputs, 0, empty, 'clip');
connect(empty, 0, sampler, 'negative');
}
const images = add('PMHistoryImages', 1800, 0);
images.widgets.find(widget => widget.name === 'value').value = 99;
connect(sampler, 0, images, 'latent');
const save = add('SaveImage', 2100, 0);
save.widgets.find(widget => widget.name === 'filename_prefix').value = prefix;
connect(images, 0, save, 'images');
// The Classic nodes install their own text serializer shortly after creation; queue after it, as a user does.
await prompt._pmTextSerializerReady;
const { output, workflow } = await app.graphToPrompt();
return { output, workflow, promptNode: String(prompt.id) };
}, { kind, positive, negative, prefix, saveToLibrary });
}
async function run(execution, page, testCase, built) {
const prefix = `textlink/${randomUUID()}`;
const { output, workflow, promptNode } = built ?? await build(page, { ...testCase, prefix });
const id = randomUUID();
const response = await execution.request.post('/prompt', { data: { prompt: output, prompt_id: id, extra_data: { extra_pnginfo: { workflow } } } });
expect(response.ok(), await response.text()).toBe(true);
let item;
await expect.poll(async () => {
item = (await (await execution.request.get(`/history/${id}`)).json())[id];
return item?.status?.status_str ?? null;
}, { timeout: 20000 }).toBe('success');
let result;
await expect.poll(async () => {
result = await readExecutionRuns(execution.databasePath, id);
const run = result.rows[0];
return run ? result.captures[run.run_id].state : null;
}, { timeout: 10000, message: 'the run and its prompt context reach SQLite' }).toBe('complete');
const run = result.rows[0];
const files = Object.values(item.outputs).flatMap(value => value.images || []).map(image => image.filename);
return { output, promptNode, context: result.captures[run.run_id].contexts[0], usage: result.usage[run.run_id], files };
}
async function scan(execution) {
const response = await execution.request.post('/prompt_manager/scan', { timeout: 60000 });
expect(response.ok()).toBe(true);
const events = (await response.text()).split('\n').filter(line => line.startsWith('data: ')).map(line => JSON.parse(line.slice(6)));
expect(events.at(-1)?.type, JSON.stringify(events.at(-1))).toBe('complete');
}
// filename -> linked prompt id (null when the image is not linked), read-only from the owned host's library.
const LINKS = `import json, sqlite3, sys, pathlib
db = sqlite3.connect(pathlib.Path(sys.argv[1]).resolve().as_uri() + "?mode=ro", uri=True)
links = {name: None for name in sys.argv[2:]}
for name, prompt in db.execute("SELECT filename, prompt_id FROM generated_images WHERE filename IN (%s)" % ",".join("?" * (len(sys.argv) - 2)), sys.argv[2:]):
links[name] = prompt
print(json.dumps(links))`;
async function linkedPrompts(execution, files) {
const { stdout } = await promisify(execFile)(process.env.PM_TEST_PYTHON || 'python', ['-c', LINKS, execution.databasePath, ...files], { timeout: 5000 });
return JSON.parse(stdout);
}
// Text a link resolves from the queued graph (a primitive) links its images through their embedded metadata.
// Text only known at run time (PreviewAny) cannot be read from the image: its attribution from the recorded run is
// #342's. Here it must never be linked to another prompt.
async function expectImagesLinked(execution, evidence, viaRuntime) {
await scan(execution);
const links = await linkedPrompts(execution, evidence.files);
expect(evidence.files.length).toBeGreaterThan(0);
for (const [file, prompt] of Object.entries(links)) {
if (viaRuntime) expect([null, evidence.context.positive_prompt_id], file).toContain(prompt);
else expect(prompt, file).toBe(evidence.context.positive_prompt_id);
}
}
const unique = label => `${label} ${randomUUID()}`;
const sources = { primitive: 'PrimitiveString', legacy: 'a legacy Primitive', preview: 'PreviewAny' };
for (const kind of ['PromptManager', 'PromptManagerText', 'PromptManagerFocused', 'PromptManagerSplit']) {
for (const via of Object.keys(sources)) {
test(`${kind} saves, counts, records and links images to text linked from ${sources[via]}`, async ({ execution, page }) => {
test.setTimeout(90000);
const text = unique(`Linked ${kind} ${via}`);
const evidence = await run(execution, page, { kind, positive: { typed: 'Typed under the link', text, via } });
const source = evidence.output[evidence.promptNode].inputs.text;
// A real node is queued as a link; the virtual Primitive as its own value, never the hidden typed text.
if (via === 'legacy') expect(source).toBe(text);
else expect(Array.isArray(source), 'the frontend queued the link, not the typed text').toBe(true);
expect(evidence.context.positive.raw).toBe(text);
expect(evidence.context.positive.effective).toBe(text);
expect(evidence.usage).toEqual([{ role: 'positive', run_count: 1 }]);
const saved = await (await execution.request.get(`/prompt_manager/library/${evidence.context.positive_prompt_id}`)).json();
expect(saved.positive.text).toBe(text);
await expectImagesLinked(execution, evidence, via === 'preview');
});
}
}
// A legacy Primitive's wildcard is drawn once when the workflow is queued: the run saves and counts what was queued.
for (const kind of ['PromptManager', 'PromptManagerFocused']) {
test(`${kind} saves and counts the text a wildcard legacy Primitive queued (#343 re-review R1)`, async ({ execution, page }) => {
test.setTimeout(90000);
const stem = unique(`Wildcard ${kind}`);
const evidence = await run(execution, page, { kind, positive: { typed: 'Typed under the link', text: `${stem} {alpha|beta}`, via: 'legacy' } });
const queuedText = evidence.output[evidence.promptNode].inputs.text;
// The Classic node keeps its widget binding: the host applies the Primitive, and its own text serializer
// queues the text as written, as on main. The Focused editor queues one choice.
if (kind === 'PromptManager') expect(queuedText).toBe(`${stem} {alpha|beta}`);
else expect([`${stem} alpha`, `${stem} beta`]).toContain(queuedText);
expect(evidence.context.positive.raw).toBe(queuedText);
expect(evidence.usage).toEqual([{ role: 'positive', run_count: 1 }]);
const saved = await (await execution.request.get(`/prompt_manager/library/${evidence.context.positive_prompt_id}`)).json();
expect(saved.positive.text).toBe(queuedText);
});
}
for (const [label, linked, negativeVia] of [['positive', ['text'], 'primitive'], ['negative', ['negative_text'], 'primitive'],
['both', ['text', 'negative_text'], 'primitive'], ['neither', [], 'primitive'], ['negative from a legacy Primitive', ['negative_text'], 'legacy']]) {
test(`PromptManagerPair with ${label} linked saves, counts and records the pair and links its images`, async ({ execution, page }) => {
test.setTimeout(90000);
const sides = {
text: { typed: unique('Typed positive'), text: unique('Linked positive'), via: linked.includes('text') ? 'preview' : null },
negative_text: { typed: unique('Typed negative'), text: unique('Linked negative'), via: linked.includes('negative_text') ? negativeVia : null },
};
const evidence = await run(execution, page, { kind: 'PromptManagerPair', positive: sides.text, negative: sides.negative_text });
const expected = name => sides[name].via ? sides[name].text : sides[name].typed;
for (const name of ['text', 'negative_text']) {
const queuedValue = evidence.output[evidence.promptNode].inputs[name];
if (sides[name].via === 'legacy') expect(queuedValue, name).toBe(sides[name].text);
else expect(Array.isArray(queuedValue), name).toBe(linked.includes(name));
}
expect(evidence.context.positive.raw).toBe(expected('text'));
expect(evidence.context.negative.raw).toBe(expected('negative_text'));
expect(evidence.usage).toEqual([{ role: 'positive', run_count: 1 }, { role: 'negative', run_count: 0 }, { role: 'pair', run_count: 1 }]);
const pair = await (await execution.request.get(`/prompt_manager/pairs/${evidence.context.pair_id}`)).json();
expect(pair.pair).toMatchObject({ positive_text: expected('text'), negative_text: expected('negative_text') });
await expectImagesLinked(execution, evidence, sides.text.via === 'preview');
});
}
// #90: with Save to library off the run still produces its image, but PromptManager saves, counts and links nothing,
// and the run's history records no prompt text for it.
for (const kind of ['PromptManager', 'PromptManagerText', 'PromptManagerFocused', 'PromptManagerSplit', 'PromptManagerPair']) {
test(`${kind} with Save to library off saves, counts and links nothing (#90)`, async ({ execution, page }) => {
test.setTimeout(90000);
const text = unique(`Not saved ${kind}`);
const negative = kind === 'PromptManagerPair' ? { typed: unique('Not saved negative') } : undefined;
const prefix = `notsaved/${randomUUID()}`;
const { output, workflow, promptNode } = await build(page, { kind, positive: { typed: text }, negative, prefix, saveToLibrary: false });
expect(output[promptNode].inputs.save_to_library).toBe(false);
const id = randomUUID();
const response = await execution.request.post('/prompt', { data: { prompt: output, prompt_id: id, extra_data: { extra_pnginfo: { workflow } } } });
expect(response.ok(), await response.text()).toBe(true);
let item;
await expect.poll(async () => {
item = (await (await execution.request.get(`/history/${id}`)).json())[id];
return item?.status?.status_str ?? null;
}, { timeout: 20000 }).toBe('success');
let result;
await expect.poll(async () => {
result = await readExecutionRuns(execution.databasePath, id);
const run = result.rows[0];
return Boolean(run && result.captures[run.run_id]);
}, { timeout: 10000, message: 'the run reaches SQLite' }).toBe(true);
const run = result.rows[0];
for (const context of result.captures[run.run_id].contexts || []) {
expect(context.positive_prompt_id ?? null).toBeNull();
expect(context.pair_id ?? null).toBeNull();
}
expect((result.usage[run.run_id] || []).every(entry => entry.run_count === 0)).toBe(true);
const files = Object.values(item.outputs).flatMap(value => value.images || []).map(image => image.filename);
expect(files.length).toBeGreaterThan(0);
await scan(execution);
expect(Object.values(await linkedPrompts(execution, files))).toEqual(files.map(() => null));
for (const query of [text, negative?.typed].filter(Boolean)) {
const found = await (await execution.request.get(`/prompt_manager/search?text=${encodeURIComponent(query)}`)).json();
expect(found.results ?? [], query).toEqual([]);
}
});
}
// #347: a cached PromptManager node counts every job, as an executing one does. Jobs after the first change only
// the sampler seed, so ComfyUI serves the PromptManager node (and its PreviewAny source) from its output cache.
const COUNTS = `import json, sqlite3, sys, pathlib
db = sqlite3.connect(pathlib.Path(sys.argv[1]).resolve().as_uri() + "?mode=ro", uri=True)
one = lambda sql, key: (db.execute(sql, (int(key),)).fetchone() or [None])[0] if key != "-" else None
print(json.dumps({"positive": one("SELECT run_count FROM prompts WHERE id=?", sys.argv[2]),
"negative": one("SELECT run_count FROM prompts WHERE id=?", sys.argv[3]),
"pair": one("SELECT run_count FROM prompt_pairs WHERE id=?", sys.argv[4])}))`;
async function runCounts(execution, context) {
const key = value => value == null ? '-' : String(value);
const { stdout } = await promisify(execFile)(process.env.PM_TEST_PYTHON || 'python',
['-c', COUNTS, execution.databasePath, key(context.positive_prompt_id), key(context.negative_prompt_id), key(context.pair_id)], { timeout: 5000 });
return JSON.parse(stdout);
}
async function setCache(execution, mode) {
expect((await execution.request.post('/pm_execution_fixture/cache', { data: { mode } })).ok()).toBe(true);
}
// Queue an already built workflow again with another sampler seed; returns the job's history item and cached nodes.
async function queueAgain(execution, first, { seed, edit, targets, status = 'success' } = {}) {
const output = structuredClone(first.output);
const sampler = Object.values(output).find(node => node.class_type === 'KSampler');
sampler.inputs.seed = seed;
edit?.(output);
const id = randomUUID();
const data = { prompt: output, prompt_id: id, extra_data: { extra_pnginfo: { workflow: first.workflow } } };
if (targets) data.partial_execution_targets = targets;
const response = await execution.request.post('/prompt', { data });
expect(response.ok(), await response.text()).toBe(true);
let item;
await expect.poll(async () => {
item = (await (await execution.request.get(`/history/${id}`)).json())[id];
return item?.status?.status_str ?? null;
}, { timeout: 20000 }).toBe(status);
const cached = item.status.messages.find(([event]) => event === 'execution_cached')?.[1]?.nodes ?? [];
return { id, item, cached };
}
// The recorded context of one job, once its capture reached SQLite.
async function capturedContext(execution, id) {
let result;
await expect.poll(async () => {
result = await readExecutionRuns(execution.databasePath, id);
const row = result.rows[0];
return row ? result.captures[row.run_id].state : null;
}, { timeout: 10000 }).toMatch(/^(complete|partial)$/);
return result.captures[result.rows[0].run_id].contexts[0];
}
async function firstRun(execution, page, testCase) {
const prefix = `textlink/${randomUUID()}`;
const built = await build(page, { ...testCase, prefix });
const evidence = await run(execution, page, testCase, built);
return { ...evidence, workflow: built.workflow };
}
for (const kind of ['PromptManager', 'PromptManagerFocused', 'PromptManagerSplit', 'PromptManagerText', 'PromptManagerPair']) {
test(`${kind} counts each cached re-run of text linked from PreviewAny (#347)`, async ({ execution, page }) => {
test.setTimeout(90000);
await setCache(execution, 'RAM_PRESSURE');
const pair = kind === 'PromptManagerPair';
const testCase = { kind, positive: { typed: 'Typed under the link', text: unique(`Cached ${kind}`), via: 'preview' },
negative: pair ? { typed: unique('Typed negative') } : undefined };
const first = await firstRun(execution, page, testCase);
for (const seed of [2, 3]) {
const again = await queueAgain(execution, first, { seed });
expect(again.cached, 'ComfyUI served the PromptManager node from its cache').toContain(first.promptNode);
}
const counts = await runCounts(execution, first.context);
expect(counts.positive).toBe(3);
if (pair) expect(counts).toMatchObject({ negative: 0, pair: 3 });
});
}
test.describe('cached runs in one renderer (#347)', () => {
// Cache behavior does not depend on the renderer: one project runs these.
test.beforeEach(({}, testInfo) => { test.skip(testInfo.project.name !== 'classic-start', 'covered in classic-start'); });
const linked = () => ({ kind: 'PromptManager', positive: { typed: 'Typed under the link', text: unique('Cached'), via: 'preview' } });
for (const mode of ['CLASSIC', 'LRU', 'RAM_PRESSURE', 'NONE']) {
test(`counts 3 jobs under the ${mode} cache`, async ({ execution, page }) => {
test.setTimeout(90000);
await setCache(execution, mode);
try {
const first = await firstRun(execution, page, linked());
for (const seed of [2, 3]) {
const again = await queueAgain(execution, first, { seed });
if (mode === 'NONE') expect(again.cached).not.toContain(first.promptNode);
else expect(again.cached).toContain(first.promptNode);
}
expect((await runCounts(execution, first.context)).positive).toBe(3);
} finally { await setCache(execution, 'RAM_PRESSURE'); }
});
}
test('alternating upstream values A, B, A under LRU each count their own prompt', async ({ execution, page }) => {
test.setTimeout(90000);
await setCache(execution, 'LRU');
try {
const [a, b] = [unique('Cycled A'), unique('Cycled B')];
const cycle = async value => expect((await execution.request.post('/pm_execution_fixture/cycled', { data: { value } })).ok()).toBe(true);
await cycle(a);
const first = await firstRun(execution, page, { kind: 'PromptManager', positive: { typed: 'Typed under the link', text: a, via: 'cycled' } });
expect(first.context.positive.raw).toBe(a);
await cycle(b);
const second = await queueAgain(execution, first, { seed: 2 });
expect(second.cached).not.toContain(first.promptNode);
await cycle(a);
const third = await queueAgain(execution, first, { seed: 3 });
expect(third.cached, 'LRU kept A').toContain(first.promptNode);
expect((await runCounts(execution, first.context)).positive).toBe(2);
const bContext = await capturedContext(execution, second.id);
expect(bContext.positive.raw).toBe(b);
expect((await runCounts(execution, bContext)).positive).toBe(1);
} finally { await setCache(execution, 'RAM_PRESSURE'); }
});
test('typed text served from the cache still counts once per job', async ({ execution, page }) => {
test.setTimeout(90000);
await setCache(execution, 'RAM_PRESSURE');
const first = await firstRun(execution, page, { kind: 'PromptManager', positive: { typed: unique('Typed cached') } });
for (const seed of [2, 3]) expect((await queueAgain(execution, first, { seed })).cached).toContain(first.promptNode);
expect((await runCounts(execution, first.context)).positive).toBe(3);
});
test('a job failing downstream of a cached node counts it once; a job that does not need it, never', async ({ execution, page }) => {
test.setTimeout(90000);
await setCache(execution, 'RAM_PRESSURE');
const first = await firstRun(execution, page, linked());
const failing = await queueAgain(execution, first, { seed: 2, status: 'error', edit: output => {
output.pm_failure = { class_type: 'PMHistoryOutput', inputs: { value: [Object.keys(output).find(id => output[id].class_type === 'KSampler'), 0], mode: 'failure' } };
} });
expect(failing.cached).toContain(first.promptNode);
expect((await runCounts(execution, first.context)).positive).toBe(2);
// "Run selected output": only an unrelated output node; ComfyUI still reports the cached PromptManager node
const unrelated = await queueAgain(execution, first, { seed: 2, targets: ['pm_unrelated'], edit: output => {
output.pm_unrelated = { class_type: 'PMHistoryCached', inputs: { value: unique('unrelated') } };
} });
expect(unrelated.cached).toContain(first.promptNode);
expect((await runCounts(execution, first.context)).positive).toBe(2);
});
test('a cached node rewired to the negative input does not count the positive prompt', async ({ execution, page }) => {
test.setTimeout(90000);
await setCache(execution, 'RAM_PRESSURE');
const first = await firstRun(execution, page, linked());
const rewired = await queueAgain(execution, first, { seed: 2, edit: output => {
const sampler = Object.values(output).find(node => node.class_type === 'KSampler');
[sampler.inputs.positive, sampler.inputs.negative] = [sampler.inputs.negative, sampler.inputs.positive];
} });
expect(rewired.cached).toContain(first.promptNode);
expect((await runCounts(execution, first.context)).positive).toBe(1);
// The history capture of a cached job is unchanged: effective text from the cache, no stored-prompt hint
const cachedContext = await capturedContext(execution, (await queueAgain(execution, first, { seed: 3 })).id);
expect(cachedContext.positive.effective).toBe(first.context.positive.effective);
expect(cachedContext.positive_prompt_id).toBeNull();
expect((await runCounts(execution, first.context)).positive).toBe(2);
});
});