CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
push.test.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 534f04a | 1 | /** |
| 2 | * Block M2 — Web Push tests. | |
| 3 | * | |
| 4 | * Covers `src/lib/push.ts` + the `/pwa/*` API surface: | |
| 5 | * - getVapidPublicKey: stable across calls (process-cached) | |
| 6 | * - subscribeUser: persists, idempotent on (user, endpoint) | |
| 7 | * - unsubscribeUser: deletes | |
| 8 | * - sendPushToUser: per-subscription failures don't crash the fan-out, | |
| 9 | * returns sent/failed counts | |
| 10 | * - Stale-endpoint deletion on HTTP 410 | |
| 11 | * - Routes: vapid-public-key public, subscribe requires auth, | |
| 12 | * unsubscribe requires auth | |
| 13 | * | |
| 14 | * Mock isolation | |
| 15 | * -------------- | |
| 16 | * Bun's `mock.module()` is process-global. We capture the real `../db` | |
| 17 | * module BEFORE any mock so afterAll can restore it. The fake db | |
| 18 | * dispatches on the table passed to .from/.insert/.delete/.update, | |
| 19 | * records mutations to in-memory arrays, and returns canned rows. | |
| 20 | */ | |
| 21 | ||
| 22 | import { | |
| 23 | describe, | |
| 24 | it, | |
| 25 | expect, | |
| 26 | mock, | |
| 27 | beforeEach, | |
| 28 | afterAll, | |
| 29 | } from "bun:test"; | |
| 30 | ||
| 31 | const _real_db = await import("../db"); | |
| 32 | ||
| 33 | // --------------------------------------------------------------------------- | |
| 34 | // Fake db state | |
| 35 | // --------------------------------------------------------------------------- | |
| 36 | ||
| 37 | type SubRow = { | |
| 38 | userId: string; | |
| 39 | endpoint: string; | |
| 40 | p256dh: string; | |
| 41 | auth: string; | |
| 42 | userAgent: string | null; | |
| 43 | }; | |
| 44 | ||
| 45 | let _subRows: SubRow[] = []; | |
| 46 | let _nextUserRow: any = null; | |
| 47 | const _inserted: any[] = []; | |
| 48 | const _updated: any[] = []; | |
| 49 | const _deleted: any[] = []; | |
| 50 | ||
| 51 | const tableName = (t: any): string => { | |
| 52 | if (!t || typeof t !== "object") return "?"; | |
| 53 | if ("p256dh" in t && "endpoint" in t) return "push_subscriptions"; | |
| 54 | if ("token" in t && "expiresAt" in t && "userId" in t) return "sessions"; | |
| 55 | if ("notifyPushOnMention" in t || "passwordHash" in t) return "users"; | |
| 56 | return "?"; | |
| 57 | }; | |
| 58 | ||
| 59 | let _lastSelectFrom: any = null; | |
| 60 | let _nextSessionRow: any = null; | |
| 61 | ||
| 62 | const _selectChain: any = { | |
| 63 | from: (t: any) => { | |
| 64 | _lastSelectFrom = t; | |
| 65 | return _selectChain; | |
| 66 | }, | |
| 67 | where: () => _selectChain, | |
| 68 | orderBy: () => _selectChain, | |
| 69 | limit: async () => { | |
| 70 | const name = tableName(_lastSelectFrom); | |
| 71 | if (name === "sessions") return _nextSessionRow ? [_nextSessionRow] : []; | |
| 72 | if (name === "users") return _nextUserRow ? [_nextUserRow] : []; | |
| 73 | return []; | |
| 74 | }, | |
| 75 | then: (resolve: (v: any) => void) => { | |
| 76 | const name = tableName(_lastSelectFrom); | |
| 77 | if (name === "push_subscriptions") { | |
| 78 | resolve(_subRows.map((r) => ({ | |
| 79 | endpoint: r.endpoint, | |
| 80 | p256dh: r.p256dh, | |
| 81 | auth: r.auth, | |
| 82 | }))); | |
| 83 | return; | |
| 84 | } | |
| 85 | resolve([]); | |
| 86 | }, | |
| 87 | }; | |
| 88 | ||
| 89 | const _insertChain = (table: any) => ({ | |
| 90 | values: (vals: any) => ({ | |
| 91 | onConflictDoUpdate: ({ set }: { set: any }) => { | |
| 92 | const name = tableName(table); | |
| 93 | if (name === "push_subscriptions") { | |
| 94 | const existing = _subRows.find( | |
| 95 | (r) => r.userId === vals.userId && r.endpoint === vals.endpoint | |
| 96 | ); | |
| 97 | if (existing) { | |
| 98 | existing.p256dh = set.p256dh ?? vals.p256dh; | |
| 99 | existing.auth = set.auth ?? vals.auth; | |
| 100 | existing.userAgent = set.userAgent ?? vals.userAgent ?? null; | |
| 101 | } else { | |
| 102 | _subRows.push({ | |
| 103 | userId: vals.userId, | |
| 104 | endpoint: vals.endpoint, | |
| 105 | p256dh: vals.p256dh, | |
| 106 | auth: vals.auth, | |
| 107 | userAgent: vals.userAgent ?? null, | |
| 108 | }); | |
| 109 | } | |
| 110 | } | |
| 111 | _inserted.push({ table: name, values: vals }); | |
| 112 | return Promise.resolve(); | |
| 113 | }, | |
| 114 | returning: async () => [], | |
| 115 | then: (r: (v: any) => void) => { | |
| 116 | const name = tableName(table); | |
| 117 | if (name === "push_subscriptions") { | |
| 118 | _subRows.push({ | |
| 119 | userId: vals.userId, | |
| 120 | endpoint: vals.endpoint, | |
| 121 | p256dh: vals.p256dh, | |
| 122 | auth: vals.auth, | |
| 123 | userAgent: vals.userAgent ?? null, | |
| 124 | }); | |
| 125 | } | |
| 126 | _inserted.push({ table: name, values: vals }); | |
| 127 | r(undefined); | |
| 128 | }, | |
| 129 | }), | |
| 130 | }); | |
| 131 | ||
| 132 | const _updateChain = (table: any) => ({ | |
| 133 | set: (s: any) => ({ | |
| 134 | where: () => { | |
| 135 | const name = tableName(table); | |
| 136 | _updated.push({ table: name, set: s }); | |
| 137 | return Promise.resolve(); | |
| 138 | }, | |
| 139 | }), | |
| 140 | }); | |
| 141 | ||
| 142 | let _deleteFilter: { userId?: string; endpoint?: string } | null = null; | |
| 143 | ||
| 144 | const _deleteChain = (table: any) => ({ | |
| 145 | where: (_cond: any) => { | |
| 146 | const name = tableName(table); | |
| 147 | _deleted.push({ table: name }); | |
| 148 | if (name === "push_subscriptions") { | |
| 149 | // We don't get the filter values from the cond object easily; the | |
| 150 | // tests below call delete after subscribing exactly one row per | |
| 151 | // case, so we just drop everything matching the most recently used | |
| 152 | // endpoint hint stored on the chain. | |
| 153 | if (_deleteFilter) { | |
| 154 | _subRows = _subRows.filter( | |
| 155 | (r) => | |
| 156 | !(r.userId === _deleteFilter!.userId && | |
| 157 | r.endpoint === _deleteFilter!.endpoint) | |
| 158 | ); | |
| 159 | } | |
| 160 | } | |
| 161 | return Promise.resolve(); | |
| 162 | }, | |
| 163 | }); | |
| 164 | ||
| 165 | const _fakeDb = { | |
| 166 | db: { | |
| 167 | select: () => _selectChain, | |
| 168 | insert: (t: any) => _insertChain(t), | |
| 169 | update: (t: any) => _updateChain(t), | |
| 170 | delete: (t: any) => _deleteChain(t), | |
| 171 | }, | |
| 172 | getDb: () => _fakeDb.db, | |
| 173 | }; | |
| 174 | ||
| 175 | mock.module("../db", () => ({ ..._real_db, ..._fakeDb })); | |
| 176 | ||
| 177 | // Imports must come AFTER the mock so the module graph picks up the fake db. | |
| 178 | const { | |
| 179 | getVapidPublicKey, | |
| 180 | subscribeUser, | |
| 181 | unsubscribeUser, | |
| 182 | sendPushToUser, | |
| 183 | __setSendTransport, | |
| 184 | __resetVapidCacheForTests, | |
| 185 | } = await import("../lib/push"); | |
| 186 | const { default: app } = await import("../app"); | |
| 187 | const { sessionCache } = await import("../lib/cache"); | |
| 188 | ||
| 189 | // --------------------------------------------------------------------------- | |
| 190 | // Fixtures | |
| 191 | // --------------------------------------------------------------------------- | |
| 192 | ||
| 193 | // Valid (random) base64url-encoded payload-encryption keys. The encryption | |
| 194 | // path generates a fresh ECDH keypair on each call so the recipient public | |
| 195 | // key only needs to be a real P-256 uncompressed point; we generate one | |
| 196 | // with Web Crypto at test setup time. | |
| 197 | let RECIPIENT_P256DH = ""; | |
| 198 | let RECIPIENT_AUTH = ""; | |
| 199 | ||
| 200 | const USER_ID = "55555555-5555-5555-5555-555555555555"; | |
| 201 | const SESSION_TOKEN = "test-session-token-push-m2"; | |
| 202 | ||
| 203 | const TEST_USER = { | |
| 204 | id: USER_ID, | |
| 205 | username: "push_test_user", | |
| 206 | displayName: "Push Test User", | |
| 207 | email: "push@example.com", | |
| 208 | passwordHash: "x", | |
| 209 | bio: null, | |
| 210 | notifyPushOnMention: true, | |
| 211 | notifyPushOnAssign: true, | |
| 212 | notifyPushOnReviewRequest: true, | |
| 213 | notifyPushOnDeployFailed: true, | |
| 214 | createdAt: new Date(), | |
| 215 | updatedAt: new Date(), | |
| 216 | }; | |
| 217 | ||
| 218 | function authedHeaders(): HeadersInit { | |
| 219 | return { | |
| 220 | cookie: `session=${SESSION_TOKEN}`, | |
| 221 | "content-type": "application/json", | |
| 222 | // CSRF same-origin guard: requests from real browser fetch() carry an | |
| 223 | // Origin matching the page host. We mirror that here so the | |
| 224 | // double-submit token isn't required for these JSON POSTs. | |
| 225 | host: "localhost", | |
| 226 | origin: "http://localhost", | |
| 227 | }; | |
| 228 | } | |
| 229 | ||
| 230 | beforeEach(async () => { | |
| 231 | _subRows = []; | |
| 232 | _nextUserRow = TEST_USER; | |
| 233 | _nextSessionRow = { | |
| 234 | userId: USER_ID, | |
| 235 | token: SESSION_TOKEN, | |
| 236 | expiresAt: new Date(Date.now() + 60 * 60 * 1000), | |
| 237 | requires2fa: false, | |
| 238 | }; | |
| 239 | _inserted.length = 0; | |
| 240 | _updated.length = 0; | |
| 241 | _deleted.length = 0; | |
| 242 | _deleteFilter = null; | |
| 243 | sessionCache.set(SESSION_TOKEN, TEST_USER as any); | |
| 244 | ||
| 245 | // Generate a real P-256 point so encryptPayload doesn't choke on dummy | |
| 246 | // bytes. We only need the public-key bytes; the private key stays inside | |
| 247 | // Web Crypto. `auth` can be any 16 bytes. | |
| 248 | if (!RECIPIENT_P256DH) { | |
| 249 | const kp = await crypto.subtle.generateKey( | |
| 250 | { name: "ECDH", namedCurve: "P-256" }, | |
| 251 | true, | |
| 252 | ["deriveBits"] | |
| 253 | ); | |
| 254 | const raw = new Uint8Array( | |
| 255 | await crypto.subtle.exportKey("raw", kp.publicKey) | |
| 256 | ); | |
| 257 | const authBytes = crypto.getRandomValues(new Uint8Array(16)); | |
| 258 | RECIPIENT_P256DH = b64u(raw); | |
| 259 | RECIPIENT_AUTH = b64u(authBytes); | |
| 260 | } | |
| 261 | }); | |
| 262 | ||
| 263 | function b64u(bytes: Uint8Array): string { | |
| 264 | let bin = ""; | |
| 265 | for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!); | |
| 266 | return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); | |
| 267 | } | |
| 268 | ||
| 269 | afterAll(() => { | |
| 270 | sessionCache.invalidate(SESSION_TOKEN); | |
| 271 | __resetVapidCacheForTests(); | |
| 272 | mock.module("../db", () => _real_db); | |
| 273 | }); | |
| 274 | ||
| 275 | // --------------------------------------------------------------------------- | |
| 276 | // Pure-helper tests | |
| 277 | // --------------------------------------------------------------------------- | |
| 278 | ||
| 279 | describe("push.ts — getVapidPublicKey", () => { | |
| 280 | it("returns a stable key across calls (in-memory cached)", async () => { | |
| 281 | __resetVapidCacheForTests(); | |
| 282 | const a = await getVapidPublicKey(); | |
| 283 | const b = await getVapidPublicKey(); | |
| 284 | expect(a).toBe(b); | |
| 285 | // Base64url, no padding. | |
| 286 | expect(a).toMatch(/^[A-Za-z0-9_\-]+$/); | |
| 287 | expect(a.length).toBeGreaterThan(40); | |
| 288 | }); | |
| 289 | }); | |
| 290 | ||
| 291 | describe("push.ts — subscribeUser / unsubscribeUser", () => { | |
| 292 | it("subscribeUser inserts a row", async () => { | |
| 293 | await subscribeUser( | |
| 294 | USER_ID, | |
| 295 | { endpoint: "https://push.example/abc", keys: { p256dh: RECIPIENT_P256DH, auth: RECIPIENT_AUTH } }, | |
| 296 | "test-ua" | |
| 297 | ); | |
| 298 | expect(_subRows.length).toBe(1); | |
| 299 | expect(_subRows[0]!.userId).toBe(USER_ID); | |
| 300 | expect(_subRows[0]!.endpoint).toBe("https://push.example/abc"); | |
| 301 | expect(_subRows[0]!.userAgent).toBe("test-ua"); | |
| 302 | }); | |
| 303 | ||
| 304 | it("subscribeUser is idempotent on (user, endpoint)", async () => { | |
| 305 | await subscribeUser(USER_ID, { | |
| 306 | endpoint: "https://push.example/abc", | |
| 307 | keys: { p256dh: RECIPIENT_P256DH, auth: RECIPIENT_AUTH }, | |
| 308 | }); | |
| 309 | await subscribeUser(USER_ID, { | |
| 310 | endpoint: "https://push.example/abc", | |
| 311 | keys: { p256dh: RECIPIENT_P256DH, auth: "newauth" }, | |
| 312 | }); | |
| 313 | expect(_subRows.length).toBe(1); | |
| 314 | expect(_subRows[0]!.auth).toBe("newauth"); | |
| 315 | }); | |
| 316 | ||
| 317 | it("unsubscribeUser deletes the row", async () => { | |
| 318 | await subscribeUser(USER_ID, { | |
| 319 | endpoint: "https://push.example/zzz", | |
| 320 | keys: { p256dh: RECIPIENT_P256DH, auth: RECIPIENT_AUTH }, | |
| 321 | }); | |
| 322 | expect(_subRows.length).toBe(1); | |
| 323 | _deleteFilter = { userId: USER_ID, endpoint: "https://push.example/zzz" }; | |
| 324 | await unsubscribeUser(USER_ID, "https://push.example/zzz"); | |
| 325 | expect(_subRows.length).toBe(0); | |
| 326 | }); | |
| 327 | }); | |
| 328 | ||
| 329 | // --------------------------------------------------------------------------- | |
| 330 | // sendPushToUser fan-out + stale-endpoint cleanup | |
| 331 | // --------------------------------------------------------------------------- | |
| 332 | ||
| 333 | describe("push.ts — sendPushToUser", () => { | |
| 334 | it("swallows per-subscription failures and returns sent/failed counts", async () => { | |
| 335 | // Three subscriptions: one ok (200), one server error (500), one gone (410). | |
| 336 | _subRows = [ | |
| 337 | { userId: USER_ID, endpoint: "https://push.example/ok", p256dh: RECIPIENT_P256DH, auth: RECIPIENT_AUTH, userAgent: null }, | |
| 338 | { userId: USER_ID, endpoint: "https://push.example/fail", p256dh: RECIPIENT_P256DH, auth: RECIPIENT_AUTH, userAgent: null }, | |
| 339 | { userId: USER_ID, endpoint: "https://push.example/gone", p256dh: RECIPIENT_P256DH, auth: RECIPIENT_AUTH, userAgent: null }, | |
| 340 | ]; | |
| 341 | ||
| 342 | const prev = __setSendTransport(async (url) => { | |
| 343 | if (url.endsWith("/ok")) return { status: 201 }; | |
| 344 | if (url.endsWith("/fail")) return { status: 500 }; | |
| 345 | if (url.endsWith("/gone")) return { status: 410 }; | |
| 346 | return { status: 502 }; | |
| 347 | }); | |
| 348 | try { | |
| 349 | // Drop the gone endpoint when the helper purges it. | |
| 350 | _deleteFilter = { userId: USER_ID, endpoint: "https://push.example/gone" }; | |
| 351 | const res = await sendPushToUser(USER_ID, { | |
| 352 | title: "hello", | |
| 353 | body: "world", | |
| 354 | url: "/notifications", | |
| 355 | }); | |
| 356 | expect(res.sent).toBe(1); | |
| 357 | expect(res.failed).toBe(2); | |
| 358 | // The 410 endpoint should have been purged from the table. | |
| 359 | expect(_subRows.find((r) => r.endpoint.endsWith("/gone"))).toBeUndefined(); | |
| 360 | // The 500 endpoint must NOT be purged — it might be transient. | |
| 361 | expect(_subRows.find((r) => r.endpoint.endsWith("/fail"))).toBeDefined(); | |
| 362 | } finally { | |
| 363 | __setSendTransport(prev); | |
| 364 | } | |
| 365 | }); | |
| 366 | ||
| 367 | it("returns zero counts when the user has no subscriptions", async () => { | |
| 368 | _subRows = []; | |
| 369 | const res = await sendPushToUser(USER_ID, { title: "x", body: "y" }); | |
| 370 | expect(res).toEqual({ sent: 0, failed: 0 }); | |
| 371 | }); | |
| 372 | }); | |
| 373 | ||
| 374 | // --------------------------------------------------------------------------- | |
| 375 | // Routes | |
| 376 | // --------------------------------------------------------------------------- | |
| 377 | ||
| 378 | describe("/pwa/vapid-public-key", () => { | |
| 379 | it("is public — returns 200 + { key } without auth", async () => { | |
| 380 | const res = await app.request("/pwa/vapid-public-key"); | |
| 381 | expect(res.status).toBe(200); | |
| 382 | const body = await res.json(); | |
| 383 | expect(typeof body.key).toBe("string"); | |
| 384 | expect(body.key.length).toBeGreaterThan(40); | |
| 385 | }); | |
| 386 | }); | |
| 387 | ||
| 388 | describe("/pwa/subscribe", () => { | |
| 389 | it("requires auth (302 to /login when anonymous)", async () => { | |
| 390 | const res = await app.request("/pwa/subscribe", { | |
| 391 | method: "POST", | |
| 392 | headers: { "content-type": "application/json" }, | |
| 393 | body: JSON.stringify({ | |
| 394 | endpoint: "https://push.example/x", | |
| 395 | keys: { p256dh: RECIPIENT_P256DH, auth: RECIPIENT_AUTH }, | |
| 396 | }), | |
| 397 | }); | |
| 398 | // requireAuth either redirects to /login or returns 401 — accept either. | |
| 399 | expect([302, 401, 403]).toContain(res.status); | |
| 400 | }); | |
| 401 | ||
| 402 | it("authed POST persists a subscription and returns 201", async () => { | |
| 403 | const res = await app.request("/pwa/subscribe", { | |
| 404 | method: "POST", | |
| 405 | headers: authedHeaders(), | |
| 406 | body: JSON.stringify({ | |
| 407 | endpoint: "https://push.example/me", | |
| 408 | keys: { p256dh: RECIPIENT_P256DH, auth: RECIPIENT_AUTH }, | |
| 409 | }), | |
| 410 | }); | |
| 411 | expect(res.status).toBe(201); | |
| 412 | expect(_subRows.length).toBe(1); | |
| 413 | expect(_subRows[0]!.endpoint).toBe("https://push.example/me"); | |
| 414 | }); | |
| 415 | ||
| 416 | it("rejects malformed bodies with 400", async () => { | |
| 417 | const res = await app.request("/pwa/subscribe", { | |
| 418 | method: "POST", | |
| 419 | headers: authedHeaders(), | |
| 420 | body: JSON.stringify({ endpoint: "no-keys" }), | |
| 421 | }); | |
| 422 | expect(res.status).toBe(400); | |
| 423 | }); | |
| 424 | }); | |
| 425 | ||
| 426 | describe("/pwa/unsubscribe", () => { | |
| 427 | it("requires auth", async () => { | |
| 428 | const res = await app.request("/pwa/unsubscribe", { | |
| 429 | method: "POST", | |
| 430 | headers: { "content-type": "application/json" }, | |
| 431 | body: JSON.stringify({ endpoint: "https://push.example/x" }), | |
| 432 | }); | |
| 433 | expect([302, 401, 403]).toContain(res.status); | |
| 434 | }); | |
| 435 | ||
| 436 | it("authed POST deletes and returns 204", async () => { | |
| 437 | _subRows = [ | |
| 438 | { userId: USER_ID, endpoint: "https://push.example/byebye", p256dh: RECIPIENT_P256DH, auth: RECIPIENT_AUTH, userAgent: null }, | |
| 439 | ]; | |
| 440 | _deleteFilter = { userId: USER_ID, endpoint: "https://push.example/byebye" }; | |
| 441 | const res = await app.request("/pwa/unsubscribe", { | |
| 442 | method: "POST", | |
| 443 | headers: authedHeaders(), | |
| 444 | body: JSON.stringify({ endpoint: "https://push.example/byebye" }), | |
| 445 | }); | |
| 446 | expect(res.status).toBe(204); | |
| 447 | expect(_subRows.length).toBe(0); | |
| 448 | }); | |
| 449 | }); | |
| 450 | ||
| 904927d | 451 | describe("/sw-push.js (AA-loop self-unregister)", () => { |
| 452 | // 2026-05-16 — was previously a push + offline + fetch-fallback SW | |
| 453 | // registered at scope `/`. That collided with `/sw.js` (also at `/`), | |
| 454 | // causing the admin-dashboard reload loop. The body is now a self- | |
| 455 | // unregister script so existing browsers that have it installed | |
| 456 | // auto-recover on their next SW update check (which is every | |
| 457 | // navigation since `Cache-Control: no-store`). | |
| 458 | it("serves a self-unregistering service worker", async () => { | |
| 534f04a | 459 | const res = await app.request("/sw-push.js"); |
| 460 | expect(res.status).toBe(200); | |
| 461 | expect(res.headers.get("content-type") || "").toContain( | |
| 462 | "application/javascript" | |
| 463 | ); | |
| 464 | const body = await res.text(); | |
| 904927d | 465 | expect(body).toContain("self.registration.unregister"); |
| 466 | expect(body).toContain("addEventListener('install'"); | |
| 467 | expect(body).toContain("addEventListener('activate'"); | |
| 468 | }); | |
| 469 | ||
| 470 | it("has no fetch handler — every request must hit the network", async () => { | |
| 471 | const res = await app.request("/sw-push.js"); | |
| 472 | const body = await res.text(); | |
| 473 | expect(body).not.toContain("addEventListener('fetch'"); | |
| 474 | }); | |
| 475 | ||
| 476 | it("does not subscribe to push events anymore", async () => { | |
| 477 | const res = await app.request("/sw-push.js"); | |
| 478 | const body = await res.text(); | |
| 479 | expect(body).not.toContain("addEventListener('push'"); | |
| 480 | expect(body).not.toContain("addEventListener('notificationclick'"); | |
| 534f04a | 481 | }); |
| 482 | }); | |
| 483 | ||
| 484 | describe("/offline.html", () => { | |
| 485 | it("serves a dark-themed offline page", async () => { | |
| 486 | const res = await app.request("/offline.html"); | |
| 487 | expect(res.status).toBe(200); | |
| 488 | expect(res.headers.get("content-type") || "").toContain("text/html"); | |
| 489 | const body = await res.text(); | |
| 490 | expect(body).toContain("You're offline"); | |
| 81201cc | 491 | expect(body).toContain('data-theme="light"'); |
| 534f04a | 492 | }); |
| 493 | }); |