| """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, |
| ) |
|
|
|
|
| |
| |
| _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)) |
|
|
|
|
| |
| |
| |
|
|
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
|
|
| 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 |
|
|