test(history): exercise real cached and headless executions (#209)

This commit is contained in:
Vito Sansevero
2026-10-17 15:59:26 -07:00
parent 6abcdf5516
commit 978fdfb2cc
15 changed files with 619 additions and 41 deletions
+5
View File
@@ -97,6 +97,11 @@ jobs:
PM_COMFY_ROOT: ${{ github.workspace }}/.ci-comfyui
PM_TEST_PYTHON: python
run: npm run --prefix tools/vendor test:comfyui
- name: Real cached and headless execution history
env:
PM_COMFY_ROOT: ${{ github.workspace }}/.ci-comfyui
PM_TEST_PYTHON: python
run: npm run --prefix tools/vendor test:executions
- name: Independent and combined pack compatibility
env:
PM_COMFY_ROOT: ${{ github.workspace }}/.ci-comfyui
+60 -2
View File
@@ -10,7 +10,7 @@ reviewed. Add a regression test for each bug; prove it fails before the fix.
| Python tests | Domain logic, SQLite, API handlers, filesystem checks | ComfyUI host and ML runtimes where needed |
| JavaScript tests | Frontend modules and controller methods | DOM/network fixtures for focused edge cases |
| Viewer checks | Production Viewer.js and frontend methods in Chromium desktop/touch | Controlled images and page fixtures |
| Real ComfyUI | Pinned CPU host, actual extension, node definitions, widgets, routes and SQLite | Inference is not queued; browser startup settings and host preference writes are isolated |
| Real ComfyUI | Pinned CPU host, actual extension, node definitions, widgets, routes, SQLite and real execution lifecycle | Stock UI cases isolate host writes; the execution suite queues deterministic test-only nodes without models |
| Page workflows | Production HTML, startup, click handlers, HTTP routes, SQLite, PNG files, thumbnails | ComfyUI host services and the tagging model |
Page workflows cover save/edit/reload, duplicate-edit rejection, gallery image
@@ -106,7 +106,7 @@ and failure screenshots/traces. Open traces with Playwright's `show-trace` tool.
The real-host suite covers both Classic and Nodes 2.0 startup, switching both
ways, actual database prompt selection, execution-text serialization, workflow
reload, widget/style cleanup, node schemas and standalone-page opening. It does
not queue inference or certify every existing feature, arbitrary host versions,
not certify every existing feature, arbitrary host versions,
GPU/model changes, cross-pack installation or visual design changes. Those still
need targeted validation.
@@ -197,3 +197,61 @@ Viewer, including nested modal focus, mobile layouts and per-role sending.
`tests/comfyui/prompt-sender.spec.mjs` sends through the actual workspace UI into
real Classic and Nodes 2.0 widgets and verifies workflow serialization without
loading a workflow or queueing generation.
## Durable execution ledger
`npm run --prefix tools/vendor test:executions` adds ten real execution cases
(five each in Classic and Nodes 2.0) to the existing 78 stock host cases and six
pack compatibility cases. Run these suites serially because they share report
cleanup and owned host resources. Use the same pinned disposable `PM_COMFY_ROOT`,
`PM_TEST_PYTHON` and Playwright browser installation as `test:comfyui`:
```sh
PM_COMFY_ROOT=/path/to/disposable/ComfyUI \
PM_TEST_PYTHON=/path/to/cpu-env/bin/python \
PLAYWRIGHT_BROWSERS_PATH=/path/to/playwright-browsers \
npm run --prefix tools/vendor test:executions
```
This suite reserves `http://127.0.0.1:8196/` and never attaches to an existing
server. Each renderer worker launches a CPU/offline host with independent
configuration, database, user and output directories. The shared launcher installs
tracked snapshots of PromptManager and a tiny temporary Git-tracked fixture pack.
Only that owned test pack provides cached output, deliberate failure, an
interruptible queue gate and gate-control routes; production has no test nodes or
endpoints. Cleanup always releases the gate, stops the host and removes both
owned directories. Logs and Playwright failures appear under
`tools/vendor/test-results/executions/` and the existing CI artifact upload.
The request-only case creates no browser or WebSocket client. Other cases verify
real `execution_cached` node lists after a failed job, both renderer switches and
workflow reload, repeated public UUIDs, interruption, graph rejection, and queued
removal before dequeue. Actual host history is checked alongside finite polling
of SQLite after the background writer. The read helper uses SQLite read-only
mode, exact UUID job/session filters and a 256-row limit; absent ledger schema is
explicit, while missing/corrupt databases and other read errors fail the suite.
Sensitive sentinels in graph inputs, extra data and error messages must never
appear in any stored row.
For a meaningful historical failure, create a separate disposable detached
checkout at `fdba6fd0126a8a40cbd6f3e50ed4c5de8991efd4` and pass its path to
`launchExecutionHost({ repository })`. That base also predates the #211 config
import fix: after confirming an actual headless job succeeded, use
`readExecutionRuns` on its explicitly constructed owned path
`host.stateDirectory/custom_nodes/promptmanager/prompts.db`. It reports
`schemaAvailable: false` and empty rows, failing the expected succeeded-row
assertion. This historical inspection stays outside the ordinary suite, which
requires the configured database path. The launcher also accepts
`PM_EXECUTION_REPOSITORY`; omit it for the implementation worktree. Stage new
files first because snapshots use `git ls-files`. Never reset the implementation
checkout or point this harness at a live ComfyUI checkout.
The package-qualified config import also corrects #211: the configured database
path is honored when PromptManager runs as a ComfyUI custom node. Existing files
are not moved automatically; deployments that previously used the fallback path
should set the intended database path before restarting.
The ledger records only new dequeued jobs and observed outcomes. Existing
counters, prompts, pairs and image links are preserved, with no reconstructed old
runs. Prompt contexts, output associations and the chronological timeline remain
future work under #209. Deploying the Python changes requires a ComfyUI restart.
+2 -2
View File
@@ -9,8 +9,8 @@ code. This plan proposes implementation order and acceptance gates.
- Foundation merged in [#198](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/198).
- [#200](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/200) delivers native Images browsing, physical-file details, infinite scroll, Grid/List/Masonry, Viewer.js and video playback. The workspace opens on Images; original application entry points remain available.
- Shared role-aware sending and the physical-file prompt inspector merged in [#203](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/203) and [#206](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/206).
- [#208](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/208) adds the native Prompt Library: one positive parent, saved negative variants, full selected text and a shared filtered image gallery. It does not invent chronological runs from counters or scan dates. [Durable history storage](prompt-history-storage.md) must be designed and tested before the execution timeline.
- Execution history, tagging/review and System consolidation remain subsequent slices under #196.
- [#208](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/208) adds the native Prompt Library: one positive parent, saved negative variants, full selected text and a shared filtered image gallery. It does not invent chronological runs from counters or scan dates. [Durable history storage](prompt-history-storage.md) now records new job outcomes; prompt contexts, output associations and the execution timeline remain pending under #209.
- Prompt execution contexts and timeline, tagging/review and System consolidation remain subsequent slices under #196; #209 now records durable job-level outcomes.
## Scope and compatibility
+52 -36
View File
@@ -1,46 +1,62 @@
# Durable Prompt History
Status: future proposal tracked in [#209](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/209); the native library does not migrate execution storage.
Status: the durable job ledger is implemented as the first slice of
[#209](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/209). Prompt
contexts, output associations and the chronological timeline remain pending;
#209 stays open.
## What the current database proves
## Recorded jobs
- `prompts.run_count` and `prompt_pairs.run_count` are aggregate usage counters.
`utils/usage_tracking.py` counts known recipes when ComfyUI dequeues a job,
including cached node executions. Dequeue does not establish successful completion.
- `last_used_at` records the latest aggregate update. The legacy prompt migration
estimated usage from linked images, with a minimum of one; no flag distinguishes
those estimates from newer recorded uses (`database/models.py`).
- `generated_images` identifies linked files; distinct image IDs are image counts,
never run counts. Its default `generation_time` is database insertion time.
- `image_prompt_pairs` stores immutable association provenance. More than one
provenance row can describe the same image and recipe. Its `created_at` is the
association time, potentially an old-image scan.
- `prompt_pairs.created_at` is the saved recipe date. Execution PNG evidence in
`utils/pair_execution.py` records distinct text/composition values without a
durable job ID or timestamp. `PromptTracker` execution IDs remain process-local.
New jobs observed at the ComfyUI queue's dequeue boundary receive an immutable
run identity and a host-session/queue-item identity. The public ComfyUI job UUID
is a correlation field: submitting it twice records two attempts. Rejected graphs
and jobs removed from the queue before dequeue create no execution rows. Cached
jobs are recorded even when no PromptManager node runs, and headless jobs do not
require a browser or WebSocket client.
The current UI can show saved negative variants, linked images and aggregate uses
with an estimate caveat. It cannot honestly show chronological execution dots,
repeat-run branches or completed-run counts.
The additive `execution_runs` table stores dequeue time, observed callback time,
optional executor event times and an explicit outcome: `observed`, `succeeded`,
`failed`, `interrupted` or `unknown`. Matching stored terminal events determine
outcomes. Missing or conflicting terminal evidence remains unknown; a process
crash can leave an observed row without an invented completion time. Bounded
safe diagnostics retain node ID/type and exception type, never graph text,
arbitrary extra data, credentials, exception messages, traceback, inputs or
output payloads. A bounded background writer keeps storage failures from
changing host execution and reports capture gaps rather than inventing rows.
## Smallest useful future migration
Existing databases begin with zero execution rows. Migration and backup restore
preserve prompts, pairs, image links and aggregate counters; storage epochs keep
stale queued writes from being replayed into restored data. There is no
historical backfill from counters, scan dates, image timestamps or saved recipes.
Add a durable `run_events` table using a stable host-session and ComfyUI job ID
plus recipe identity. Create started events at dequeue, rather than node encoding,
so cached executions remain visible. Retain nullable recipe references and immutable
raw/effective positive, negative and composition snapshots. Record completion,
failure and interruption only when observed; otherwise retain unknown status.
[Testing](../TESTING.md#durable-execution-ledger) describes the real CPU host
matrix, historical failure proof, privacy checks and cleanup. Python deployment
requires restarting ComfyUI.
Add a deduplicated `run_images(run_id, image_id)` relationship. Multiple output
images can belong to one run, and one physical file must not manufacture multiple
runs through repeated metadata evidence. Preserve aggregate counters and existing
image provenance. Do not manufacture historical events from counters, file dates
or scans.
## What the existing UI proves
## Required migration and integration tests
`prompts.run_count` and `prompt_pairs.run_count` remain aggregate usage counters,
not completed-run counts. `last_used_at` is the latest aggregate update; legacy
migration may have estimated counts from linked images. `generated_images`
identifies linked files; image counts and insertion times are not run counts or
execution times. Image/pair association creation time can reflect an old-image
scan. A saved pair's creation time is its recipe date.
Verify old databases upgrade transactionally and idempotently, with rollback on
failure; unchanged counters and associations; cached repeat executions; failed or
cancelled jobs; batched outputs; duplicate callbacks; process restart; unknown
connected inputs; and metadata rescans that create no execution events. Cover real
Classic and Nodes 2.0 hosts before exposing run chronology.
The native Prompt Library shows saved negative variants, linked images and
aggregate uses. It does not expose the ledger as chronological dots or repeat-run
branches. `PromptTracker` identities and PNG recipe evidence remain separate
from durable host job identity.
## Remaining history work
Design bounded execution contexts with nullable recipe references, immutable
raw/effective positive, negative and composition snapshots and explicit
inferred/recorded/unknown provenance. Cached nodes must not inherit unrelated
recent prompt evidence. Define deletion and restore behavior before connecting
saved recipes to historical snapshots.
Add exact output references and a deduplicated run/image relationship without
claiming cached outputs were newly created. Preserve existing image provenance
and same-path replacement protection. Only after those semantics are tested can
the timeline honestly show prompt-level execution chronology, batched outputs
and repeat-run branches.
+47
View File
@@ -0,0 +1,47 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { fileURLToPath } from 'node:url';
import { access } from 'node:fs/promises';
import { join } from 'node:path';
import assert from 'node:assert/strict';
import { test as base, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import { launchExecutionHost } from './execution-host.mjs';
import runtime from '../../tools/vendor/comfyui-runtime.json' with { type: 'json' };
export { launchExecutionHost };
const run = promisify(execFile);
const reader = fileURLToPath(new URL('./read_execution_history.py', import.meta.url));
export async function readExecutionRuns(databasePath, jobId, sessionId) {
const args = [reader, databasePath, jobId];
if (sessionId !== undefined) args.push('--session-id', sessionId);
const { stdout } = await run(process.env.PM_TEST_PYTHON || 'python', args, { timeout: 5000, maxBuffer: 1024 * 1024 });
return JSON.parse(stdout);
}
export const test = base.extend({
execution: [async ({ playwright }, use, workerInfo) => {
const execution = await launchExecutionHost({
logPath: fileURLToPath(new URL(`../../tools/vendor/test-results/executions/${workerInfo.project.name}-host.log`, import.meta.url)),
});
let request;
try {
request = await playwright.request.newContext({ baseURL: 'http://127.0.0.1:8196/' });
await expect.poll(async () => {
if (execution.host.child.exitCode !== null) throw new Error('Owned execution host exited before readiness');
try { return (await request.get('/system_stats')).ok(); } catch { return false; }
}, { timeout: 90000 }).toBe(true);
const info = (await (await request.get('/system_stats')).json()).system;
expect(info.comfyui_version).toBe(runtime.serverVersion);
expect(info.pytorch_version).toBe(`${runtime.torchVersion}+cpu`);
expect(info.required_frontend_version).toBe(runtime.frontendVersion);
expect((await request.get('/prompt_manager/stats')).ok()).toBe(true);
await access(execution.databasePath);
await assert.rejects(access(join(execution.host.stateDirectory, 'custom_nodes/promptmanager/prompts.db')), { code: 'ENOENT' });
await use({ ...execution, request });
} finally {
try { if (request) await request.post('/pm_execution_fixture/gate/release', { timeout: 5000 }); }
finally { try { await request?.dispose(); } finally { await execution.dispose(); } }
}
}, { scope: 'worker', timeout: 120000 }],
});
+168
View File
@@ -0,0 +1,168 @@
import { randomUUID } from 'node:crypto';
import { expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import { test, readExecutionRuns } from './execution-fixture.mjs';
const sensitive = ['PM_SECRET_PROMPT_209', 'PM_SECRET_TOKEN_209', 'PM_SECRET_TRACE_209'];
const cached = value => ({ '1': { class_type: 'PMHistoryCached', inputs: { value } } });
const gate = value => ({ '1': { class_type: 'PMHistoryGate', inputs: { value } } });
async function submit(request, prompt, promptId = randomUUID()) {
const response = await request.post('/prompt', { data: { prompt, prompt_id: promptId,
extra_data: { token: sensitive[1], extra_pnginfo: { private: sensitive[2] } } } });
expect(response.ok()).toBe(true);
expect((await response.json()).prompt_id).toBe(promptId);
return promptId;
}
async function history(request, jobId, terminal = 'execution_success') {
let item;
await expect.poll(async () => {
const response = await request.get(`/history/${jobId}`);
expect(response.ok()).toBe(true);
item = (await response.json())[jobId];
return item?.status?.messages?.some(([kind]) => kind === terminal) || false;
}, { timeout: 15000 }).toBe(true);
expect(item.prompt[1]).toBe(jobId);
return item;
}
async function rows(execution, jobId, outcomes) {
let result;
await expect.poll(async () => {
result = await readExecutionRuns(execution.databasePath, jobId);
return { schemaAvailable: result.schemaAvailable, outcomes: result.rows.map(row => row.outcome) };
}, { timeout: 10000, message: `Persisted exact-job outcomes for ${jobId}` }).toEqual({ schemaAvailable: true, outcomes });
for (const sentinel of sensitive) expect(JSON.stringify(result.rows)).not.toContain(sentinel);
for (const row of result.rows) {
expect(row.observed_dequeue_at).toBeGreaterThan(0);
expect(row.observed_callback_at).toBeGreaterThanOrEqual(row.observed_dequeue_at);
expect(row.executor_started_at).toBeGreaterThan(0);
expect(row.terminal_event_at).toBeGreaterThanOrEqual(row.executor_started_at);
}
return result.rows;
}
async function control(request, action) {
expect((await request.post(`/pm_execution_fixture/gate/${action}`)).ok()).toBe(true);
}
async function entered(request) {
await expect.poll(async () => (await (await request.get('/pm_execution_fixture/gate')).json()).entered,
{ timeout: 10000 }).toBe(true);
}
test('headless actual queue execution persists without a browser or WebSocket client', async ({ execution }) => {
const id = await submit(execution.request, cached(`headless ${sensitive[0]}`));
const item = await history(execution.request, id);
expect(item.status.status_str).toBe('success');
expect((await rows(execution, id, ['succeeded']))[0].outcome).toBe('succeeded');
});
test('cached reruns after failure survive renderer switches both ways and workflow reload', async ({ execution, page, context }, testInfo) => {
const request = execution.request;
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?.PMHistoryCached;
});
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(testInfo.project.metadata.initialRenderer);
await page.evaluate(async value => {
const { app } = await import('/scripts/app.js');
app.graph.clear();
const node = LiteGraph.createNode('PMHistoryCached');
app.graph.add(node);
node.widgets.find(widget => widget.name === 'value').value = value;
}, `cache ${sensitive[0]}`);
const graph = await page.evaluate(async () => (await (await import('/scripts/app.js')).app.graphToPrompt()).output);
expect(Object.values(graph).map(node => node.class_type)).toEqual(['PMHistoryCached']);
const cachedNode = Object.keys(graph)[0];
await history(request, await submit(request, graph));
const failureGraph = { ...graph, '999': { class_type: 'PMHistoryFailure', inputs: { value: sensitive[2] } } };
const failed = await submit(request, failureGraph);
const failureHistory = await history(request, failed, 'execution_error');
expect(failureHistory.status.messages.find(([kind]) => kind === 'execution_error')[1].exception_message).toContain(sensitive[2]);
const failedRows = await rows(execution, failed, ['failed']);
expect(failedRows[0]).toMatchObject({ outcome: 'failed', node_id: '999', node_type: 'PMHistoryFailure', exception_type: 'ValueError' });
for (const renderer of [!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 reloaded = await page.evaluate(async () => (await (await import('/scripts/app.js')).app.graphToPrompt()).output);
const id = await submit(request, reloaded);
const item = await history(request, id);
expect(item.status.messages.find(([kind]) => kind === 'execution_cached')[1].nodes).toContain(cachedNode);
expect((await rows(execution, id, ['succeeded']))[0].outcome).toBe('succeeded');
}
expect(errors).toEqual([]);
});
test('reusing a public UUID creates two distinct persisted attempts', async ({ execution }) => {
const id = randomUUID();
await history(execution.request, await submit(execution.request, cached('first reused UUID'), id));
await rows(execution, id, ['succeeded']);
const graph = cached('second reused UUID');
await submit(execution.request, graph, id);
await expect.poll(async () => {
const response = await execution.request.get(`/history/${id}`);
return (await response.json())[id]?.prompt[2]?.['1']?.inputs?.value;
}, { timeout: 15000 }).toBe('second reused UUID');
const attempts = await rows(execution, id, ['succeeded', 'succeeded']);
expect(attempts).toHaveLength(2);
expect(new Set(attempts.map(row => row.run_id)).size).toBe(2);
expect(new Set(attempts.map(row => row.queue_item_id)).size).toBe(2);
expect(new Set(attempts.map(row => row.session_id)).size).toBe(1);
});
test('real worker interruption persists an interrupted outcome', async ({ execution }) => {
const request = execution.request;
await control(request, 'hold');
try {
const id = await submit(request, gate(`interrupt ${sensitive[0]}`));
await entered(request);
expect((await request.post('/interrupt', { data: {} })).ok()).toBe(true);
await history(request, id, 'execution_interrupted');
expect((await rows(execution, id, ['interrupted']))[0].outcome).toBe('interrupted');
} finally { await control(request, 'release'); }
});
test('rejected and removed-before-dequeue jobs create no persisted attempts', async ({ execution }) => {
const request = execution.request;
const rejected = randomUUID();
const response = await request.post('/prompt', { data: { prompt_id: rejected,
prompt: { '1': { class_type: 'PMHistoryMissing', inputs: {} } } } });
expect(response.status()).toBe(400);
await control(request, 'hold');
try {
const blocking = await submit(request, gate('hold worker until queued item is deleted'));
await entered(request);
const removed = await submit(request, cached('must never dequeue'));
const queued = (await (await request.get('/queue')).json()).queue_pending;
expect(queued.some(item => item[1] === removed)).toBe(true);
expect((await request.post('/queue', { data: { delete: [removed] } })).ok()).toBe(true);
expect((await (await request.get('/queue')).json()).queue_pending.some(item => item[1] === removed)).toBe(false);
await control(request, 'release');
await history(request, blocking);
await rows(execution, blocking, ['succeeded']);
for (const id of [rejected, removed]) {
expect((await (await request.get(`/history/${id}`)).json())[id]).toBeUndefined();
expect((await rows(execution, id, [])).length).toBe(0);
}
} finally { await control(request, 'release'); }
});
+41
View File
@@ -0,0 +1,41 @@
import { execFileSync } from 'node:child_process';
import { copyFile, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { createServer } from 'node:net';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { launchSharedHost as launchComfyHost, preparePromptManagerPlugin } from '../../tools/vendor/comfyui-host.mjs';
import runtime from '../../tools/vendor/comfyui-runtime.json' with { type: 'json' };
const repositoryRoot = fileURLToPath(new URL('../../', import.meta.url));
export async function launchExecutionHost({ checkout = process.env.PM_COMFY_ROOT,
repository = process.env.PM_EXECUTION_REPOSITORY || repositoryRoot,
python = process.env.PM_TEST_PYTHON || 'python', port = 8196, pins = runtime,
logPath = join(repositoryRoot, 'tools/vendor/test-results/executions/host.log'), forwardOutput = true } = {}) {
if (port !== 8196) throw new Error('Execution tests reserve managed loopback port 8196');
await new Promise((resolve, reject) => {
const probe = createServer();
probe.once('error', error => reject(new Error('Execution loopback port 8196 is already in use', { cause: error })));
probe.listen({ host: '127.0.0.1', port, exclusive: true }, () => probe.close(error => error ? reject(error) : resolve()));
});
const fixtureDirectory = await mkdtemp(join(tmpdir(), 'pm-execution-fixture-'));
let host;
try {
await copyFile(new URL('./fixtures/ledger_nodes.py', import.meta.url), join(fixtureDirectory, '__init__.py'));
execFileSync('git', ['init', '--quiet', fixtureDirectory]);
execFileSync('git', ['-C', fixtureDirectory, 'add', '__init__.py']);
host = await launchComfyHost({ checkout, repository, python, port, pins, logPath, forwardOutput,
pluginName: 'promptmanager', preparePlugin: preparePromptManagerPlugin,
additionalPlugins: [{ pluginName: 'pm_execution_fixture', repository: fixtureDirectory }],
});
} catch (error) {
await rm(fixtureDirectory, { recursive: true, force: true });
throw error;
}
host.finished.finally(() => rm(fixtureDirectory, { recursive: true, force: true })).catch(() => {});
return { host, fixtureDirectory, databasePath: join(host.stateDirectory, 'prompts.db'), async dispose() {
try { await host.stop(); }
finally { await rm(fixtureDirectory, { recursive: true, force: true }); }
} };
}
+71
View File
@@ -0,0 +1,71 @@
"""Deterministic nodes and queue controls installed only in an owned test host."""
import threading
import time
from aiohttp import web
import comfy.model_management
from server import PromptServer
_GATE = threading.Event()
_GATE.set()
_ENTERED = threading.Event()
@PromptServer.instance.routes.post("/pm_execution_fixture/gate/{action}")
async def control_gate(request: web.Request) -> web.Response:
action = request.match_info["action"]
if action == "hold":
_ENTERED.clear()
_GATE.clear()
elif action == "release":
_GATE.set()
else:
raise web.HTTPBadRequest()
return web.json_response({"held": not _GATE.is_set()})
@PromptServer.instance.routes.get("/pm_execution_fixture/gate")
async def gate_status(request: web.Request) -> web.Response:
return web.json_response({"entered": _ENTERED.is_set(), "held": not _GATE.is_set()})
class PMHistoryCached:
@classmethod
def INPUT_TYPES(cls) -> dict:
return {"required": {"value": ("STRING", {"default": "deterministic"})}}
RETURN_TYPES = ("STRING",)
FUNCTION = "execute"
CATEGORY = "testing/execution-history"
OUTPUT_NODE = True
def execute(self, value: str) -> dict:
return {"ui": {"text": [value]}, "result": (value,)}
class PMHistoryFailure(PMHistoryCached):
def execute(self, value: str) -> dict:
raise ValueError(value)
class PMHistoryGate(PMHistoryCached):
@classmethod
def IS_CHANGED(cls, **kwargs: object) -> float:
return float("nan")
def execute(self, value: str) -> dict:
_ENTERED.set()
deadline = time.monotonic() + 30
while not _GATE.wait(0.05):
comfy.model_management.throw_exception_if_processing_interrupted()
if time.monotonic() >= deadline:
raise TimeoutError("Owned execution fixture gate timed out")
return super().execute(value)
NODE_CLASS_MAPPINGS = {
"PMHistoryCached": PMHistoryCached,
"PMHistoryFailure": PMHistoryFailure,
"PMHistoryGate": PMHistoryGate,
}
+46
View File
@@ -0,0 +1,46 @@
"""Bounded read-only SQLite inspection for the owned execution fixture."""
import argparse
import json
from pathlib import Path
import sqlite3
from uuid import UUID
def read_runs(database: str, job_id: str, session_id: str | None = None) -> dict:
job_id = str(UUID(job_id))
if session_id is not None:
session_id = str(UUID(session_id))
uri = Path(database).resolve().as_uri() + "?mode=ro"
with sqlite3.connect(uri, uri=True, timeout=0.25) as connection:
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA query_only=ON")
table = connection.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='execution_runs'"
).fetchone()
if table is None:
return {"schemaAvailable": False, "rows": []}
sql = "SELECT * FROM execution_runs WHERE job_id=?"
parameters = [job_id]
if session_id is not None:
sql += " AND session_id=?"
parameters.append(session_id)
rows = connection.execute(
sql + " ORDER BY rowid LIMIT 257", parameters
).fetchall()
if len(rows) > 256:
raise ValueError("Execution query exceeds the 256-row bound")
return {"schemaAvailable": True, "rows": [dict(row) for row in rows]}
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("database")
parser.add_argument("job_id")
parser.add_argument("--session-id")
arguments = parser.parse_args()
print(
json.dumps(
read_runs(arguments.database, arguments.job_id, arguments.session_id)
)
)
+32
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { readFileSync } from 'node:fs';
const root = fileURLToPath(new URL('../../', import.meta.url));
test('real-ComfyUI suite registers Classic and Nodes 2.0 startup projects', () => {
@@ -13,3 +14,34 @@ test('real-ComfyUI suite registers Classic and Nodes 2.0 startup projects', () =
assert.match(output, /\[classic-start\]/);
assert.match(output, /\[nodes2-start\]/);
});
test('execution script discovers only the new matrix and preserves all 78 stock cases', () => {
const manifest = JSON.parse(readFileSync(new URL('../../tools/vendor/package.json', import.meta.url), 'utf8'));
assert.equal(manifest.scripts['test:executions'], 'playwright test --config playwright.execution.config.mjs');
const list = config => execFileSync(process.execPath, ['tools/vendor/node_modules/@playwright/test/cli.js',
'test', '--config', config, '--list'], { cwd: root, encoding: 'utf8',
env: { ...process.env, PM_COMFY_ROOT: '/unused-pinned-checkout' } });
const stock = list('tools/vendor/comfyui.config.mjs');
assert.match(stock, /Total: 78 tests in 7 files/);
assert.doesNotMatch(stock, /execution-history/);
const executions = list('tools/vendor/playwright.execution.config.mjs');
assert.match(executions, /\[classic-start\]/);
assert.match(executions, /\[nodes2-start\]/);
assert.match(executions, /Total: 10 tests in 1 file/);
assert.doesNotMatch(executions, /prompt-manager.spec/);
});
test('execution config refuses live-host URLs and binds a managed loopback port', () => {
for (const url of ['http://127.0.0.1:8188/', 'https://127.0.0.1:8196/', 'http://localhost:8196/', 'http://example.com:8196/']) {
assert.throws(() => execFileSync(process.execPath, ['--input-type=module', '-e',
"await import('./tools/vendor/playwright.execution.config.mjs')"], { cwd: root, stdio: 'pipe',
env: { ...process.env, PM_COMFY_ROOT: '/unused', PM_EXECUTION_URL: url } }), /loopback|8196/);
}
});
test('CI retains stock, execution and pack suites in order with uploaded execution reports', () => {
const workflow = readFileSync(new URL('../../.github/workflows/test.yml', import.meta.url), 'utf8');
const commands = [...workflow.matchAll(/^ run: npm run --prefix tools\/vendor (test:[a-z]+)$/gm)].map(match => match[1]);
assert.deepEqual(commands, ['test:comfyui', 'test:executions', 'test:compat']);
assert.match(workflow, /path: tools\/vendor\/test-results\//);
});
+70 -1
View File
@@ -1,10 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtemp, writeFile, mkdir, readFile, realpath, rm, access } from 'node:fs/promises';
import { mkdtemp, writeFile, mkdir, readFile, realpath, rm, access, readdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { once } from 'node:events';
import { createServer } from 'node:net';
import { launchComfyHost } from '../../tools/vendor/comfyui-host.mjs';
async function fixture(t, program) {
@@ -59,3 +60,71 @@ test('PromptManager adapter isolates database, configuration and monitored direc
await access(join(options.repository, '__init__.py'));
});
test('execution host adds only the tracked fixture pack and removes all owned state', async t => {
const { launchExecutionHost } = await import('../comfyui/execution-host.mjs');
const options = await fixture(t, idle);
const execution = await launchExecutionHost({ ...options, port: 8196 });
t.after(() => execution.dispose());
await once(execution.host.child.stdout, 'data');
const args = execution.host.child.spawnargs;
assert.deepEqual(args.slice(args.indexOf('--whitelist-custom-nodes') + 1), ['promptmanager', 'pm_execution_fixture']);
assert.equal(execution.databasePath, join(execution.host.stateDirectory, 'prompts.db'));
const installed = join(execution.host.stateDirectory, 'custom_nodes/pm_execution_fixture');
assert.match(await readFile(join(installed, '__init__.py'), 'utf8'), /PMHistoryCached/);
assert.deepEqual(await readdir(installed), ['__init__.py']);
const source = execution.fixtureDirectory;
assert.notEqual(await realpath(source), await realpath(options.repository));
await execution.dispose();
await assert.rejects(access(execution.host.stateDirectory), { code: 'ENOENT' });
await assert.rejects(access(source), { code: 'ENOENT' });
await access(join(options.repository, '__init__.py'));
});
test('read-only ledger helper distinguishes absent schema from missing, corrupt and oversized data', async t => {
const { readExecutionRuns } = await import('../comfyui/execution-fixture.mjs');
const directory = await mkdtemp(join(tmpdir(), 'pm-ledger-read-'));
t.after(() => rm(directory, { recursive: true, force: true }));
const database = join(directory, 'prompts.db');
const python = process.env.PM_TEST_PYTHON || 'python';
const job = '10000000-0000-4000-8000-000000000001';
const session = '20000000-0000-4000-8000-000000000001';
execFileSync(python, ['-c', 'import sqlite3,sys; sqlite3.connect(sys.argv[1]).close()', database]);
assert.deepEqual(await readExecutionRuns(database, job), { schemaAvailable: false, rows: [] });
execFileSync(python, ['-c', `import sqlite3,sys
c=sqlite3.connect(sys.argv[1])
c.execute('CREATE TABLE execution_runs (run_id TEXT, job_id TEXT, session_id TEXT, outcome TEXT)')
c.executemany('INSERT INTO execution_runs VALUES (?,?,?,?)', [('first',sys.argv[2],sys.argv[3],'succeeded'),('other','30000000-0000-4000-8000-000000000001',sys.argv[3],'failed')])
c.commit()`, database, job, session]);
const before = await readFile(database);
assert.deepEqual(await readExecutionRuns(database, job, session), { schemaAvailable: true, rows: [
{ run_id: 'first', job_id: job, session_id: session, outcome: 'succeeded' },
] });
assert.deepEqual((await readExecutionRuns(database, job, '40000000-0000-4000-8000-000000000001')).rows, []);
assert.deepEqual(await readFile(database), before);
await assert.rejects(readExecutionRuns(database, "' OR 1=1 --"));
execFileSync(python, ['-c', `import sqlite3,sys
c=sqlite3.connect(sys.argv[1])
c.executemany('INSERT INTO execution_runs VALUES (?,?,?,?)', [(str(i),sys.argv[2],sys.argv[3],'succeeded') for i in range(256)])
c.commit()`, database, job, session]);
await assert.rejects(readExecutionRuns(database, job), /bound|256/);
await assert.rejects(readExecutionRuns(join(directory, 'missing.db'), job));
await writeFile(database, 'corrupt database');
await assert.rejects(readExecutionRuns(database, job));
});
test('execution launcher refuses an occupied port without touching an existing server', async t => {
const { launchExecutionHost } = await import('../comfyui/execution-host.mjs');
const options = await fixture(t, idle);
const server = createServer();
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(8196, '127.0.0.1', resolve); });
let execution;
try {
await assert.rejects(async () => { execution = await launchExecutionHost({ ...options, port: 8196 }); }, /already in use/);
assert.equal(server.listening, true);
} finally {
await execution?.dispose();
await new Promise(resolve => server.close(resolve));
}
});
+2
View File
@@ -4,6 +4,8 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
import { launchComfyHost as launchSharedHost } from '@comfyassets/toolkit/testing/host';
import runtime from './comfyui-runtime.json' with { type: 'json' };
export { launchSharedHost };
const repositoryRoot = fileURLToPath(new URL('../../', import.meta.url));
export async function preparePromptManagerPlugin({ installed, stateDirectory }) {
+1
View File
@@ -14,6 +14,7 @@ const servers = [{
export default defineConfig({
testDir: '../../tests/comfyui',
testIgnore: 'execution-history.spec.mjs',
outputDir: './test-results/comfyui',
timeout: 60000,
workers: 1,
+1
View File
@@ -12,6 +12,7 @@
"test:browser": "playwright test --config playwright.config.mjs",
"test:workflows": "playwright test --config playwright.workflows.config.mjs",
"test:comfyui": "playwright test --config comfyui.config.mjs",
"test:executions": "playwright test --config playwright.execution.config.mjs",
"test:coverage": "node frontend-coverage.mjs",
"coverage:update": "node frontend-coverage.mjs --update-baseline",
"test:compat": "playwright test --config pack-compat.config.mjs"
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from './node_modules/@playwright/test/index.mjs';
if (!process.env.PM_COMFY_ROOT) throw new Error('Set PM_COMFY_ROOT to a pinned disposable ComfyUI checkout');
const host = new URL(process.env.PM_EXECUTION_URL || 'http://127.0.0.1:8196/');
if (host.href !== 'http://127.0.0.1:8196/') throw new Error('Execution tests require managed loopback http://127.0.0.1:8196/');
export default defineConfig({
testDir: '../../tests/comfyui',
testMatch: 'execution-history.spec.mjs',
outputDir: './test-results/executions/cases',
timeout: 60000,
workers: 1,
forbidOnly: Boolean(process.env.CI),
retries: 0,
use: { baseURL: host.href, browserName: 'chromium', viewport: { width: 1600, height: 1100 },
trace: 'retain-on-failure', screenshot: 'only-on-failure' },
projects: [
{ name: 'classic-start', metadata: { initialRenderer: false } },
{ name: 'nodes2-start', metadata: { initialRenderer: true } },
],
});