* fix(web): generation-agnostic Comfy draft-quota guard "Failed to save workflow draft" toasts returned on ComfyUI frontend 1.44+ because draft storage moved to new localStorage key names (per-workspace V1 keys, then a V2 index+payload scheme) that the old exact-name guard embedded in ltx_director.js never matched - and the 1.44 V2 layer latches a session-wide storage-unavailable flag after one failed save+evict cycle, re-toasting on every edit until reload. Move the guard to its own global extension, web/koolook_draft_guard.js: - match draft keys by prefix (Comfy.Workflow.Draft) across all known generations, with unknown future keys as last-resort evictions - rescue quota failures inside localStorage.setItem (evict the oldest draft across generations, retry) before Comfy's own catch sees the error, so the save succeeds and the latch never trips - boot prune: drop the suffixed-V1 dead weight the frontend's V1->V2 migration never deletes, scope corrupt-key cleanup to the offending key, drop oversized entries, enforce a total draft budget - ship the guard with dev-sync-audio so a scoped sync cannot strand a dev install guard-less Behavior is covered by tests/js/test_draft_guard.mjs (12 scenarios, wired into CI next to the existing node smoke test); static integration contracts live in tests/sidebar/test_draft_guard.py. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(web): address deep-review findings on the draft guard - latch the "still full" hint toast once per session so a genuinely full origin cannot re-toast on repeated failing autosave attempts; regression assertion added to the exhaustion scenario - document the bounded-eviction termination contract and the post-rethrow storage-unavailable latch at the wrapper, plus the budget's V1-blob-only (1.5M) -> all-generations (2M) scope widening (also now stated in the CHANGELOG entry) - version-robust wording for the suffixed-V1 cleanup rationale: the shipped 1.44 migration leaves the keys behind (verified against the installed 1.44.19 dist - no removeItem exists there), later frontends clean up on successful migration, and an interrupted migration can leak the keys on any version - comment the intentional best-effort swallow in the toast helper Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
374 lines
14 KiB
JavaScript
374 lines
14 KiB
JavaScript
// Behavior tests for web/koolook_draft_guard.js — the generation-agnostic
|
|
// guard around ComfyUI's browser-side workflow draft storage.
|
|
//
|
|
// Black-box: each scenario builds a fake quota-enforcing localStorage,
|
|
// seeds it with draft keys from one or more storage generations, loads the
|
|
// guard source in an isolated vm context (which runs the boot prune and
|
|
// installs the setItem wrapper, exactly like a real page load), then
|
|
// asserts on the resulting storage state / thrown errors / toasts.
|
|
//
|
|
// Storage generations modelled here (see the guard's header comment):
|
|
// V1 unsuffixed Comfy.Workflow.Drafts / Comfy.Workflow.DraftOrder
|
|
// V1 per-workspace ...Drafts:<ws> / ...DraftOrder:<ws>
|
|
// V2 (1.44+) Comfy.Workflow.DraftIndex.v2:<ws> + Comfy.Workflow.Draft.v2:<ws>:<hash>
|
|
//
|
|
// Run: node tests/js/test_draft_guard.mjs
|
|
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import vm from "node:vm";
|
|
|
|
const repoRoot = path.resolve(import.meta.dirname, "../..");
|
|
const sourcePath = path.join(repoRoot, "web", "koolook_draft_guard.js");
|
|
const source = fs.readFileSync(sourcePath, "utf8");
|
|
|
|
const V1_DRAFTS = "Comfy.Workflow.Drafts";
|
|
const V1_ORDER = "Comfy.Workflow.DraftOrder";
|
|
const v2Index = (ws) => `Comfy.Workflow.DraftIndex.v2:${ws}`;
|
|
const v2Payload = (ws, hash) => `Comfy.Workflow.Draft.v2:${ws}:${hash}`;
|
|
|
|
function makeLocalStorage(initialQuota = Infinity) {
|
|
const map = new Map();
|
|
let quota = initialQuota;
|
|
const used = () => {
|
|
let total = 0;
|
|
for (const [k, v] of map) total += k.length + v.length;
|
|
return total;
|
|
};
|
|
return {
|
|
getItem(k) {
|
|
k = String(k);
|
|
return map.has(k) ? map.get(k) : null;
|
|
},
|
|
setItem(k, v) {
|
|
k = String(k);
|
|
v = String(v);
|
|
const current = map.has(k) ? k.length + map.get(k).length : 0;
|
|
if (used() - current + k.length + v.length > quota) {
|
|
throw new DOMException("Simulated quota exceeded", "QuotaExceededError");
|
|
}
|
|
map.set(k, v);
|
|
},
|
|
removeItem(k) {
|
|
map.delete(String(k));
|
|
},
|
|
key(i) {
|
|
return [...map.keys()][i] ?? null;
|
|
},
|
|
get length() {
|
|
return map.size;
|
|
},
|
|
__map: map,
|
|
__used: used,
|
|
__setQuota(n) {
|
|
quota = n;
|
|
},
|
|
};
|
|
}
|
|
|
|
function loadGuard(store, { toasts = [], warns = [], context = null } = {}) {
|
|
const ctx = context ?? {
|
|
localStorage: store,
|
|
DOMException,
|
|
setTimeout: () => 0,
|
|
document: {
|
|
createElement: () => ({ textContent: "", style: {}, remove() {} }),
|
|
body: { appendChild: (el) => toasts.push(el.textContent) },
|
|
},
|
|
console: {
|
|
warn: (...args) => warns.push(args.map(String).join(" ")),
|
|
log() {},
|
|
error() {},
|
|
},
|
|
};
|
|
ctx.globalThis = ctx;
|
|
vm.runInNewContext(source, ctx, { filename: sourcePath });
|
|
return ctx;
|
|
}
|
|
|
|
function seedV1(store, suffix, entries) {
|
|
const drafts = {};
|
|
const order = [];
|
|
for (const [p, data, updatedAt] of entries) {
|
|
drafts[p] = { data, updatedAt, name: p, isTemporary: false };
|
|
order.push(p);
|
|
}
|
|
store.setItem(V1_DRAFTS + suffix, JSON.stringify(drafts));
|
|
store.setItem(V1_ORDER + suffix, JSON.stringify(order));
|
|
}
|
|
|
|
function seedV2(store, ws, entries) {
|
|
const index = { v: 2, updatedAt: 1, order: [], entries: {} };
|
|
for (const [hash, data, updatedAt] of entries) {
|
|
index.order.push(hash);
|
|
index.entries[hash] = { name: hash, isTemporary: false, updatedAt, path: `workflows/${hash}.json` };
|
|
store.setItem(v2Payload(ws, hash), JSON.stringify({ data, updatedAt }));
|
|
}
|
|
store.setItem(v2Index(ws), JSON.stringify(index));
|
|
}
|
|
|
|
function v1Paths(store, suffix = "") {
|
|
const raw = store.getItem(V1_DRAFTS + suffix);
|
|
return raw === null ? null : Object.keys(JSON.parse(raw));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// S1: wrapper installs once; a second load (stale copy scenario) is a no-op.
|
|
{
|
|
const store = makeLocalStorage();
|
|
const nativeSetItem = store.setItem;
|
|
const ctx = loadGuard(store);
|
|
assert.notEqual(store.setItem, nativeSetItem, "setItem should be wrapped after load");
|
|
const firstWrapper = store.setItem;
|
|
loadGuard(store, { context: ctx });
|
|
assert.equal(store.setItem, firstWrapper, "second install must not re-wrap (sentinel)");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// S2: quota error on a NON-draft key is rethrown untouched; no draft evicted.
|
|
{
|
|
const store = makeLocalStorage();
|
|
seedV1(store, "", [["wf-a", "x".repeat(200), 10]]);
|
|
const toasts = [];
|
|
loadGuard(store, { toasts });
|
|
store.__setQuota(store.__used() + 50);
|
|
assert.throws(
|
|
() => store.setItem("koolook.workflows.fallback.v1", "y".repeat(5000)),
|
|
(err) => err instanceof DOMException && err.name === "QuotaExceededError",
|
|
"non-draft quota error must propagate",
|
|
);
|
|
assert.deepEqual(v1Paths(store), ["wf-a"], "draft entries must not be touched");
|
|
assert.equal(toasts.length, 0, "no toast for non-draft failures");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// S3: the live bug — a V2 payload write is rescued by evicting old V1 drafts.
|
|
{
|
|
const store = makeLocalStorage();
|
|
seedV1(store, "", [
|
|
["wf-old", "x".repeat(300), 10],
|
|
["wf-new", "y".repeat(300), 20],
|
|
]);
|
|
seedV2(store, "personal", [["aaaa1111", "z".repeat(100), 30]]);
|
|
const toasts = [];
|
|
loadGuard(store, { toasts });
|
|
|
|
const key = v2Payload("personal", "bbbb2222");
|
|
const value = JSON.stringify({ data: "w".repeat(220), updatedAt: 40 });
|
|
store.__setQuota(store.__used() + key.length + value.length - 1);
|
|
|
|
store.setItem(key, value); // must not throw
|
|
assert.equal(store.getItem(key), value, "new V2 payload must be written");
|
|
assert.deepEqual(v1Paths(store), ["wf-new"], "oldest V1 draft must be evicted first");
|
|
assert.ok(
|
|
store.getItem(v2Payload("personal", "aaaa1111")) !== null,
|
|
"newer V2 payload must survive",
|
|
);
|
|
assert.equal(toasts.length, 1, "one rescue toast per page session");
|
|
assert.match(toasts[0], /removed the oldest/i);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// S4: eviction picks the OLDEST draft across generations (V2 older than V1).
|
|
{
|
|
const store = makeLocalStorage();
|
|
seedV1(store, "", [["wf-v1", "x".repeat(150), 100]]);
|
|
seedV2(store, "personal", [
|
|
["old00001", "a".repeat(150), 50],
|
|
["new00001", "b".repeat(150), 200],
|
|
]);
|
|
loadGuard(store);
|
|
|
|
const key = v2Payload("personal", "cccc3333");
|
|
const value = JSON.stringify({ data: "c".repeat(60), updatedAt: 300 });
|
|
store.__setQuota(store.__used() + key.length + value.length - 1);
|
|
|
|
store.setItem(key, value);
|
|
assert.equal(store.getItem(v2Payload("personal", "old00001")), null, "oldest (V2, age 50) evicted");
|
|
assert.deepEqual(v1Paths(store), ["wf-v1"], "younger V1 draft survives");
|
|
assert.ok(store.getItem(v2Payload("personal", "new00001")) !== null, "younger V2 payload survives");
|
|
const index = JSON.parse(store.getItem(v2Index("personal")));
|
|
assert.ok(!index.order.includes("old00001"), "V2 index order drops the evicted hash");
|
|
assert.ok(!("old00001" in index.entries), "V2 index entries drop the evicted hash");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// S5: boot prune deletes suffixed-V1 families superseded by a V2 index,
|
|
// keeps families that are still that workspace's only draft store.
|
|
{
|
|
const store = makeLocalStorage();
|
|
seedV1(store, "", [["wf-root", "r".repeat(50), 5]]);
|
|
seedV1(store, ":wsA", [["wf-a", "a".repeat(50), 5]]);
|
|
seedV2(store, "wsA", [["aaaa0001", "p".repeat(50), 6]]);
|
|
seedV1(store, ":wsB", [["wf-b", "b".repeat(50), 5]]);
|
|
loadGuard(store);
|
|
|
|
assert.equal(store.getItem(V1_DRAFTS + ":wsA"), null, "migrated V1 family is dead weight");
|
|
assert.equal(store.getItem(V1_ORDER + ":wsA"), null, "migrated V1 order key removed too");
|
|
assert.notEqual(store.getItem(V1_DRAFTS + ":wsB"), null, "unmigrated workspace keeps its V1 family");
|
|
assert.deepEqual(v1Paths(store), ["wf-root"], "unsuffixed V1 family untouched");
|
|
assert.notEqual(store.getItem(v2Index("wsA")), null, "V2 index untouched");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// S6: boot prune drops oversized entries (per-entry cap) in both generations.
|
|
{
|
|
const store = makeLocalStorage();
|
|
seedV1(store, "", [
|
|
["wf-huge", "h".repeat(800_000), 10],
|
|
["wf-ok", "o".repeat(100), 20],
|
|
]);
|
|
seedV2(store, "personal", [
|
|
["huge0001", "H".repeat(800_000), 10],
|
|
["fine0001", "f".repeat(100), 20],
|
|
]);
|
|
loadGuard(store);
|
|
|
|
assert.deepEqual(v1Paths(store), ["wf-ok"], "oversized V1 entry dropped, sibling kept");
|
|
assert.equal(store.getItem(v2Payload("personal", "huge0001")), null, "oversized V2 payload dropped");
|
|
assert.notEqual(store.getItem(v2Payload("personal", "fine0001")), null, "small V2 payload kept");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// S7: boot prune enforces the total draft budget, oldest first.
|
|
{
|
|
const store = makeLocalStorage();
|
|
seedV2(store, "personal", [
|
|
["aged0001", "a".repeat(700_000), 1],
|
|
["aged0002", "b".repeat(700_000), 2],
|
|
["aged0003", "c".repeat(700_000), 3],
|
|
]);
|
|
const warns = [];
|
|
loadGuard(store, { warns });
|
|
|
|
assert.equal(store.getItem(v2Payload("personal", "aged0001")), null, "oldest payload evicted");
|
|
assert.notEqual(store.getItem(v2Payload("personal", "aged0002")), null, "second-oldest survives");
|
|
assert.notEqual(store.getItem(v2Payload("personal", "aged0003")), null, "newest survives");
|
|
assert.ok(
|
|
warns.some((w) => w.includes("[Koolook draft-guard]")),
|
|
"prune logs what it did",
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// S8: a corrupt draft key is removed without touching other generations.
|
|
{
|
|
const store = makeLocalStorage();
|
|
store.setItem(V1_DRAFTS, "{not json");
|
|
store.setItem(V1_ORDER, "[]");
|
|
seedV2(store, "personal", [["good0001", "g".repeat(100), 10]]);
|
|
loadGuard(store);
|
|
|
|
assert.equal(store.getItem(V1_DRAFTS), null, "corrupt V1 blob removed");
|
|
assert.equal(store.getItem(V1_ORDER), null, "its order twin removed");
|
|
assert.notEqual(store.getItem(v2Payload("personal", "good0001")), null, "V2 untouched");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// S9a: unknown future draft keys are NOT evicted while structured candidates
|
|
// remain...
|
|
{
|
|
const store = makeLocalStorage();
|
|
seedV1(store, "", [
|
|
["wf-old", "x".repeat(300), 10],
|
|
["wf-new", "y".repeat(300), 20],
|
|
]);
|
|
store.setItem("Comfy.Workflow.DraftCache.v3:future", "F".repeat(120));
|
|
loadGuard(store);
|
|
|
|
const key = v2Payload("personal", "dddd4444");
|
|
const value = JSON.stringify({ data: "d".repeat(80), updatedAt: 99 });
|
|
store.__setQuota(store.__used() + key.length + value.length - 1);
|
|
store.setItem(key, value);
|
|
|
|
assert.notEqual(store.getItem("Comfy.Workflow.DraftCache.v3:future"), null, "future key kept");
|
|
assert.deepEqual(v1Paths(store), ["wf-new"], "structured candidate evicted instead");
|
|
}
|
|
|
|
// S9b: ...but ARE evicted as a last resort, so a future frontend rename
|
|
// cannot brick draft saves again.
|
|
{
|
|
const store = makeLocalStorage();
|
|
store.setItem("Comfy.Workflow.DraftCache.v3:future", "F".repeat(400));
|
|
loadGuard(store);
|
|
|
|
const key = v2Payload("personal", "eeee5555");
|
|
const value = JSON.stringify({ data: "e".repeat(200), updatedAt: 99 });
|
|
store.__setQuota(store.__used() + key.length + value.length - 1);
|
|
store.setItem(key, value);
|
|
|
|
assert.equal(store.getItem("Comfy.Workflow.DraftCache.v3:future"), null, "future key evicted last-resort");
|
|
assert.equal(store.getItem(key), value, "write rescued");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// S10: when nothing can be evicted, the original quota error propagates and
|
|
// the user gets the "still full" toast.
|
|
{
|
|
const store = makeLocalStorage();
|
|
store.setItem("someone.elses.data", "Z".repeat(500));
|
|
const toasts = [];
|
|
loadGuard(store, { toasts });
|
|
store.__setQuota(store.__used() + 20);
|
|
|
|
const key = v2Payload("personal", "ffff6666");
|
|
assert.throws(
|
|
() => store.setItem(key, "f".repeat(100)),
|
|
(err) => err instanceof DOMException && err.name === "QuotaExceededError",
|
|
"unrescuable write rethrows the quota error",
|
|
);
|
|
// A second failing write in the same session still throws but must NOT
|
|
// re-toast — otherwise a genuinely-full origin gets per-edit toast spam.
|
|
assert.throws(
|
|
() => store.setItem(key, "f".repeat(100)),
|
|
(err) => err instanceof DOMException && err.name === "QuotaExceededError",
|
|
"repeat unrescuable write still rethrows",
|
|
);
|
|
assert.equal(
|
|
toasts.filter((t) => /still full/i.test(t)).length,
|
|
1,
|
|
"still-full toast is latched once per session",
|
|
);
|
|
assert.notEqual(store.getItem("someone.elses.data"), null, "non-draft data never touched");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// S11: a pre-set sentinel (older guard copy already installed) skips the
|
|
// wrapper but the boot prune still runs.
|
|
{
|
|
const store = makeLocalStorage();
|
|
store.__koolookDraftQuotaGuardInstalled = true;
|
|
const nativeSetItem = store.setItem;
|
|
seedV1(store, ":wsA", [["wf-a", "a".repeat(50), 5]]);
|
|
seedV2(store, "wsA", [["aaaa0001", "p".repeat(50), 6]]);
|
|
loadGuard(store);
|
|
|
|
assert.equal(store.setItem, nativeSetItem, "wrapper must not double-install");
|
|
assert.equal(store.getItem(V1_DRAFTS + ":wsA"), null, "boot prune still runs");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// S12: when the failing write IS a draft container (V1 blob growing), prefer
|
|
// freeing OTHER keys — rewriting the target key smaller cannot help,
|
|
// because the retry overwrites it with the same big value anyway.
|
|
{
|
|
const store = makeLocalStorage();
|
|
seedV1(store, "", [["wf-keep", "k".repeat(200), 5]]); // older than the V2 entry
|
|
seedV2(store, "personal", [["gggg7777", "g".repeat(200), 50]]);
|
|
loadGuard(store);
|
|
|
|
const grown = JSON.stringify({
|
|
"wf-keep": { data: "k".repeat(200), updatedAt: 5, name: "wf-keep", isTemporary: false },
|
|
"wf-added": { data: "n".repeat(120), updatedAt: 60, name: "wf-added", isTemporary: false },
|
|
});
|
|
const delta = grown.length - store.getItem(V1_DRAFTS).length;
|
|
store.__setQuota(store.__used() + delta - 1);
|
|
|
|
store.setItem(V1_DRAFTS, grown);
|
|
assert.equal(store.getItem(V1_DRAFTS), grown, "grown V1 blob written intact");
|
|
assert.equal(store.getItem(v2Payload("personal", "gggg7777")), null, "space freed from another key");
|
|
}
|
|
|
|
console.log("test_draft_guard.mjs: all scenarios passed");
|