agent: lumotia — Phase A.5 vitest scaffold + localStorageMigration unit tests

First frontend unit test framework on Lumotia. Pinned exact versions for
supply-chain hygiene (matches the rust-toolchain.toml discipline from the
27661c8 hygiene pass and the npm audit signatures pre-flight from e4d56b8):

  - vitest 4.1.6 (compatible with vite 6, supports vite 6/7/8)
  - jsdom 29.1.1

Installed with `npm install --save-dev --save-exact --ignore-scripts` per
the install discipline documented in the README — the --ignore-scripts
flag blocks the postinstall vector that npm worms (Shai-Hulud,
mini-Shai-Hulud) rely on.

vite.config.js:
  - Switched defineConfig import to vitest/config (superset of vite/config;
    production builds ignore the `test` key).
  - test.environment = "jsdom" so storage-shim tests drive real browser APIs.
  - test.include scoped to src/**/*.{test,spec}.{ts,js} — colocated with
    source, mirrors the Rust #[cfg(test)] sibling pattern.
  - test.exclude blocks src-tauri/ (owned by cargo test).
  - restoreMocks + clearMocks + unstubAllGlobals on so module-level state
    can't leak between tests.

src/lib/utils/localStorageMigration.test.ts — 12 tests:
  migrateLocalStorageKey:
    - copies value + removes old when only old exists
    - removes old + keeps new when both exist (lumotia is authoritative)
    - no-op when only new exists
    - no-op when neither exists
    - idempotent (second call after first migrates nothing)
    - preserves the value's exact bytes (no JSON round-trip)
    - preserves empty-string values (distinct from null)
    - survives DOMException / quota errors without re-raising
    - no-op when localStorage is undefined (SSR-safe)
  migrateLocalStorageKeys:
    - processes pairs in order
    - per-pair failure does not strand remaining pairs (resilience)
    - empty pairs list is a clean no-op

package.json:
  - "test": "vitest run" (one-shot, CI-friendly)
  - "test:watch": "vitest" (dev loop)

README: documents `npm run test` alongside `cargo test --workspace` and
`npm run check` in the Testing section.

Verification:
- npm run test: 12/12 pass
- npm run check: 0 errors, 0 warnings (the new .ts test type-checks clean
  against jsconfig.json's strict typescript settings)
This commit is contained in:
2026-05-14 07:29:57 +01:00
parent 18a64f5c56
commit 206ac6219d
5 changed files with 1138 additions and 4 deletions

View File

@@ -310,11 +310,17 @@ CI also builds release installers on tag push (see `.github/workflows/build.yml`
### Testing ### Testing
```bash ```bash
cargo test --workspace --lib # 220+ lib tests across 9 library crates cargo test --workspace # all Rust tests (lib + integration)
npm run check # svelte-check (type-checks .svelte files) npm run check # svelte-check (type-checks .svelte files)
npm run test # vitest run (frontend unit tests)
npm run test:watch # vitest watch mode
cargo check --workspace --all-targets cargo check --workspace --all-targets
``` ```
Frontend test files live alongside source (`src/**/*.test.ts`) and run in
jsdom by default. See [vite.config.js](vite.config.js) for the vitest
configuration.
--- ---
## Project documentation ## Project documentation

887
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,8 @@
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
"test": "vitest run",
"test:watch": "vitest",
"tauri": "tauri" "tauri": "tauri"
}, },
"license": "MIT", "license": "MIT",
@@ -31,10 +33,12 @@
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"jsdom": "29.1.1",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"typescript": "~5.6.2", "typescript": "~5.6.2",
"vite": "^6.4.2" "vite": "^6.4.2",
"vitest": "4.1.6"
} }
} }

View File

@@ -0,0 +1,217 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
migrateLocalStorageKey,
migrateLocalStorageKeys,
} from "./localStorageMigration";
// All tests run inside jsdom (see vite.config.js test.environment). The
// shared localStorage is the real `window.localStorage` jsdom provides;
// we wipe it between tests so one test's writes never leak forward.
beforeEach(() => {
// Unstub first in case a prior test left `localStorage` stubbed
// (e.g. the SSR-safe test that sets it to undefined). vitest's
// `unstubAllGlobals: true` runs AFTER each test's afterEach, which
// is too late if afterEach itself touches localStorage.
vi.unstubAllGlobals();
localStorage.clear();
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
localStorage.clear();
});
describe("migrateLocalStorageKey", () => {
it("copies the value and removes the old key when only the old key exists", () => {
localStorage.setItem("magnotia_settings", '{"theme":"dark"}');
migrateLocalStorageKey("magnotia_settings", "lumotia_settings");
expect(localStorage.getItem("lumotia_settings")).toBe('{"theme":"dark"}');
expect(localStorage.getItem("magnotia_settings")).toBeNull();
});
it("removes the old key and keeps the new key when both exist (lumotia wins)", () => {
// Contract: if the user has already used the app under the new
// identifier, the new (lumotia) value is authoritative. The legacy
// (magnotia) value is deleted to avoid resurrecting it on a future
// run if the new key were ever cleared.
localStorage.setItem("magnotia_settings", '{"theme":"light"}');
localStorage.setItem("lumotia_settings", '{"theme":"dark"}');
migrateLocalStorageKey("magnotia_settings", "lumotia_settings");
expect(localStorage.getItem("lumotia_settings")).toBe('{"theme":"dark"}');
expect(localStorage.getItem("magnotia_settings")).toBeNull();
});
it("is a no-op when only the new key exists", () => {
localStorage.setItem("lumotia_settings", '{"theme":"dark"}');
migrateLocalStorageKey("magnotia_settings", "lumotia_settings");
expect(localStorage.getItem("lumotia_settings")).toBe('{"theme":"dark"}');
expect(localStorage.getItem("magnotia_settings")).toBeNull();
expect(localStorage.length).toBe(1);
});
it("is a no-op when neither key exists", () => {
migrateLocalStorageKey("magnotia_settings", "lumotia_settings");
expect(localStorage.length).toBe(0);
});
it("is idempotent — second call after a first migration is a no-op", () => {
localStorage.setItem("magnotia_settings", '{"theme":"dark"}');
migrateLocalStorageKey("magnotia_settings", "lumotia_settings");
// Simulate the user editing the new key after the first boot.
localStorage.setItem("lumotia_settings", '{"theme":"system"}');
// Second boot: legacy is gone, new has user edits. Migration must
// not clobber the user edits.
migrateLocalStorageKey("magnotia_settings", "lumotia_settings");
expect(localStorage.getItem("lumotia_settings")).toBe('{"theme":"system"}');
expect(localStorage.getItem("magnotia_settings")).toBeNull();
});
it("preserves the value's exact bytes — no JSON round-tripping", () => {
// Some keys hold plain strings ("2026-05-12"), others hold JSON
// blobs, others hold base64-encoded protobufs. The shim must not
// parse + reserialise (which would lose insignificant whitespace,
// alter number precision, or normalise unicode forms). Round-trip
// a deliberately fragile string and require byte-equality.
const fragile = '{\n "n": 1.00000000001,\n "s":"\\u00e9caf\\u00e9"\n}';
localStorage.setItem("magnotia_settings", fragile);
migrateLocalStorageKey("magnotia_settings", "lumotia_settings");
expect(localStorage.getItem("lumotia_settings")).toBe(fragile);
});
it("preserves an empty-string value (distinct from null)", () => {
// Empty string is a valid stored value — distinct from "key
// absent". The shim must copy "" rather than treating it as a
// sentinel meaning "nothing to migrate".
localStorage.setItem("magnotia_settings", "");
migrateLocalStorageKey("magnotia_settings", "lumotia_settings");
expect(localStorage.getItem("lumotia_settings")).toBe("");
expect(localStorage.getItem("magnotia_settings")).toBeNull();
});
it("survives a thrown quota / DOMException without re-raising", () => {
// Quota-exceeded is the canonical failure mode for setItem in
// private browsing modes and on locked-down sandboxes. The shim
// is documented as silently swallowing errors to avoid console
// spam on every window in the multi-window flow.
localStorage.setItem("magnotia_settings", '{"theme":"dark"}');
const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
setItemSpy.mockImplementationOnce(() => {
throw new DOMException("Quota exceeded", "QuotaExceededError");
});
expect(() =>
migrateLocalStorageKey("magnotia_settings", "lumotia_settings"),
).not.toThrow();
// The error happens on the setItem(new, value) call. The old key
// is still on disk because removeItem wasn't reached. That's the
// documented "fall back to the old key on next read" behaviour.
expect(localStorage.getItem("magnotia_settings")).toBe('{"theme":"dark"}');
expect(localStorage.getItem("lumotia_settings")).toBeNull();
});
it("is a no-op when localStorage is undefined (SSR-safe)", () => {
// Inside SvelteKit's SSR path localStorage doesn't exist. The shim
// gates on `typeof localStorage === "undefined"` and must return
// without throwing. Use vi.stubGlobal so we don't have to redefine
// jsdom's localStorage property descriptor.
vi.stubGlobal("localStorage", undefined);
expect(() =>
migrateLocalStorageKey("magnotia_settings", "lumotia_settings"),
).not.toThrow();
});
});
describe("migrateLocalStorageKeys", () => {
it("processes all pairs in input order", () => {
localStorage.setItem("magnotia_a", "alpha");
localStorage.setItem("magnotia_b", "bravo");
localStorage.setItem("magnotia_c", "charlie");
migrateLocalStorageKeys([
["magnotia_a", "lumotia_a"],
["magnotia_b", "lumotia_b"],
["magnotia_c", "lumotia_c"],
]);
expect(localStorage.getItem("lumotia_a")).toBe("alpha");
expect(localStorage.getItem("lumotia_b")).toBe("bravo");
expect(localStorage.getItem("lumotia_c")).toBe("charlie");
expect(localStorage.getItem("magnotia_a")).toBeNull();
expect(localStorage.getItem("magnotia_b")).toBeNull();
expect(localStorage.getItem("magnotia_c")).toBeNull();
});
it("a failure on one pair does not prevent the remaining pairs from migrating", () => {
// Resilience contract: per-key migration must be independent so a
// quota error on the first key (e.g. a 5 MB legacy blob) doesn't
// strand the other keys on the old name forever.
localStorage.setItem("magnotia_a", "alpha");
localStorage.setItem("magnotia_b", "bravo");
localStorage.setItem("magnotia_c", "charlie");
// Capture the real setItem BEFORE spying so the mock can delegate
// to it for calls that should succeed.
const realSetItem = Storage.prototype.setItem;
let throwOnce = true;
vi.spyOn(Storage.prototype, "setItem").mockImplementation(function (
this: Storage,
key: string,
value: string,
) {
if (throwOnce) {
throwOnce = false;
throw new DOMException("Quota exceeded", "QuotaExceededError");
}
realSetItem.call(this, key, value);
});
expect(() =>
migrateLocalStorageKeys([
["magnotia_a", "lumotia_a"],
["magnotia_b", "lumotia_b"],
["magnotia_c", "lumotia_c"],
]),
).not.toThrow();
// First pair: setItem threw, so the legacy key remains and the new
// key was never written. The shim caught the DOMException per its
// documented "silently no-op" contract, leaving the data accessible
// under the old key on next read.
expect(localStorage.getItem("lumotia_a")).toBeNull();
expect(localStorage.getItem("magnotia_a")).toBe("alpha");
// Subsequent pairs migrated normally — the failure was per-pair,
// not a hard stop on the batch.
expect(localStorage.getItem("lumotia_b")).toBe("bravo");
expect(localStorage.getItem("magnotia_b")).toBeNull();
expect(localStorage.getItem("lumotia_c")).toBe("charlie");
expect(localStorage.getItem("magnotia_c")).toBeNull();
});
it("handles an empty pairs list cleanly", () => {
localStorage.setItem("magnotia_settings", '{"theme":"dark"}');
migrateLocalStorageKeys([]);
// No migration happened, no error thrown.
expect(localStorage.getItem("magnotia_settings")).toBe('{"theme":"dark"}');
});
});

View File

@@ -1,4 +1,7 @@
import { defineConfig } from "vite"; // Using vitest/config rather than vite/config so one config surface drives
// `vite dev` / `vite build` AND `vitest run`. Vitest's defineConfig is a
// superset of Vite's — production builds ignore the `test` key.
import { defineConfig } from "vitest/config";
import { sveltekit } from "@sveltejs/kit/vite"; import { sveltekit } from "@sveltejs/kit/vite";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
@@ -24,4 +27,23 @@ export default defineConfig(async () => ({
ignored: ["**/src-tauri/**"], ignored: ["**/src-tauri/**"],
}, },
}, },
test: {
// jsdom gives storage-shim tests a real localStorage / DOMException /
// window surface rather than hand-rolled mocks. Tests can override
// per-file with `// @vitest-environment node` if they need to.
environment: "jsdom",
// Discover *.test.{ts,js} colocated next to source. Mirrors Rust's
// #[cfg(test)] sibling convention; no separate tests/ tree.
include: ["src/**/*.{test,spec}.{ts,js}"],
// Frontend tests must never reach into the Tauri backend; that surface
// is owned by `cargo test` in src-tauri/ and crates/.
exclude: ["src-tauri/**", "node_modules/**", "build/**", ".svelte-kit/**"],
// Restore a clean global state between tests — vitest's restoreMocks
// unwinds vi.spyOn / vi.fn, clearMocks resets call history, and
// unstubAllGlobals undoes vi.stubGlobal. Prevents accidental leakage
// when tests share modules with module-level state.
restoreMocks: true,
clearMocks: true,
unstubAllGlobals: true,
},
})); }));