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:
co-authored by
Claude Fable 5
parent
eb72a2e646
commit
9f39a82982
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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_
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user