Files

207 lines
13 KiB
JavaScript

import { NativePreview } from "/extensions/comfyui-encrypt/native_preview.mjs";
import { PreviewLoader } from "/extensions/comfyui-encrypt/loader.mjs";
import { restorePacket } from "/extensions/comfyui-encrypt/codec.mjs";
const output = document.querySelector("#result");
const assert = (condition, message) => { if (!condition) throw new Error(message); };
const until = async (predicate, name, timeout = 45_000) => {
const deadline = performance.now() + timeout;
while (!predicate()) {
if (performance.now() > deadline) throw new Error(`Timed out: ${name}`);
await new Promise(resolve => setTimeout(resolve, 30));
}
};
const status = () => fetch("/encrypt-preview-test/status").then(r => r.json());
async function execute(count, size, failAt, view, onImage, outputType = "EncryptPreviewImage") {
const clientId = crypto.randomUUID(), promptId = crypto.randomUUID();
const socket = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws?clientId=${clientId}`);
let binary = 0, previewStarted, terminal, result, rejectDone;
const events = [];
const started = performance.now();
const done = new Promise((resolve, reject) => {
rejectDone = reject;
socket.onmessage = ({ data }) => {
if (typeof data !== "string") { binary++; return; }
const message = JSON.parse(data), detail = message.data;
if (detail?.prompt_id !== promptId) return;
if (message.type === "executing" && detail.node === "2") previewStarted = performance.now();
if (message.type === "encrypt-preview") {
events.push(detail);
view?.receive(detail);
if (detail.event === "image") onImage?.(detail, promptId);
}
if (message.type === "executed" && detail.output?.encrypt_preview) {
result = detail.output.encrypt_preview;
view?.showResults(result);
}
if (["execution_success", "execution_error", "execution_interrupted"].includes(message.type)) {
terminal = message.type;
resolve({ terminal, result, events, binary, elapsed_ms: performance.now() - started,
preview_ms: previewStarted ? performance.now() - previewStarted : null });
}
};
socket.onerror = () => reject(new Error("Test WebSocket failed"));
socket.onclose = () => { if (!terminal) reject(new Error("Test WebSocket closed before completion")); };
});
// Attach rejection handling before connecting/submitting so startup failures
// cannot become unhandled promise rejections.
done.catch(() => {});
const timeout = setTimeout(() => rejectDone(new Error("ComfyUI execution timed out")), 120_000);
try {
await until(() => socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CLOSED, "WebSocket connection", 15_000);
assert(socket.readyState === WebSocket.OPEN, "WebSocket did not connect");
const response = await fetch("/api/prompt", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ client_id: clientId, prompt_id: promptId, prompt: {
"1": { class_type: "PreviewTestImage", inputs: { count, size, fail_at: failAt } },
"2": { class_type: outputType, inputs: { images: ["1", 0] } },
} }),
});
if (!response.ok) throw new Error(`Prompt rejected: ${await response.text()}`);
return await done;
} finally { clearTimeout(timeout); socket.close(); }
}
document.querySelector("#run").onclick = async event => {
event.target.disabled = true;
document.body.dataset.result = "running";
const checks = [];
const pass = (name, metrics = {}) => { checks.push({ name, ...metrics }); output.textContent = JSON.stringify(checks, null, 2); };
let view, loader, faultView, faultLoader;
try {
const requests = [];
let active = 0, maxActive = 0, bytes = 0, firstThumbnail;
const started = performance.now();
loader = new PreviewLoader(async (route, options) => {
requests.push(route); active++; maxActive = Math.max(maxActive, active);
try {
const response = await fetch("/api" + route, options);
bytes += Number(response.headers.get("Content-Length"));
return response;
} finally { active--; }
});
const node = { imgs: [], graph: { setDirtyCanvas() {
const sample = node.imgs?.find(image => image.src.startsWith("blob:"));
if (sample && !firstThumbnail) {
firstThumbnail = performance.now() - started;
document.querySelector("#sample").src = sample.src;
}
} } };
let currentStatus = "";
view = new NativePreview(node, loader, text => { currentStatus = text; });
view.setVisible(true);
const before = await status();
const batch = await execute(100, 1024, -1, view);
await until(() => view.thumbnails.size === 100, "100 decoded thumbnails");
assert(batch.terminal === "execution_success", "Batch failed");
const indices = batch.events.filter(e => e.event === "image").map(e => e.item.index);
assert(indices.length === 100 && indices.every((value, index) => value === index), "Image indices lost or reordered");
assert(batch.binary === 0, "Native plaintext preview was transmitted");
assert(!requests.some(route => route.endsWith("/original")), "Originals were eagerly loaded");
assert(maxActive <= 2, "Unbounded request concurrency");
const after = await status();
assert(after.preview_method === "none", "Per-job native preview override was not applied");
assert(after.temp_pngs === before.temp_pngs && after.output_pngs === before.output_pngs, "Encrypted preview wrote plaintext PNG files");
pass("100 actual ComfyUI images: ordered thumbnails, bounded loading, binary transport, no plaintext preview files", {
first_thumbnail_ms: +firstThumbnail.toFixed(1), preview_node_ms: +batch.preview_ms.toFixed(1),
workflow_ms: +batch.elapsed_ms.toFixed(1), thumbnail_bytes: bytes,
thumbnail_pixel_bytes: [...view.thumbnails.values()].reduce((sum, record) => sum + record.image.naturalWidth * record.image.naturalHeight * 4, 0),
backend_cache_bytes: after.cache_bytes, peak_fetch_concurrency: maxActive,
});
node.imageIndex = 0;
const original = await view.ensureOriginal(0);
assert(node.imgs[0].naturalWidth === 1024, "Selected original was not attached to the native image list");
const originalUrl = original.url;
const download = await view.createDownload(0);
const downloaded = new Uint8Array(await (await fetch(download.url)).arrayBuffer());
const packet = await (await fetch(`/api/encrypt-preview/v1/${batch.result[0].batch_id}/0/original`)).arrayBuffer();
const plain = restorePacket(packet.slice(0)).bytes;
assert(downloaded.length === plain.length && downloaded.every((value, index) => value === plain[index]), "Download differs from encoded original PNG");
assert(download.filename.endsWith("_001.png"), "Download filename/index mismatch");
node.imageIndex = 99;
await view.ensureOriginal(99);
assert(view.original.index === 99 && node.imgs[99].naturalWidth === 1024, "Last original was not selected");
assert(node.imgs[0].naturalWidth === 384, "Previous full image was retained in the grid");
assert(await fetch(originalUrl).then(() => false, () => true), "Previous original Blob URL leaked");
assert((await fetch(download.url)).ok, "Selection change revoked an ongoing download");
node.imageIndex = null;
assert(!view.original && node.imgs.every(image => image.naturalWidth === 384), "Returning to the grid retained a full image");
pass("Full-resolution selection, byte-exact PNG downloads and independent download lifetime");
view.setVisible(false);
await until(() => loader.queue.length === 0 && loader.slots.every(slot => !slot.busy), "hidden node cleanup");
assert(node.imgs.length === 0 && view.thumbnails.size === 0 && !view.original, "Hidden node retained decoded images");
view.setVisible(true);
await until(() => view.thumbnails.size > 0, "visible node reload");
pass("Offscreen nodes release images and resume loading when visible");
const failure = await execute(3, 64, 1, view);
assert(failure.terminal === "execution_error", "Partial failure reported success");
assert(failure.events.filter(e => e.event === "image").length === 1 && view.items.size === 1, "Partial results were lost or stale");
assert(currentStatus.includes("失败"), "Processing failure was not shown");
pass("Partial batch failures propagate through ComfyUI and remain visible");
let interruptRequest;
const cancelled = await execute(100, 1024, -1, view, (_, promptId) => {
interruptRequest ??= fetch("/api/interrupt", {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt_id: promptId }),
});
});
assert((await interruptRequest).ok, "Interrupt request failed");
assert(cancelled.terminal === "execution_interrupted", "Cancellation did not propagate");
assert(cancelled.events.filter(e => e.event === "image").length < 100, "Cancellation was ignored");
const key = view.key;
view.receive({ event: "image", batch_id: "f".repeat(32), item: { index: 99, width: 1, height: 1 } });
assert(view.key === key && !view.items.has(99), "Stale event changed the active batch");
pass("User cancellation stops processing; stale batch events are ignored");
const small = await execute(2, 64, -1, view);
await until(() => view.thumbnails.size === 2, "small preview");
const counter = (await status()).fixture_executions;
const repeated = await execute(2, 64, -1, view);
assert((await status()).fixture_executions === counter, "Output refresh invalidated upstream cached image generation");
assert(repeated.result[0].batch_id !== small.result[0].batch_id, "Rerun did not refresh preview cache handles");
pass("Rerunning the output refreshes previews while keeping upstream results cached");
const expired = repeated.result[0].batch_id;
await fetch("/encrypt-preview-test/expire", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ batch_id: expired }) });
view.setVisible(false); view.setVisible(true);
await until(() => currentStatus.includes("预览已过期"), "expired preview error");
const gone = await fetch(`/api/encrypt-preview/v1/${expired}/0/original`);
assert(gone.status === 410 && gone.headers.get("Cache-Control").includes("no-store"), "Expired cache response was incorrect");
pass("Expired history previews fail visibly with HTTP 410 and no-store");
const corrupt = packet.slice(0); new Uint8Array(corrupt)[24] ^= 1;
let faultStatus = "";
faultLoader = new PreviewLoader(async () => new Response(corrupt.slice(0)));
faultView = new NativePreview({ graph: { setDirtyCanvas() {} } }, faultLoader, text => { faultStatus = text; });
faultView.showResults([{ version: 1, batch_id: "a".repeat(32), total: 1, status: "complete", items: [{ index: 0, width: 1024, height: 1024 }] }]);
faultView.setVisible(true);
await until(() => faultStatus.includes("数据损坏"), "corrupt packet error");
faultView.dispose(); view.dispose();
await until(() => [loader, faultLoader].every(value => value.queue.length === 0 && value.slots.every(slot => !slot.busy && !slot.worker)), "worker disposal");
assert(view.items.size === 0 && node.imgs.length === 0 && !view.downloads.size, "Disposed preview retained resources");
pass("Worker integrity failures are visible; disposal releases requests, workers, images and Blob URLs");
const native = await execute(100, 1024, -1, null, null, "PreviewImage");
assert(native.terminal === "execution_success", "Native preview baseline failed");
const normal = await execute(1, 72, -1, null, null, "PreviewImage");
assert(normal.binary > 0 && (await status()).preview_method === "auto", "Native preview override leaked into an ordinary workflow");
pass("Native PreviewImage backend baseline; ordinary workflows retain native sampling previews", {
native_preview_node_ms: +native.preview_ms.toFixed(1), encrypted_preview_node_ms: +batch.preview_ms.toFixed(1),
ratio: +(batch.preview_ms / native.preview_ms).toFixed(3),
});
output.textContent = "PASS\n" + JSON.stringify(checks, null, 2);
document.body.dataset.result = "pass";
await fetch("/encrypt-preview-test-result", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: "pass", checks, user_agent: navigator.userAgent }) });
} catch (error) {
view?.dispose(); faultView?.dispose();
output.textContent = "FAIL\n" + error.stack + "\n" + JSON.stringify(checks, null, 2);
document.body.dataset.result = "fail";
await fetch("/encrypt-preview-test-result", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: "fail", error: error.stack, checks }) });
} finally { event.target.disabled = false; }
};