Files
malkuthro-ComfyUI-Koolook/tests/sidebar/test_boot_drift_detection.py
T
8058e17456 fix(snapshot): paired drift-corruption fix — duplicate-install guard + boot-time mismatch (#161, #162) (#186)
* fix(snapshot): prevent drift corruption from duplicate installs + boot-time mismatch (#161, #162)

Closes #162 and #161 as the paired fix the issues describe — both touch
the same data-loss surface and verify against the same dev-sync rig.

installs at import time, log critical, register nothing in the
alphabetical loser; client-side `window` sentinel as belt-and-suspenders;
align .env.example / dev-iteration-loop / sync_to_dev / README at the
Registry-derived `koolook/` path.

compare against live /userdata + picks, set a drift flag on mismatch;
gate periodic + pre-load autosave routing to `_unsaved_autosave/` while
drifted; promote status pill to "drifted" precedence over "saved" with a
recovery tooltip; auto-clear on the next markStateSaved (Save / Load).

26 new tests (14 install guard, 4 sidebar sentinel, 8 boot drift) —
316 total passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(snapshot): apply review fixes — canonical fingerprint + early loser exit + drift timestamp

Review-driven follow-ups on PR #186:

1. **Symmetrize file-side fingerprint (MEDIUM).** `detectBootDrift`
   now uses a new `_canonicalStateFingerprint()` that normalizes the
   workflows tree through `normalizeWorkflowsStore` AND deep-sorts keys
   via `sortJsonValue`. Without this, hand-edited / cross-machine /
   pre-v0.3 files would false-positive as drifted on first boot
   (e.g. `archived: "false"` string vs `false` bool, missing
   `directories: {}`, key insertion-order asymmetry from the
   `{...wf, archived, module, tags}` spread). `_computeFingerprint`
   stays unchanged so the localStorage save baseline contract holds
   for existing users. New regression test verifies a shape-only diff
   does NOT trigger drift.

2. **Gate fork imports on `_is_winning_install` (LOW).** Non-winning
   duplicate installs now short-circuit before the heavy torch / PIL /
   diffusers / aiohttp imports, exiting in ~5 LoC instead of paying
   full import cost just to discard the mappings.

3. **Drift timestamp uses `driftDetectedAt`, not stale
   `lastNamedSaveAt` (LOW).** The localStorage save baseline could be
   from a prior session whose live state was already corrupt —
   surfacing it as "Last named save" in the drift tooltip would
   mislead. Replaced with the session-noticed-at timestamp from
   `_bootDriftDiagnostics`.

352 tests passing (316 prior + 26 from PR #186 + 1 new normalize-
symmetry case).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(install-guard): fail safe on unreadable / non-UTF-8 siblings (#162)

Re-review follow-up. The import-time duplicate-install scan could crash
the entire plugin load (fail-silent — the alphabetical winner vanished
too, not just the loser) on two unguarded paths:

- detect_duplicate_koolook_installs: the per-entry is_dir()/is_file()
  over custom_nodes/ siblings was unguarded, so a non-traversable
  sibling (restrictive perms, broken mount, a special dir) raised
  PermissionError up into __init__.py. No duplicate even required.
  Now caught per-entry and skipped.
- read_pyproject_version: read_text(encoding="utf-8") raises
  UnicodeDecodeError (a ValueError, NOT OSError) on a non-UTF-8 /
  binary pyproject.toml, escaping the `except OSError`. Now degrades
  to "?" via `except (OSError, ValueError)`.
- __init__.py: wrapped the guard call site as a final backstop — any
  unforeseen guard failure now degrades to register-normally rather
  than aborting the import.

Both paths reproduced before the fix; the first surfaced while running
the suite from a /tmp checkout whose parent held a root-owned dir.

3 regression tests added (non-traversable sibling skipped; non-UTF-8
pyproject -> "?"; report survives a non-UTF-8 sibling). Full suite:
452 passed (449 + 3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: kflame99 <261674183+kflame99@users.noreply.github.com>
2026-06-02 18:44:57 +02:00

540 lines
20 KiB
Python

"""Tests for the boot-time tracked-snapshot drift guard (#161).
Covers the four observable contracts the rest of the codebase depends on:
1. ``detectBootDrift()`` flags drift on file-vs-live mismatch.
2. ``detectBootDrift()`` does NOT flag drift when the named file and
live state agree.
3. ``getSnapshotStatus()`` returns ``state: "drifted"`` while the
flag is set — with precedence over the localStorage-baseline
``"saved"`` state.
4. ``markStateSaved()`` clears the drift flag — Save and Load both
route through it, so this is the single point that retires the
warning.
The pre-load autosave routing (``_autosaveSubdir`` returning
``_unsaved_autosave/`` while drifted) is covered as a 5th case via
``writePreLoadAutosave`` — calling that exercises the same private
helper the periodic timer uses.
All tests stub ``fetch`` so the snapshot module operates against an
in-memory "library" without touching the real network.
"""
from __future__ import annotations
import subprocess
import textwrap
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
def _run_node(source: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["node", "--input-type=module"],
input=source,
cwd=REPO_ROOT,
text=True,
capture_output=True,
check=False,
)
# Common browser-globals + fetch-stub preamble used by every test. Kept
# as a python f-string so each test can append its own scenario body.
_PREAMBLE = """
import assert from "node:assert/strict";
import {
detectBootDrift,
getSnapshotStatus,
markStateSaved,
clearBootDrift,
isBootDrifted,
setCurrentPresetName,
writePreLoadAutosave,
} from "./web/sidebar/snapshot.js";
import {
loadWorkflowsStore,
replaceAllWorkflows,
getAllWorkflowsForExport,
} from "./web/sidebar/workflows_store.js";
import { setAllPicks } from "./web/sidebar/picks_store.js";
setupBrowserStubs();
// In-memory "server" the fetch stub reads from / writes to. Tests
// arrange their scenario by mutating these before calling the SUT.
const SERVER = {
workflowsStore: { directories: {} },
presets: new Map(), // fileName (with .json) -> snapshot object
writes: [], // every POST captured for assertion
};
globalThis.fetch = async (url, init = {}) => {
const u = new URL(String(url), "http://localhost");
const pathOnly = u.pathname;
if (pathOnly.startsWith("/userdata/koolook_workflows.json")) {
if (init.method === "POST") {
SERVER.workflowsStore = JSON.parse(init.body);
return okJson("");
}
return okJson(JSON.stringify(SERVER.workflowsStore));
}
if (pathOnly === "/koolook/presets/file") {
const name = u.searchParams.get("name");
const dir = u.searchParams.get("dir") || "";
const key = (dir ? dir + "/" : "") + name;
if (init.method === "POST") {
SERVER.presets.set(key, JSON.parse(init.body));
SERVER.writes.push({ name, dir, body: JSON.parse(init.body) });
return okJson("");
}
if (init.method === "HEAD") {
return SERVER.presets.has(key)
? { ok: true, status: 200 }
: { ok: false, status: 404 };
}
const stored = SERVER.presets.get(key);
if (!stored) return { ok: false, status: 404, text: async () => "not found" };
return okJson(JSON.stringify(stored));
}
if (pathOnly === "/koolook/presets/info") {
return okJson(JSON.stringify({ path: "/tmp/koolook-presets", isDefault: true }));
}
return { ok: false, status: 404, text: async () => "", json: async () => ({}) };
};
function okJson(body) {
return {
ok: true,
status: 200,
text: async () => body,
json: async () => JSON.parse(body || "{}"),
};
}
function setupBrowserStubs() {
const storage = new Map();
globalThis.localStorage = {
getItem: (key) => storage.has(key) ? storage.get(key) : null,
setItem: (key, value) => storage.set(key, String(value)),
removeItem: (key) => storage.delete(key),
};
globalThis.CustomEvent = class CustomEvent { constructor(type) { this.type = type; } };
globalThis.window = { dispatchEvent() {}, addEventListener() {} };
Object.defineProperty(globalThis, "navigator", {
value: { clipboard: { writeText: async () => {} } },
configurable: true,
});
const makeEl = () => ({
className: "", textContent: "", style: {},
classList: { add() {}, remove() {} },
appendChild() {}, remove() {}, addEventListener() {},
setAttribute() {},
});
globalThis.document = {
createElement: makeEl,
body: { appendChild() {} },
head: { appendChild() {} },
querySelectorAll: () => [],
getElementById: () => null,
hidden: false,
};
}
"""
def _run(scenario_body: str) -> subprocess.CompletedProcess[str]:
return _run_node(_PREAMBLE + textwrap.dedent(scenario_body))
# =============================================================================
# 1. Drift detection on file-vs-live mismatch
# =============================================================================
def test_detect_drift_flags_mismatch() -> None:
"""The named snapshot file holds 2 workflows; the live store holds
a different 1-workflow shape. ``detectBootDrift()`` must flag this
as drift and console.warn with the diagnostics."""
result = _run(
"""
// Arrange: named file with workflows A + B.
SERVER.presets.set("Foo.json", {
kind: "koolook-snapshot",
version: 1,
name: "Foo",
exportedAt: "2026-05-29T10:00:00.000Z",
picks: ["EasyResize_Koolook"],
workflows: {
directories: {
"Renders": {
workflows: {
"A": { graph: { nodes: [{ id: 1 }] }, archived: false, module: false, tags: [] },
"B": { graph: { nodes: [{ id: 2 }] }, archived: false, module: false, tags: [] },
},
directories: {},
},
},
},
});
// Arrange: live store carries a single C workflow — clearly different.
SERVER.workflowsStore = {
directories: {
"Renders": {
workflows: {
"C": { graph: { nodes: [{ id: 3 }] }, archived: false, module: false, tags: [] },
},
directories: {},
},
},
};
await loadWorkflowsStore();
setAllPicks([]);
setCurrentPresetName("Foo");
// Act
const outcome = await detectBootDrift("Foo");
// Assert
assert.equal(outcome.drifted, true, "outcome should report drift");
assert.equal(outcome.trackedName, "Foo");
assert.equal(isBootDrifted(), true, "module flag should be set");
assert.ok(outcome.diagnostics.fileFingerprintBytes > 0);
assert.ok(outcome.diagnostics.liveFingerprintBytes > 0);
"""
)
assert result.returncode == 0, result.stderr
def test_detect_drift_skips_when_states_match() -> None:
"""Named file and live state are byte-equal in payload (modulo the
snapshot envelope) — no drift flagged."""
result = _run(
"""
const sharedStore = {
directories: {
"Renders": {
workflows: {
"A": { graph: { nodes: [{ id: 1 }] }, archived: false, module: false, tags: [] },
},
directories: {},
},
},
};
SERVER.workflowsStore = sharedStore;
SERVER.presets.set("Bar.json", {
kind: "koolook-snapshot",
version: 1,
name: "Bar",
exportedAt: "2026-05-29T11:00:00.000Z",
picks: [],
workflows: sharedStore,
});
await loadWorkflowsStore();
setAllPicks([]);
setCurrentPresetName("Bar");
const outcome = await detectBootDrift("Bar");
assert.equal(outcome.drifted, false);
assert.equal(isBootDrifted(), false);
"""
)
assert result.returncode == 0, result.stderr
def test_detect_drift_skips_when_named_file_missing() -> None:
"""Tracked preset name but no file on disk — that's a distinct
failure mode the regular Load dialog already surfaces. Don't flag
drift; let the localStorage fingerprint baseline drive status."""
result = _run(
"""
SERVER.workflowsStore = { directories: {} };
await loadWorkflowsStore();
setAllPicks([]);
setCurrentPresetName("Missing");
const outcome = await detectBootDrift("Missing");
// outcome is null on the "file not readable" path
assert.equal(outcome, null);
assert.equal(isBootDrifted(), false);
"""
)
assert result.returncode == 0, result.stderr
# =============================================================================
# 2. Status precedence — "drifted" wins over "saved"
# =============================================================================
def test_status_drifted_wins_over_saved_baseline() -> None:
"""Even when the persisted localStorage fingerprint matches the
current live state (which would otherwise report "saved"), the
drift flag promotes the status to "drifted". This is the core
precedence rule that protects against a stale-but-matching
fingerprint baseline."""
result = _run(
"""
SERVER.workflowsStore = { directories: {} };
await loadWorkflowsStore();
setAllPicks([]);
setCurrentPresetName("Baz");
// Baseline: live state matches itself, so markStateSaved persists
// a fingerprint that satisfies the "saved" check.
markStateSaved();
let status = getSnapshotStatus();
assert.equal(status.state, "saved", "baseline should report saved");
// Now arrange a divergent named file and run the drift check.
// markStateSaved was just called, so the fingerprint baseline
// matches the live state — but the named file on disk doesn't,
// and that's the authoritative comparison.
SERVER.presets.set("Baz.json", {
kind: "koolook-snapshot",
version: 1,
name: "Baz",
exportedAt: "2026-05-29T12:00:00.000Z",
picks: ["EasyResize_Koolook", "EasyAIPipeline"],
workflows: { directories: { "X": { workflows: { "Y": { graph: { nodes: [] }, archived: false, module: false, tags: [] } }, directories: {} } } },
});
const outcome = await detectBootDrift("Baz");
assert.equal(outcome.drifted, true);
status = getSnapshotStatus();
assert.equal(status.state, "drifted", "drift should win precedence over saved baseline");
assert.equal(status.name, "Baz");
"""
)
assert result.returncode == 0, result.stderr
# =============================================================================
# 3. markStateSaved clears the drift flag (Save/Load realignment)
# =============================================================================
def test_mark_state_saved_clears_drift_flag() -> None:
"""A successful Save/Load means the named file and live state are
realigned. ``markStateSaved()`` is the single point both paths call;
it must retire the drift warning so the pill returns to "saved"
without the user having to dismiss anything."""
result = _run(
"""
SERVER.workflowsStore = { directories: {} };
SERVER.presets.set("Qux.json", {
kind: "koolook-snapshot",
version: 1,
name: "Qux",
exportedAt: "2026-05-29T13:00:00.000Z",
picks: ["EasyResize_Koolook"],
workflows: { directories: { "X": { workflows: { "Y": { graph: { nodes: [] }, archived: false, module: false, tags: [] } }, directories: {} } } },
});
await loadWorkflowsStore();
setAllPicks([]);
setCurrentPresetName("Qux");
await detectBootDrift("Qux");
assert.equal(isBootDrifted(), true);
// User realigns by Saving — production path runs markStateSaved().
markStateSaved();
assert.equal(isBootDrifted(), false, "Save/Load must retire the drift warning");
const status = getSnapshotStatus();
assert.equal(status.state, "saved");
"""
)
assert result.returncode == 0, result.stderr
def test_clear_boot_drift_public_export_works() -> None:
"""The exported ``clearBootDrift()`` escape hatch (for direct
test-side reset) must flip the flag and refresh status."""
result = _run(
"""
SERVER.workflowsStore = { directories: {} };
SERVER.presets.set("Zap.json", {
kind: "koolook-snapshot",
version: 1,
name: "Zap",
exportedAt: "2026-05-29T14:00:00.000Z",
picks: ["EasyResize_Koolook"],
workflows: { directories: { "X": { workflows: { "Y": { graph: { nodes: [] }, archived: false, module: false, tags: [] } }, directories: {} } } },
});
await loadWorkflowsStore();
setAllPicks([]);
setCurrentPresetName("Zap");
await detectBootDrift("Zap");
assert.equal(isBootDrifted(), true);
clearBootDrift();
assert.equal(isBootDrifted(), false);
"""
)
assert result.returncode == 0, result.stderr
# =============================================================================
# 4. Pre-load autosave routes to _unsaved_autosave/ while drifted
# =============================================================================
def test_pre_load_autosave_redirects_to_unsaved_when_drifted() -> None:
"""The same ``_autosaveSubdir`` helper that controls the periodic
timer also controls pre-load autosaves. While drifted, both must
route to ``_unsaved_autosave/`` so the named preset's recovery
folder stays clean."""
result = _run(
"""
SERVER.workflowsStore = { directories: {} };
SERVER.presets.set("Drifty.json", {
kind: "koolook-snapshot",
version: 1,
name: "Drifty",
exportedAt: "2026-05-29T15:00:00.000Z",
picks: ["EasyResize_Koolook"],
workflows: { directories: { "X": { workflows: { "Y": { graph: { nodes: [] }, archived: false, module: false, tags: [] } }, directories: {} } } },
});
await loadWorkflowsStore();
setAllPicks([]);
setCurrentPresetName("Drifty");
await detectBootDrift("Drifty");
assert.equal(isBootDrifted(), true);
SERVER.writes.length = 0;
await writePreLoadAutosave("test-redirect");
const writtenDirs = SERVER.writes.map((w) => w.dir).filter(Boolean);
assert.ok(writtenDirs.length > 0, "pre-load autosave should write at least one file");
for (const d of writtenDirs) {
assert.equal(d, "_unsaved_autosave",
`pre-load autosave routed to "${d}" while drifted; expected _unsaved_autosave`);
}
"""
)
assert result.returncode == 0, result.stderr
def test_detect_drift_does_not_fire_on_normalize_only_shape_differences() -> None:
"""Cross-machine snapshot import + hand-edited files (the documented
NFS/SMB/Dropbox sync workflow this module supports) save fields in
shapes the live cache passes through ``normalizeWorkflowsStore`` —
``archived: "false"`` becomes ``archived: false``; missing
``directories: {}`` is filled in; non-string tag entries get dropped.
Without normalising the file side too, the first boot after a fresh
cross-machine import would false-positive as drift and route every
autosave to ``_unsaved_autosave/`` until the user re-saved.
This guards the symmetric-fingerprint contract: when the disk shape
and the live shape are equivalent *after* normalisation, no drift
is flagged.
"""
result = _run(
"""
// Live: post-normalize shape — strict bool, deduped tags, present
// directories: {}. This is what workflowsCache looks like the
// moment loadWorkflowsStore() returns.
SERVER.workflowsStore = {
directories: {
"Renders": {
workflows: {
"A": {
graph: { nodes: [{ id: 1 }] },
archived: false,
module: false,
tags: ["lit", "exterior"],
},
},
directories: {},
},
},
};
// File: pre-normalize shape — `archived` as a string literal,
// duplicate + whitespace-padded tag entries, missing
// `directories: {}` on the leaf node. Hand-edited / cross-
// machine import shape that should normalize to the live shape.
SERVER.presets.set("CrossMachine.json", {
kind: "koolook-snapshot",
version: 1,
name: "CrossMachine",
exportedAt: "2026-05-29T17:00:00.000Z",
picks: [],
workflows: {
directories: {
"Renders": {
workflows: {
"A": {
graph: { nodes: [{ id: 1 }] },
archived: "false",
tags: ["lit", " exterior", "exterior ", "lit"],
},
},
// directories field deliberately omitted — pre-v0.3
// shape that normalize fills with `{}`.
},
},
},
});
await loadWorkflowsStore();
setAllPicks([]);
setCurrentPresetName("CrossMachine");
const outcome = await detectBootDrift("CrossMachine");
assert.equal(outcome.drifted, false,
"shape differences that normalize to the same canonical form should NOT trigger drift");
assert.equal(isBootDrifted(), false);
"""
)
assert result.returncode == 0, result.stderr
def test_pre_load_autosave_uses_preset_subdir_when_not_drifted() -> None:
"""Sanity check the other branch: when drift is NOT set,
pre-load autosaves go to ``<preset>_autosave/`` per the regular
contract — the drift redirect is the only override."""
result = _run(
"""
const sharedStore = {
directories: {
"X": {
workflows: {
"Y": { graph: { nodes: [] }, archived: false, module: false, tags: [] },
},
directories: {},
},
},
};
SERVER.workflowsStore = sharedStore;
SERVER.presets.set("Clean.json", {
kind: "koolook-snapshot",
version: 1,
name: "Clean",
exportedAt: "2026-05-29T16:00:00.000Z",
picks: [],
workflows: sharedStore,
});
await loadWorkflowsStore();
setAllPicks([]);
setCurrentPresetName("Clean");
await detectBootDrift("Clean");
assert.equal(isBootDrifted(), false);
SERVER.writes.length = 0;
await writePreLoadAutosave("baseline");
const writtenDirs = SERVER.writes.map((w) => w.dir).filter(Boolean);
assert.ok(writtenDirs.length > 0);
for (const d of writtenDirs) {
assert.equal(d, "Clean_autosave",
`non-drifted autosave should route to Clean_autosave/, got "${d}"`);
}
"""
)
assert result.returncode == 0, result.stderr