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 the27661c8hygiene pass and the npm audit signatures pre-flight frome4d56b8): - 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:
@@ -310,11 +310,17 @@ CI also builds release installers on tag push (see `.github/workflows/build.yml`
|
||||
### Testing
|
||||
|
||||
```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 test # vitest run (frontend unit tests)
|
||||
npm run test:watch # vitest watch mode
|
||||
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
|
||||
|
||||
887
package-lock.json
generated
887
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,8 @@
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"license": "MIT",
|
||||
@@ -31,10 +33,12 @@
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"jsdom": "29.1.1",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.4.2"
|
||||
"vite": "^6.4.2",
|
||||
"vitest": "4.1.6"
|
||||
}
|
||||
}
|
||||
|
||||
217
src/lib/utils/localStorageMigration.test.ts
Normal file
217
src/lib/utils/localStorageMigration.test.ts
Normal 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"}');
|
||||
});
|
||||
});
|
||||
@@ -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 tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
@@ -24,4 +27,23 @@ export default defineConfig(async () => ({
|
||||
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,
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user