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
|
### 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
887
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 { 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,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user