| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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)); |
| } |
|
|
| |
| |
| { |
| 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)"); |
| } |
|
|
| |
| |
| { |
| 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"); |
| } |
|
|
| |
| |
| { |
| 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); |
| 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); |
| } |
|
|
| |
| |
| { |
| 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"); |
| } |
|
|
| |
| |
| |
| { |
| 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"); |
| } |
|
|
| |
| |
| { |
| 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"); |
| } |
|
|
| |
| |
| { |
| 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", |
| ); |
| } |
|
|
| |
| |
| { |
| 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"); |
| } |
|
|
| |
| |
| |
| { |
| 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"); |
| } |
|
|
| |
| |
| { |
| 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"); |
| } |
|
|
| |
| |
| |
| { |
| 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", |
| ); |
| |
| |
| 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"); |
| } |
|
|
| |
| |
| |
| { |
| 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"); |
| } |
|
|
| |
| |
| |
| |
| { |
| const store = makeLocalStorage(); |
| seedV1(store, "", [["wf-keep", "k".repeat(200), 5]]); |
| 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"); |
|
|