207 lines
13 KiB
JavaScript
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; }
|
|
};
|