WIP: M3 PWA/offline implementation, M4 auth (backend + frontend), D-013 faster-whisper

- M3: hand-rolled service worker (precache + runtime /ort caching), precache
  manifest injection, SW registration/update toast, manifest + generated icons,
  production build script, offline indicator in ModelPicker (D-005/D-007)
- M4 backend: bun:sqlite users/sessions (D-009), Passport local strategy behind
  connect-compat adapter (D-008), /api/auth routes wired into dev+prod serving,
  Bun.password Argon2id, 38 auth tests
- M4 frontend: AuthForms + useAuth + framework-free authClient, session-aware
  header, anonymous-first fail-silent offline handling, 18 client tests
- D-013: server engine (M5) will use faster-whisper via Python sidecar;
  browser side stays Transformers.js (CTranslate2 has no WASM build)
- bun test: 83 pass / tsc --noEmit clean
- Note: M3 doc closeout (WorkBreakdown/Architecture/tests/offline.md) still in
  flight from a background agent; follow-up commit will finish docs

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-07-10 04:02:28 +02:00
co-authored by Claude Fable 5
parent eb72a2e646
commit 9f39a82982
38 changed files with 3857 additions and 246 deletions
+178
View File
@@ -0,0 +1,178 @@
import { describe, test, expect } from "bun:test";
import {
fetchCurrentUser,
login,
logout,
register,
validatePassword,
validateUsername,
type FetchLike,
} from "../src/lib/authClient";
// The auth client is framework-free with an injectable fetch, so we can test
// validation, the request contract, and the fail-silent /me behaviour without
// a DOM or a running server (the M4 backend is exercised separately).
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
/** Mock fetch returning a fixed response and recording the call. */
function mockFetch(response: Response | Error): {
fetch: FetchLike;
calls: { input: string; init?: RequestInit }[];
} {
const calls: { input: string; init?: RequestInit }[] = [];
const fetch: FetchLike = (input, init) => {
calls.push({ input, init });
return response instanceof Error
? Promise.reject(response)
: Promise.resolve(response);
};
return { fetch, calls };
}
const USER = { id: 1, username: "alice" };
describe("validateUsername", () => {
test("accepts the allowed charset within 3–32 chars", () => {
expect(validateUsername("abc")).toBeNull(); // lower bound
expect(validateUsername("a".repeat(32))).toBeNull(); // upper bound
expect(validateUsername("User_1.name-x")).toBeNull();
});
test("rejects usernames that are too short or too long", () => {
expect(validateUsername("")).not.toBeNull();
expect(validateUsername("ab")).not.toBeNull();
expect(validateUsername("a".repeat(33))).not.toBeNull();
});
test("rejects characters outside [a-zA-Z0-9_.-]", () => {
expect(validateUsername("has space")).not.toBeNull();
expect(validateUsername("email@host")).not.toBeNull();
expect(validateUsername("émile")).not.toBeNull();
});
});
describe("validatePassword", () => {
test("requires at least 8 characters", () => {
expect(validatePassword("1234567")).not.toBeNull();
expect(validatePassword("12345678")).toBeNull();
});
});
describe("fetchCurrentUser (fail-silent /me)", () => {
test("returns the user on 200", async () => {
const { fetch, calls } = mockFetch(jsonResponse(200, { user: USER }));
expect(await fetchCurrentUser(fetch)).toEqual(USER);
// The cookie is HttpOnly — it must ride along via same-origin credentials.
expect(calls[0]?.input).toBe("/api/auth/me");
expect(calls[0]?.init?.credentials).toBe("same-origin");
});
test("resolves to anonymous (null) on 401", async () => {
const { fetch } = mockFetch(jsonResponse(401, { error: "unauthorized" }));
expect(await fetchCurrentUser(fetch)).toBeNull();
});
test("resolves to anonymous on network failure (offline-first)", async () => {
// /api/* is network-only in the service worker, so offline /me rejects.
// That must NOT throw or surface an error — just stay anonymous.
const { fetch } = mockFetch(new TypeError("Failed to fetch"));
expect(await fetchCurrentUser(fetch)).toBeNull();
});
test("resolves to anonymous on a malformed body", async () => {
const { fetch } = mockFetch(
new Response("<html>proxy error</html>", { status: 200 }),
);
expect(await fetchCurrentUser(fetch)).toBeNull();
});
});
describe("login", () => {
test("POSTs JSON credentials and returns the user on 200", async () => {
const { fetch, calls } = mockFetch(jsonResponse(200, { user: USER }));
expect(await login("alice", "hunter22", fetch)).toEqual(USER);
const call = calls[0];
expect(call?.input).toBe("/api/auth/login");
expect(call?.init?.method).toBe("POST");
expect(call?.init?.credentials).toBe("same-origin");
expect(
(call?.init?.headers as Record<string, string>)["Content-Type"],
).toBe("application/json");
expect(JSON.parse(call?.init?.body as string)).toEqual({
username: "alice",
password: "hunter22",
});
});
test("surfaces the server's error message on 401", async () => {
const { fetch } = mockFetch(
jsonResponse(401, { error: "invalid credentials" }),
);
expect(login("alice", "wrong-pass", fetch)).rejects.toThrow(
"invalid credentials",
);
});
test("throws a UI-safe message on network failure", async () => {
const { fetch } = mockFetch(new TypeError("Failed to fetch"));
expect(login("alice", "hunter22", fetch)).rejects.toThrow(
/could not reach the server/i,
);
});
test("throws on an unexpected success body", async () => {
const { fetch } = mockFetch(jsonResponse(200, { nope: true }));
expect(login("alice", "hunter22", fetch)).rejects.toThrow(
/unexpected response/i,
);
});
});
describe("register", () => {
test("returns the user on 201", async () => {
const { fetch, calls } = mockFetch(jsonResponse(201, { user: USER }));
expect(await register("alice", "hunter22", fetch)).toEqual(USER);
expect(calls[0]?.input).toBe("/api/auth/register");
});
test("surfaces {error} on 409 (username taken)", async () => {
const { fetch } = mockFetch(jsonResponse(409, { error: "username taken" }));
expect(register("alice", "hunter22", fetch)).rejects.toThrow(
"username taken",
);
});
test("surfaces {error} on 400 (server-side validation)", async () => {
const { fetch } = mockFetch(jsonResponse(400, { error: "invalid input" }));
expect(register("a", "short", fetch)).rejects.toThrow("invalid input");
});
test("falls back to a generic message when the error body is not JSON", async () => {
const { fetch } = mockFetch(new Response("boom", { status: 500 }));
expect(register("alice", "hunter22", fetch)).rejects.toThrow(
"Request failed (500).",
);
});
});
describe("logout", () => {
test("POSTs to /api/auth/logout with credentials", async () => {
const { fetch, calls } = mockFetch(new Response(null, { status: 204 }));
await logout(fetch);
expect(calls[0]?.input).toBe("/api/auth/logout");
expect(calls[0]?.init?.method).toBe("POST");
expect(calls[0]?.init?.credentials).toBe("same-origin");
});
test("resolves even when the server is unreachable", async () => {
const { fetch } = mockFetch(new TypeError("Failed to fetch"));
await logout(fetch); // must not throw — local state is cleared regardless
});
});
+217
View File
@@ -0,0 +1,217 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { createDb, type AppDb } from "../src/server/db";
import { createAuth, type AuthHandlers } from "../src/server/auth";
/**
* M4 auth tests (WorkBreakdown M4): exercised through the exported handlers
* against an in-memory bun:sqlite database — hermetic, no server socket, and
* the on-disk default (./data/app.db) is never touched because createAuth
* takes an injected database getter.
*/
let db: AppDb;
let auth: AuthHandlers;
beforeAll(() => {
db = createDb(":memory:");
auth = createAuth(() => db);
});
afterAll(() => db.close());
// --- Request helpers --------------------------------------------------------
const BASE = "http://localhost";
function postJson(path: string, body: unknown, cookie?: string): Request {
return new Request(`${BASE}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(cookie ? { cookie } : {}),
},
body: JSON.stringify(body),
});
}
function get(path: string, cookie?: string): Request {
return new Request(`${BASE}${path}`, {
headers: cookie ? { cookie } : {},
});
}
/** Extracts "sid=<value>" from a Set-Cookie header for replay in later requests. */
function sidCookie(response: Response): string {
const header = response.headers.get("set-cookie");
expect(header).toBeTruthy();
const match = /(?:^|;\s*)sid=([^;]+)/.exec(header!);
expect(match).toBeTruthy();
return `sid=${match![1]}`;
}
async function registerUser(username: string, password = "hunter2secret") {
const response = await auth.register(postJson("/api/auth/register", { username, password }));
expect(response.status).toBe(201);
return response;
}
// --- Register ----------------------------------------------------------------
describe("POST /api/auth/register", () => {
test("201 with user body and session cookie", async () => {
const response = await registerUser("alice");
expect(response.headers.get("content-type")).toStartWith("application/json");
const body = await response.json();
expect(body.user.username).toBe("alice");
expect(typeof body.user.id).toBe("number");
// Password material must never leak into the response.
expect(JSON.stringify(body)).not.toContain("hash");
const cookie = response.headers.get("set-cookie")!;
expect(cookie).toContain("sid=");
expect(cookie).toContain("HttpOnly");
expect(cookie).toContain("SameSite=Lax");
expect(cookie).toContain("Path=/");
expect(cookie).toContain("Max-Age=604800");
// Not production in tests, so no Secure attribute.
expect(cookie).not.toContain("Secure");
});
test("password is stored as an Argon2 hash, not plaintext", () => {
const user = db.findUserByUsername("alice");
expect(user).not.toBeNull();
expect(user!.passwordHash).toStartWith("$argon2");
});
test("409 for a duplicate username", async () => {
const response = await auth.register(
postJson("/api/auth/register", { username: "alice", password: "hunter2secret" }),
);
expect(response.status).toBe(409);
expect((await response.json()).error).toBeString();
});
test.each([
["short username", { username: "ab", password: "hunter2secret" }],
["long username", { username: "x".repeat(33), password: "hunter2secret" }],
["bad username characters", { username: "not ok!", password: "hunter2secret" }],
["short password", { username: "bob", password: "1234567" }],
["missing password", { username: "bob" }],
["non-string fields", { username: 42, password: 12345678 }],
])("400 for %s", async (_label, body) => {
const response = await auth.register(postJson("/api/auth/register", body));
expect(response.status).toBe(400);
expect((await response.json()).error).toBeString();
});
test("400 for a non-JSON body", async () => {
const response = await auth.register(
new Request(`${BASE}/api/auth/register`, { method: "POST", body: "not json" }),
);
expect(response.status).toBe(400);
});
});
// --- Login --------------------------------------------------------------------
describe("POST /api/auth/login", () => {
test("200 with user body and fresh session cookie", async () => {
const response = await auth.login(
postJson("/api/auth/login", { username: "alice", password: "hunter2secret" }),
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toStartWith("application/json");
expect((await response.json()).user.username).toBe("alice");
expect(sidCookie(response)).toStartWith("sid=");
});
test("401 with the same message for wrong password and unknown user", async () => {
const wrongPassword = await auth.login(
postJson("/api/auth/login", { username: "alice", password: "wrong-password" }),
);
const unknownUser = await auth.login(
postJson("/api/auth/login", { username: "nobody", password: "hunter2secret" }),
);
expect(wrongPassword.status).toBe(401);
expect(unknownUser.status).toBe(401);
// Identical bodies: responses must not reveal which part was wrong.
expect(await wrongPassword.json()).toEqual({ error: "invalid credentials" });
expect(await unknownUser.json()).toEqual({ error: "invalid credentials" });
});
});
// --- Session lifecycle (/me, logout, expiry) -----------------------------------
describe("GET /api/auth/me + logout + expiry", () => {
test("200 with a valid session cookie", async () => {
const registered = await registerUser("carol");
const cookie = sidCookie(registered);
const response = await auth.me(get("/api/auth/me", cookie));
expect(response.status).toBe(200);
expect((await response.json()).user.username).toBe("carol");
});
test("401 without a cookie", async () => {
const response = await auth.me(get("/api/auth/me"));
expect(response.status).toBe(401);
expect((await response.json()).error).toBeString();
});
test("401 with a garbage session id", async () => {
const response = await auth.me(get("/api/auth/me", "sid=definitely-not-a-session"));
expect(response.status).toBe(401);
});
test("logout deletes the session and clears the cookie; /me then 401s", async () => {
const registered = await registerUser("dave");
const cookie = sidCookie(registered);
expect((await auth.me(get("/api/auth/me", cookie))).status).toBe(200);
const logout = await auth.logout(postJson("/api/auth/logout", {}, cookie));
expect(logout.status).toBe(204);
expect(logout.headers.get("set-cookie")).toContain("Max-Age=0");
// The session row is gone server-side, so replaying the old cookie fails.
const me = await auth.me(get("/api/auth/me", cookie));
expect(me.status).toBe(401);
});
test("logout without a session is still 204 (idempotent)", async () => {
const response = await auth.logout(postJson("/api/auth/logout", {}));
expect(response.status).toBe(204);
});
test("expired sessions are rejected server-side", async () => {
await registerUser("erin");
const user = db.findUserByUsername("erin")!;
// Forge a session that expired a minute ago, exactly as a stale row would
// look — the browser would happily still send this cookie.
const expired = db.createSession(user.id, Date.now() - 60_000);
const response = await auth.me(get("/api/auth/me", `sid=${expired.id}`));
expect(response.status).toBe(401);
});
test("deleteExpiredSessions sweeps stale rows and keeps live ones", async () => {
const user = db.findUserByUsername("erin")!;
// Live first: createSession itself sweeps expired rows opportunistically,
// so creating the stale row last keeps it alive until the explicit sweep.
const live = db.createSession(user.id, Date.now() + 60_000);
const stale = db.createSession(user.id, Date.now() - 1);
expect(db.deleteExpiredSessions()).toBeGreaterThanOrEqual(1);
expect(db.findValidSession(stale.id)).toBeNull();
expect(db.findValidSession(live.id)).not.toBeNull();
});
});
// --- getSessionUser (reused by the M5 transcribe guard) ------------------------
describe("getSessionUser", () => {
test("returns {id, username} for a valid cookie and null otherwise", async () => {
const registered = await registerUser("frank");
const cookie = sidCookie(registered);
const user = auth.getSessionUser(get("/anything", cookie));
expect(user).toEqual({ id: expect.any(Number), username: "frank" });
expect(auth.getSessionUser(get("/anything"))).toBeNull();
expect(auth.getSessionUser(get("/anything", "sid=nope"))).toBeNull();
});
});
+49
View File
@@ -0,0 +1,49 @@
import { describe, test, expect } from "bun:test";
import { matchCachedModels } from "../src/lib/modelCache";
// Pure URL matching (D-005 downloaded badge); the `caches` wrapper is
// browser-only and exercised manually.
const TINY = "onnx-community/whisper-tiny.en";
const BASE = "onnx-community/whisper-base";
function hubUrl(modelId: string, file: string): string {
return `https://huggingface.co/${modelId}/resolve/main/${file}`;
}
describe("matchCachedModels", () => {
test("matches models with cached .onnx weights", () => {
const urls = [
hubUrl(TINY, "onnx/encoder_model_quantized.onnx"),
hubUrl(TINY, "onnx/decoder_model_merged_quantized.onnx"),
hubUrl(TINY, "tokenizer.json"),
];
expect(matchCachedModels(urls, [TINY, BASE])).toEqual(new Set([TINY]));
});
test("configs/tokenizer alone do not count as downloaded", () => {
const urls = [hubUrl(BASE, "tokenizer.json"), hubUrl(BASE, "config.json")];
expect(matchCachedModels(urls, [TINY, BASE])).toEqual(new Set());
});
test("ignores query strings when checking the .onnx extension", () => {
const urls = [`${hubUrl(BASE, "onnx/encoder_model_q4.onnx")}?download=true`];
expect(matchCachedModels(urls, [BASE])).toEqual(new Set([BASE]));
});
test("does not confuse '.en' variants with their multilingual sibling", () => {
// "onnx-community/whisper-tiny.en/..." contains the substring
// "whisper-tiny" but must only match the .en id thanks to the trailing /.
const urls = [hubUrl(TINY, "onnx/encoder_model_quantized.onnx")];
expect(
matchCachedModels(urls, [TINY, "onnx-community/whisper-tiny"]),
).toEqual(new Set([TINY]));
});
test("empty inputs yield an empty set", () => {
expect(matchCachedModels([], [TINY])).toEqual(new Set());
expect(matchCachedModels([hubUrl(TINY, "onnx/encoder_model.onnx")], [])).toEqual(
new Set(),
);
});
});
+143
View File
@@ -0,0 +1,143 @@
import { describe, test, expect } from "bun:test";
import {
DEFAULT_MODEL_ID,
MODELS,
WHISPER_LANGUAGES,
estimateDefaultDownloadBytes,
estimateDownloadBytes,
formatModelSize,
getModel,
resolveDefaultDtype,
} from "../src/lib/models";
// Pure catalog data (D-006) — no browser, no network.
describe("MODELS catalog", () => {
test("contains the curated D-006 lineup from the onnx-community org", () => {
const ids = MODELS.map((model) => model.id);
expect(ids).toEqual([
"onnx-community/whisper-tiny.en",
"onnx-community/whisper-tiny",
"onnx-community/whisper-base.en",
"onnx-community/whisper-base",
"onnx-community/whisper-small.en",
"onnx-community/whisper-small",
"onnx-community/whisper-medium.en",
"onnx-community/whisper-medium",
"onnx-community/whisper-large-v3-turbo",
]);
expect(new Set(ids).size).toBe(ids.length);
});
test(".en variants are English-only; the rest are multilingual", () => {
for (const model of MODELS) {
expect(model.multilingual).toBe(!model.id.endsWith(".en"));
}
});
test("every model has dtype defaults with notes for both devices", () => {
for (const model of MODELS) {
for (const device of ["wasm", "webgpu"] as const) {
const entry = model.defaults[device];
expect(entry.dtype).toBeDefined();
expect(entry.note.length).toBeGreaterThan(0);
expect(typeof entry.verified).toBe("boolean");
}
}
});
test("only the M1-tested combination is marked verified", () => {
const verified = MODELS.flatMap((model) =>
(["wasm", "webgpu"] as const)
.filter((device) => model.defaults[device].verified)
.map((device) => `${model.id}@${device}`),
);
expect(verified).toEqual(["onnx-community/whisper-tiny.en@wasm"]);
});
test("the default model exists in the catalog", () => {
expect(getModel(DEFAULT_MODEL_ID)).toBeDefined();
expect(getModel("onnx-community/whisper-nonexistent")).toBeUndefined();
});
});
describe("resolveDefaultDtype", () => {
test("WASM defaults: q8 for small models, q4 for large ones", () => {
expect(resolveDefaultDtype("onnx-community/whisper-tiny.en", "wasm")).toBe("q8");
expect(resolveDefaultDtype("onnx-community/whisper-small", "wasm")).toBe("q8");
expect(resolveDefaultDtype("onnx-community/whisper-medium", "wasm")).toBe("q4");
expect(resolveDefaultDtype("onnx-community/whisper-large-v3-turbo", "wasm")).toBe("q4");
});
test("WebGPU defaults: fp32 for tiny/base (fp16 known-bad), fp16+q4 above", () => {
expect(resolveDefaultDtype("onnx-community/whisper-tiny", "webgpu")).toEqual({
encoder_model: "fp32",
decoder_model_merged: "fp32",
});
expect(resolveDefaultDtype("onnx-community/whisper-base.en", "webgpu")).toEqual({
encoder_model: "fp32",
decoder_model_merged: "fp32",
});
expect(
resolveDefaultDtype("onnx-community/whisper-large-v3-turbo", "webgpu"),
).toEqual({ encoder_model: "fp16", decoder_model_merged: "q4" });
});
test("unknown model ids fall back to q8", () => {
expect(resolveDefaultDtype("someone/some-model", "wasm")).toBe("q8");
});
});
describe("size estimates", () => {
const tiny = getModel("onnx-community/whisper-tiny.en")!;
const turbo = getModel("onnx-community/whisper-large-v3-turbo")!;
test("scale with quantization level", () => {
const fp32 = estimateDownloadBytes(tiny, "fp32");
const fp16 = estimateDownloadBytes(tiny, "fp16");
const q8 = estimateDownloadBytes(tiny, "q8");
const q4 = estimateDownloadBytes(tiny, "q4");
expect(fp32).toBeGreaterThan(fp16);
expect(fp16).toBeGreaterThan(q8);
expect(q8).toBeGreaterThan(q4);
});
test("per-part dtypes are priced per part", () => {
const mixed = estimateDownloadBytes(turbo, {
encoder_model: "fp16",
decoder_model_merged: "q4",
});
expect(mixed).toBeLessThan(estimateDownloadBytes(turbo, "fp16"));
expect(mixed).toBeGreaterThan(estimateDownloadBytes(turbo, "q4"));
});
test("default-dtype estimates are in a plausible range", () => {
// tiny.en on WASM (q8, ~39M params) — tens of MB.
const tinyWasm = estimateDefaultDownloadBytes(tiny, "wasm");
expect(tinyWasm).toBeGreaterThan(20e6);
expect(tinyWasm).toBeLessThan(100e6);
// large-v3-turbo on WebGPU (fp16 encoder + q4 decoder) — around 1.4 GB.
const turboWebgpu = estimateDefaultDownloadBytes(turbo, "webgpu");
expect(turboWebgpu).toBeGreaterThan(1e9);
expect(turboWebgpu).toBeLessThan(2e9);
});
test("formatModelSize renders MB below 1 GB and GB above", () => {
expect(formatModelSize(42_000_000)).toBe("42 MB");
expect(formatModelSize(1_400_000_000)).toBe("1.4 GB");
});
});
describe("WHISPER_LANGUAGES", () => {
test("codes are unique, lowercase, and include common languages", () => {
const codes = WHISPER_LANGUAGES.map((entry) => entry.code);
expect(new Set(codes).size).toBe(codes.length);
for (const code of codes) expect(code).toMatch(/^[a-z]{2,3}$/);
expect(codes).toContain("en");
expect(codes).toContain("da");
});
test("does not contain the auto-detect sentinel (UI adds it)", () => {
expect(WHISPER_LANGUAGES.some((entry) => entry.code === "auto")).toBe(false);
});
});
+91
View File
@@ -0,0 +1,91 @@
# Manual offline verification (M3)
The service worker, precache injection, and production serving are covered by
unit tests and curl smoke tests; actual offline behavior (SW lifecycle, Cache
Storage, airplane mode) can only be exercised in a real browser. This is that
procedure. Re-run it after any change to `src/pwa/*`, `scripts/build.ts`, or
the serve paths in `src/index.ts`.
## Prerequisites
- Chrome or Edge (WebGPU + full PWA support; Firefox works for the WASM/offline
parts but not WebGPU as of mid-2026).
- Production build served locally:
```sh
bun run build
bun run start # NODE_ENV=production bun src/index.ts → http://localhost:3000
```
Service workers require a secure context; `http://localhost` qualifies.
## 1. First visit — install + precache
1. Open `http://localhost:3000/` in a fresh profile (or after
DevTools → Application → Storage → "Clear site data").
2. DevTools → Application → Service Workers: `/sw.js` should be **activated and
running**. No reload prompt should appear on this first install
(`register.ts` only reloads after the user accepts the update toast).
3. Application → Cache Storage: expect
- `whisper-shell-<version>` — the precached app shell: `/`, the hashed
`chunk-*.js` / `chunk-*.css`, `/inference.worker.js`,
`/manifest.webmanifest` (+ a hashed copy), `/icons/icon-192.png`,
`/icons/icon-512.png` (+ a hashed 192 copy). The `<version>` matches the
"cache version" printed by `bun run build`.
- `whisper-runtime-<version>` appears only after step 2 below (it holds
`/ort/*` fetched during model load).
4. The ModelPicker status line should read "The app is cached for offline use.
Load a model once to make it available offline too."
5. Installability: the browser should offer an install icon in the address bar
(manifest + SW + icons). Optional: install and confirm standalone display.
## 2. Load a model once (online)
1. Select a model (tiny.en is fastest) and let it load fully.
2. Application → Cache Storage now also contains:
- `transformers-cache` — Whisper weights (`.onnx`, config/tokenizer JSON),
written by Transformers.js itself, never touched by our SW (D-005).
- `whisper-runtime-<version>` — `/ort/ort-wasm-simd-threaded*.{wasm,mjs}`
(onnxruntime-web loads one variant per backend, e.g. the `.jsep` build
for WebGPU; the worker points `wasmPaths` at `/ort/` so nothing hits the
jsdelivr CDN).
3. The model now shows the **"available offline"** badge in ModelPicker, and
the status line switches to "Offline ready — …".
## 3. Go offline and transcribe
1. DevTools → Network → throttling **Offline** (or OS airplane mode — stronger,
also proves nothing relies on localhost). Optionally also stop
`bun run start` to prove the app survives our server being down.
2. Reload the page. The app shell must render normally (served from
`whisper-shell-*`, cache-first). Also try a deep link like
`http://localhost:3000/foo` — the SW falls back to the cached shell for
navigations.
3. Load the previously-downloaded model. It must become ready with **no
network** (weights from `transformers-cache`, ort runtime from
`whisper-runtime-*`). The DevTools Network tab should show every request
answered by the service worker / cache, zero failed fetches escaping to the
network.
4. Drop a local audio file and confirm a complete transcription offline.
5. Still offline, confirm a **not**-downloaded model fails gracefully (error
surfaced, app usable) rather than hanging.
## 4. Update flow (toast)
1. Back online, with the old tab still open: make any visible change (e.g.
edit a string in `src/App.tsx`), then `bun run build` and restart
`bun run start`. The cache version in `sw.js` changes.
2. Reload the tab once so the browser re-checks `/sw.js` (served
`Cache-Control: no-cache`). When the new SW reaches "installed/waiting",
the toast "A new version of Whisper Web is available." appears.
3. "Later" dismisses it; the old version keeps running.
4. "Reload" posts `SKIP_WAITING`, the new SW activates, and the page reloads
itself once. After activation, Cache Storage must contain only the **new**
`whisper-shell-*` / `whisper-runtime-*` pair — old versions deleted —
while `transformers-cache` is left intact (model still "available
offline").
## Recording the result
When this passes in a real browser, flip the `[~]` offline-verification item
in `WorkBreakdown.md` M3 to `[x]` and note browser + version here.
- [ ] Verified on: _browser/version, date_
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, test } from "bun:test";
import {
injectPrecacheManifest,
PRECACHE_PLACEHOLDER,
precachePathsFromOutputs,
VERSION_PLACEHOLDER,
} from "../src/pwa/precache";
describe("precachePathsFromOutputs", () => {
test("maps index.html to /, prefixes assets, drops sourcemaps", () => {
expect(
precachePathsFromOutputs(
["index.html", "chunk-abc123.js", "chunk-abc123.js.map", "./index-def456.css"],
["/inference.worker.js"],
),
).toEqual(["/", "/chunk-abc123.js", "/index-def456.css", "/inference.worker.js"]);
});
test("deduplicates and sorts", () => {
expect(precachePathsFromOutputs(["a.js", "a.js"], ["/a.js", "/"])).toEqual([
"/",
"/a.js",
]);
});
});
describe("injectPrecacheManifest", () => {
const swSource = `const PRECACHE = JSON.parse(${PRECACHE_PLACEHOLDER});\nconst CACHE = "shell-${VERSION_PLACEHOLDER}";`;
test("injects a JSON-parseable asset list and the version", () => {
const result = injectPrecacheManifest(swSource, ["/", "/chunk-abc.js"], "v1abc");
expect(result).toContain('"shell-v1abc"');
expect(result).not.toContain("__PRECACHE_MANIFEST__");
// The injected literal must survive a JS string round-trip back to the array.
const literal = /JSON\.parse\((".*")\)/.exec(result)?.[1];
expect(literal).toBeDefined();
expect(JSON.parse(JSON.parse(literal!))).toEqual(["/", "/chunk-abc.js"]);
});
test("throws if a placeholder is missing (bundler mangled the source)", () => {
expect(() => injectPrecacheManifest("const x = 1;", [], "v1")).toThrow(
/missing/,
);
expect(() =>
injectPrecacheManifest(`JSON.parse(${PRECACHE_PLACEHOLDER})`, [], "v1"),
).toThrow(/missing/);
});
test("rejects versions that would break the cache name", () => {
expect(() => injectPrecacheManifest(swSource, [], "bad version!")).toThrow(
/Invalid cache version/,
);
});
test("the real sw.ts source contains both placeholders", async () => {
const source = await Bun.file(
new URL("../src/pwa/sw.ts", import.meta.url).pathname,
).text();
expect(source).toContain(PRECACHE_PLACEHOLDER);
expect(source).toContain(VERSION_PLACEHOLDER);
});
});
+44 -14
View File
@@ -13,17 +13,26 @@ import {
describe("isUiToWorkerMessage", () => {
test("accepts valid load and transcribe messages", () => {
const load: UiToWorkerMessage = {
type: "load",
modelId: "onnx-community/whisper-tiny.en",
device: "wasm",
};
const transcribe: UiToWorkerMessage = {
type: "transcribe",
audio: new Float32Array(8),
};
expect(isUiToWorkerMessage(load)).toBe(true);
expect(isUiToWorkerMessage(transcribe)).toBe(true);
const loads: UiToWorkerMessage[] = [
{ type: "load", modelId: "onnx-community/whisper-tiny.en", device: "wasm" },
{ type: "load", modelId: "onnx-community/whisper-tiny", device: "webgpu" },
{ type: "load", modelId: "onnx-community/whisper-base", device: "auto" },
{ type: "load", modelId: "onnx-community/whisper-base", device: "auto", dtype: "q4" },
{
type: "load",
modelId: "onnx-community/whisper-large-v3-turbo",
device: "webgpu",
dtype: { encoder_model: "fp16", decoder_model_merged: "q4" },
},
];
const transcribes: UiToWorkerMessage[] = [
{ type: "transcribe", audio: new Float32Array(8) },
{ type: "transcribe", audio: new Float32Array(8), language: "da", task: "transcribe" },
{ type: "transcribe", audio: new Float32Array(8), language: "auto", task: "translate" },
];
for (const message of [...loads, ...transcribes]) {
expect(isUiToWorkerMessage(message)).toBe(true);
}
});
test("rejects malformed messages", () => {
@@ -31,7 +40,19 @@ describe("isUiToWorkerMessage", () => {
expect(isUiToWorkerMessage("load")).toBe(false);
expect(isUiToWorkerMessage({ type: "load" })).toBe(false);
expect(isUiToWorkerMessage({ type: "load", modelId: "x", device: "cuda" })).toBe(false);
expect(isUiToWorkerMessage({ type: "load", modelId: "x", device: "auto", dtype: "int3" })).toBe(false);
expect(
isUiToWorkerMessage({
type: "load",
modelId: "x",
device: "auto",
dtype: { encoder_model: "fp16" },
}),
).toBe(false);
expect(isUiToWorkerMessage({ type: "transcribe", audio: [1, 2, 3] })).toBe(false);
expect(
isUiToWorkerMessage({ type: "transcribe", audio: new Float32Array(4), task: "detect" }),
).toBe(false);
expect(isUiToWorkerMessage({ type: "result", text: "", chunks: [] })).toBe(false);
});
});
@@ -50,7 +71,8 @@ describe("isWorkerToUiMessage", () => {
progress: 10,
},
{ type: "load-progress", status: "done", file: "model.onnx" },
{ type: "load-progress", status: "ready" },
{ type: "load-progress", status: "ready", device: "wasm" },
{ type: "load-progress", status: "ready", device: "webgpu" },
{ type: "partial", text: " hello" },
{ type: "result", text: "hello world", chunks: [{ text: "hello world", timestamp: [0, 1.2] }] },
{ type: "error", stage: "load", message: "boom" },
@@ -64,6 +86,8 @@ describe("isWorkerToUiMessage", () => {
expect(isWorkerToUiMessage(undefined)).toBe(false);
expect(isWorkerToUiMessage({})).toBe(false);
expect(isWorkerToUiMessage({ type: "load-progress", status: "progress", file: "f" })).toBe(false);
expect(isWorkerToUiMessage({ type: "load-progress", status: "ready" })).toBe(false);
expect(isWorkerToUiMessage({ type: "load-progress", status: "ready", device: "cuda" })).toBe(false);
expect(isWorkerToUiMessage({ type: "partial" })).toBe(false);
expect(isWorkerToUiMessage({ type: "result", text: "x", chunks: "nope" })).toBe(false);
expect(isWorkerToUiMessage({ type: "error", stage: "decode", message: "m" })).toBe(false);
@@ -129,14 +153,20 @@ describe("applyLoadProgress", () => {
expect(state.files["a.onnx"]).toEqual({ file: "a.onnx", loaded: 200, total: 200, done: true });
});
test("ready flips the ready flag and keeps file state", () => {
test("ready flips the ready flag, records the device, keeps file state", () => {
let state = applyLoadProgress(initialLoadProgress, {
type: "load-progress",
status: "done",
file: "a.onnx",
});
state = applyLoadProgress(state, { type: "load-progress", status: "ready" });
expect(state.device).toBeNull();
state = applyLoadProgress(state, {
type: "load-progress",
status: "ready",
device: "webgpu",
});
expect(state.ready).toBe(true);
expect(state.device).toBe("webgpu");
expect(state.files["a.onnx"]!.done).toBe(true);
});
});