* feat(history): define bounded output reference contracts * feat(database): migrate execution output evidence to schema v3 * feat(history): persist immutable run output references * test(history): isolate mocked node gallery monitor * feat(history): capture bounded output descriptors with shared leases * fix(history): authenticate payload owners and release failed captures * feat(history): attach output evidence to exact finalized attempts * test(history): verify run output evidence on real ComfyUI * fix(history): release idle worker payload references * docs(history): clarify defensive lease cleanup * test(history): align output fixtures with native path styles * test(history): separate restore completion from writer drain * test(history): bound batch drains by progress and total time
264 lines
14 KiB
JavaScript
264 lines
14 KiB
JavaScript
import { randomUUID } from 'node:crypto';
|
|
import { expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
|
import { test, readExecutionRuns } from './execution-fixture.mjs';
|
|
|
|
async function resetCache({ execution }) {
|
|
expect((await execution.request.post('/pm_execution_fixture/cache', { data: { mode: 'RAM_PRESSURE' } })).ok()).toBe(true);
|
|
}
|
|
|
|
test.beforeEach(resetCache);
|
|
test.afterAll(resetCache);
|
|
|
|
function images(value, batch = 1) {
|
|
return {
|
|
'1': { class_type: 'PMHistoryImages', inputs: { value, batch } },
|
|
'2': { class_type: 'SaveImage', inputs: { images: ['1', 0], filename_prefix: 'output-evidence/nested/image' } },
|
|
'3': { class_type: 'PreviewImage', inputs: { images: ['1', 0] } },
|
|
};
|
|
}
|
|
|
|
async function submit(execution, prompt, id = randomUUID()) {
|
|
const response = await execution.request.post('/prompt', { data: { prompt, prompt_id: id } });
|
|
expect(response.ok()).toBe(true);
|
|
expect((await response.json()).prompt_id).toBe(id);
|
|
return id;
|
|
}
|
|
|
|
async function persisted(execution, id, outcomes) {
|
|
let result;
|
|
await expect.poll(async () => {
|
|
result = await readExecutionRuns(execution.databasePath, id);
|
|
return { outcomes: result.rows.map(row => row.outcome),
|
|
states: result.rows.map(row => result.outputs[row.run_id].state) };
|
|
}, { timeout: 15000, message: 'Exact attempts and final output evidence reach SQLite' }).toEqual({
|
|
outcomes, states: outcomes.map(() => 'complete'),
|
|
});
|
|
return result;
|
|
}
|
|
|
|
async function finalHistory(execution, id) {
|
|
const response = await execution.request.get(`/history/${id}`);
|
|
expect(response.ok()).toBe(true);
|
|
const item = (await response.json())[id];
|
|
expect(item.prompt[1]).toBe(id);
|
|
return item;
|
|
}
|
|
|
|
function expectHistory(capture, history) {
|
|
const reported = Object.entries(history.outputs).flatMap(([node, output]) =>
|
|
(output.images || []).map((image, ordinal) => ({ output_node_id: node, image_ordinal: ordinal,
|
|
folder_type: image.type, subfolder: image.subfolder, filename: image.filename })));
|
|
reported.sort((a, b) => a.output_node_id.localeCompare(b.output_node_id) || a.image_ordinal - b.image_ordinal);
|
|
expect(capture).toMatchObject({ state: 'complete', reason: reported.length ? 'reported' : 'none_reported',
|
|
source: 'host_final_history', format_version: 1 });
|
|
expect(capture.references.map(({ output_node_id, image_ordinal, folder_type, subfolder, filename }) =>
|
|
({ output_node_id, image_ordinal, folder_type, subfolder, filename }))).toEqual(reported);
|
|
for (const reference of capture.references) {
|
|
expect(reference).toMatchObject({ path_style: 'posix', file_identity: 'unverified',
|
|
newly_generated: 'unknown', context_membership: 'unknown', transient: reference.folder_type === 'temp' });
|
|
expect(reference.root_key).toMatch(/^[a-f0-9]{64}$/);
|
|
}
|
|
}
|
|
|
|
async function gate(execution, action) {
|
|
expect((await execution.request.post(`/pm_execution_fixture/gate/${action}`)).ok()).toBe(true);
|
|
}
|
|
|
|
async function entered(execution) {
|
|
await expect.poll(async () => (await (await execution.request.get('/pm_execution_fixture/gate')).json()).entered,
|
|
{ timeout: 10000 }).toBe(true);
|
|
}
|
|
|
|
function downstream(value, mode) {
|
|
const graph = images(value);
|
|
delete graph['3'];
|
|
graph['4'] = { class_type: 'PMHistoryImageOutput', inputs: { images: ['2', 0], mode } };
|
|
return graph;
|
|
}
|
|
|
|
test('headless stock SaveImage and PreviewImage retain A,A,B batches under a reused public UUID', async ({ execution }) => {
|
|
const id = randomUUID();
|
|
const graph = images(31, 2);
|
|
let result;
|
|
for (const [index, value] of [31, 31, 73].entries()) {
|
|
graph['1'].inputs.value = value;
|
|
await submit(execution, graph, id);
|
|
result = await persisted(execution, id, Array(index + 1).fill('succeeded'));
|
|
const item = await finalHistory(execution, id);
|
|
const row = result.rows[index];
|
|
expectHistory(result.outputs[row.run_id], item);
|
|
expect(result.outputs[row.run_id].references).toHaveLength(4);
|
|
expect(result.captures[row.run_id].contexts).toEqual([]);
|
|
expect(result.usage[row.run_id]).toEqual([]);
|
|
if (index === 1) expect(item.status.messages.find(([kind]) => kind === 'execution_cached')[1].nodes).toEqual(expect.arrayContaining(['1', '2', '3']));
|
|
}
|
|
const attempts = result.rows;
|
|
expect(new Set(attempts.map(row => row.run_id)).size).toBe(3);
|
|
expect(new Set(attempts.map(row => row.queue_item_id)).size).toBe(3);
|
|
const captures = attempts.map(row => result.outputs[row.run_id]);
|
|
expect(captures.map(capture => capture.state)).toEqual(['complete', 'complete', 'complete']);
|
|
expect(captures[1].references).toEqual(captures[0].references);
|
|
expect(captures[2].references).not.toEqual(captures[0].references);
|
|
expect(captures[0].references[0].context_membership).toBe('unknown');
|
|
expect(captures[0].references.filter(reference => reference.transient)).toHaveLength(2);
|
|
});
|
|
|
|
test('stock image evidence survives disabled PNG metadata', async ({ execution }) => {
|
|
const enabled = images(44);
|
|
delete enabled['3'];
|
|
const enabledId = await submit(execution, enabled);
|
|
const enabledResult = await persisted(execution, enabledId, ['succeeded']);
|
|
const enabledReference = enabledResult.outputs[enabledResult.rows[0].run_id].references[0];
|
|
const withMetadata = await execution.request.get('/view', { params: {
|
|
filename: enabledReference.filename, subfolder: enabledReference.subfolder, type: enabledReference.folder_type,
|
|
} });
|
|
expect(withMetadata.ok()).toBe(true);
|
|
expect((await withMetadata.body()).includes(Buffer.from('tEXtprompt'))).toBe(true);
|
|
expect((await execution.request.post('/pm_execution_fixture/metadata', { data: { disabled: true } })).ok()).toBe(true);
|
|
try {
|
|
const graph = images(45);
|
|
delete graph['3'];
|
|
const id = await submit(execution, graph);
|
|
const result = await persisted(execution, id, ['succeeded']);
|
|
const capture = result.outputs[result.rows[0].run_id];
|
|
expectHistory(capture, await finalHistory(execution, id));
|
|
expect(capture.references).toHaveLength(1);
|
|
const { filename, subfolder, folder_type: type } = capture.references[0];
|
|
const response = await execution.request.get('/view', { params: { filename, subfolder, type } });
|
|
expect(response.ok()).toBe(true);
|
|
const png = await response.body();
|
|
expect(png.subarray(1, 4).toString()).toBe('PNG');
|
|
expect(png.includes(Buffer.from('tEXtprompt'))).toBe(false);
|
|
} finally {
|
|
expect((await execution.request.post('/pm_execution_fixture/metadata', { data: { disabled: false } })).ok()).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('text-only output reports a complete empty image set', async ({ execution }) => {
|
|
const id = await submit(execution, { '1': { class_type: 'PMHistoryCached', inputs: { value: 'text has no image locator' } } });
|
|
const result = await persisted(execution, id, ['succeeded']);
|
|
const capture = result.outputs[result.rows[0].run_id];
|
|
expectHistory(capture, await finalHistory(execution, id));
|
|
expect(capture.references).toEqual([]);
|
|
expect(capture.reason).toBe('none_reported');
|
|
});
|
|
|
|
test('batched outputs preserve PM usage while context membership stays unknown', async ({ execution }) => {
|
|
const graph = images(57, 2);
|
|
graph['10'] = { class_type: 'PMHistoryInputs', inputs: {} };
|
|
graph['20'] = { class_type: 'PromptManagerPair', inputs: { clip: ['10', 0], text: `output usage ${randomUUID()}`,
|
|
negative_text: `negative ${randomUUID()}`, prepend_text: '', append_text: '' } };
|
|
graph['30'] = { class_type: 'KSampler', inputs: { model: ['10', 1], latent_image: ['10', 2],
|
|
positive: ['20', 0], negative: ['20', 1], seed: 1, steps: 1, cfg: 1, sampler_name: 'euler', scheduler: 'normal', denoise: 1 } };
|
|
graph['1'].inputs.latent = ['30', 0];
|
|
const id = await submit(execution, graph);
|
|
const first = await persisted(execution, id, ['succeeded']);
|
|
const runId = first.rows[0].run_id;
|
|
expectHistory(first.outputs[runId], await finalHistory(execution, id));
|
|
expect(first.outputs[runId].references).toHaveLength(4);
|
|
expect(first.captures[runId].contexts).toHaveLength(1);
|
|
expect(first.usage[runId]).toEqual([{ role: 'positive', run_count: 1 }, { role: 'negative', run_count: 0 }, { role: 'pair', run_count: 1 }]);
|
|
await submit(execution, graph, id);
|
|
const second = await persisted(execution, id, ['succeeded', 'succeeded']);
|
|
expect(second.outputs[second.rows[1].run_id].references).toEqual(first.outputs[runId].references);
|
|
expect(second.usage[runId]).toEqual([{ role: 'positive', run_count: 2 }, { role: 'negative', run_count: 0 }, { role: 'pair', run_count: 2 }]);
|
|
});
|
|
|
|
test('failed work after stock SaveImage preserves exactly the final reported partial-run outputs', async ({ execution }) => {
|
|
const id = await submit(execution, downstream(81, 'failure'));
|
|
const result = await persisted(execution, id, ['failed']);
|
|
const item = await finalHistory(execution, id);
|
|
expect(item.status.messages.some(([kind]) => kind === 'execution_error')).toBe(true);
|
|
expectHistory(result.outputs[result.rows[0].run_id], item);
|
|
expect(result.outputs[result.rows[0].run_id].references).toHaveLength(1);
|
|
});
|
|
|
|
test('interruption after stock SaveImage preserves exactly the final reported outputs', async ({ execution }) => {
|
|
await gate(execution, 'hold');
|
|
try {
|
|
const id = await submit(execution, downstream(93, 'gate'));
|
|
await entered(execution);
|
|
expect((await execution.request.post('/interrupt', { data: {} })).ok()).toBe(true);
|
|
const result = await persisted(execution, id, ['interrupted']);
|
|
const item = await finalHistory(execution, id);
|
|
expect(item.status.messages.some(([kind]) => kind === 'execution_interrupted')).toBe(true);
|
|
expectHistory(result.outputs[result.rows[0].run_id], item);
|
|
expect(result.outputs[result.rows[0].run_id].references).toHaveLength(1);
|
|
} finally { await gate(execution, 'release'); }
|
|
});
|
|
|
|
test('completed image before pending gated work is not finalized early', async ({ execution }) => {
|
|
await gate(execution, 'hold');
|
|
try {
|
|
const id = await submit(execution, downstream(107, 'gate'));
|
|
await entered(execution);
|
|
let pending;
|
|
await expect.poll(async () => {
|
|
pending = await readExecutionRuns(execution.databasePath, id);
|
|
return pending.rows.map(row => row.outcome);
|
|
}, { timeout: 10000 }).toEqual(['observed']);
|
|
expect(pending.outputs[pending.rows[0].run_id]).toMatchObject({ state: 'not_captured', references: [] });
|
|
expect((await (await execution.request.get(`/history/${id}`)).json())[id]).toBeUndefined();
|
|
await gate(execution, 'release');
|
|
const result = await persisted(execution, id, ['succeeded']);
|
|
expectHistory(result.outputs[result.rows[0].run_id], await finalHistory(execution, id));
|
|
expect(result.outputs[result.rows[0].run_id].references).toHaveLength(1);
|
|
} finally { await gate(execution, 'release'); }
|
|
});
|
|
|
|
test('stock output capture survives both renderer switches and workflow reload', async ({ execution, page, context }, testInfo) => {
|
|
await context.route('**/*', async route => {
|
|
const path = new URL(route.request().url()).pathname.replace(/^\/api\//, '/');
|
|
if (path === '/settings' && route.request().method() === 'GET') {
|
|
const response = await route.fetch();
|
|
return route.fulfill({ json: { ...await response.json(),
|
|
'Comfy.VueNodes.Enabled': testInfo.project.metadata.initialRenderer,
|
|
'Comfy.TutorialCompleted': true, 'Comfy.Workflow.Persist': false,
|
|
'Comfy.Notification.ShowVersionUpdates': false } });
|
|
}
|
|
return route.continue();
|
|
});
|
|
const errors = [];
|
|
page.on('pageerror', error => errors.push(error.message));
|
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
|
await expect(page.getByRole('status', { name: 'Loading ComfyUI' })).toBeHidden({ timeout: 30000 });
|
|
await page.waitForFunction(async () => {
|
|
const { app } = await import('/scripts/app.js');
|
|
return !!app.graph && !!globalThis.LiteGraph?.registered_node_types?.PMHistoryImages;
|
|
});
|
|
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(testInfo.project.metadata.initialRenderer);
|
|
await page.evaluate(async () => {
|
|
const { app } = await import('/scripts/app.js');
|
|
app.graph.clear();
|
|
const source = LiteGraph.createNode('PMHistoryImages');
|
|
const saved = LiteGraph.createNode('SaveImage');
|
|
const preview = LiteGraph.createNode('PreviewImage');
|
|
for (const node of [source, saved, preview]) app.graph.add(node);
|
|
source.widgets.find(widget => widget.name === 'value').value = 119;
|
|
source.widgets.find(widget => widget.name === 'batch').value = 1;
|
|
saved.widgets.find(widget => widget.name === 'filename_prefix').value = 'renderer-evidence/image';
|
|
source.connect(0, saved, 0);
|
|
source.connect(0, preview, 0);
|
|
});
|
|
const captures = [];
|
|
for (const renderer of [testInfo.project.metadata.initialRenderer, !testInfo.project.metadata.initialRenderer, testInfo.project.metadata.initialRenderer]) {
|
|
await page.evaluate(async renderer => {
|
|
const { app } = await import('/scripts/app.js');
|
|
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
|
|
await app.loadGraphData(app.graph.serialize());
|
|
}, renderer);
|
|
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
|
|
const graph = await page.evaluate(async () => (await (await import('/scripts/app.js')).app.graphToPrompt()).output);
|
|
expect(Object.values(graph).map(node => node.class_type).sort()).toEqual(['PMHistoryImages', 'PreviewImage', 'SaveImage']);
|
|
const id = await submit(execution, graph);
|
|
const result = await persisted(execution, id, ['succeeded']);
|
|
const capture = result.outputs[result.rows[0].run_id];
|
|
expectHistory(capture, await finalHistory(execution, id));
|
|
expect(capture.references).toHaveLength(2);
|
|
captures.push(capture);
|
|
}
|
|
expect(captures[1].references).toEqual(captures[0].references);
|
|
expect(captures[2].references).toEqual(captures[0].references);
|
|
expect(errors).toEqual([]);
|
|
});
|