diff --git a/README.md b/README.md
index 61906d1f1..690e90e89 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
-
+
@@ -511,7 +511,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and
| **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) |
| **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking |
| **i18n** | Custom React Context (`I18nContext.tsx`) | 2942 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence |
-| **Testing** | Vitest 4.x (7644+ tests / 604 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
+| **Testing** | Vitest 4.x (7651+ tests / 604 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy |
| **Visualization** | Force-directed graph | Interactive character relationship network |
| **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` |
@@ -549,7 +549,7 @@ WorldScript-Studio/
│ ├── sw.js # PWA Service Worker
│ └── manifest.json # PWA Web App Manifest v3
├── tests/
-│ ├── unit/ # Vitest unit tests (7644+ tests, 604 files) — count spans tests/, components/, packages/*/tests/, not just this folder
+│ ├── unit/ # Vitest unit tests (7651+ tests, 604 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths
│ │ └── settings/ # WebLlmPanel, AiSections
│ └── e2e/ # Playwright specs + helpers.ts
@@ -713,8 +713,8 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt
Raw bundle-budget ceilings (KB per uncompressed asset): entry **2500 KB**, vendor **6200 KB**, other JavaScript **2500 KB**, and WASM **30000 KB**.
-**Current test metrics (2026-09-07, source-synchronized; CI remains authoritative for pass/fail):**
-- **7644+ unit tests** across **604 test files** — CI is authoritative for pass/fail
+**Current test metrics (2026-09-10, source-synchronized; CI remains authoritative for pass/fail):**
+- **7651+ unit tests** across **604 test files** — CI is authoritative for pass/fail
- Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics)
- i18n: **2942 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta)
diff --git a/public/sw.js b/public/sw.js
index 4e2cee17f..35e80ff8f 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -42,14 +42,23 @@ const swLogger = {
// eslint-disable-next-line no-underscore-dangle
const _WB_MANIFEST = self.__WB_MANIFEST || [];
-const PRECACHE_URLS = [
- BASE,
- `${BASE}index.html`,
- `${BASE}manifest.json`,
- `${BASE}favicon.svg`,
- `${BASE}offline.html`,
- ..._WB_MANIFEST.map((entry) => (typeof entry === 'string' ? entry : entry.url)),
-];
+const EXPLICIT_SHELL_URLS = [BASE, `${BASE}index.html`, `${BASE}manifest.json`, `${BASE}favicon.svg`, `${BASE}offline.html`];
+
+// QNBS-v3: keep these files in the injected manifest (their content hash still changes sw.js's own bytes, triggering an update with no version bump) but drop them here so cache.addAll() never sees the same resolved URL twice — tracked against every URL seen so far, not just the explicit list, since two manifest entries could otherwise collide with each other.
+const seenResolvedUrls = new Set(EXPLICIT_SHELL_URLS.map((url) => new URL(url, self.location.href).href));
+const manifestUrls = _WB_MANIFEST
+ .map((entry) => (typeof entry === 'string' ? entry : entry.url))
+ .filter((url) => {
+ const resolved = new URL(url, self.location.href).href;
+ if (seenResolvedUrls.has(resolved)) return false;
+ seenResolvedUrls.add(resolved);
+ return true;
+ });
+
+const PRECACHE_URLS = [...EXPLICIT_SHELL_URLS, ...manifestUrls];
+
+// QNBS-v3: marker written into CACHE_STATIC only once precache fully succeeds; activate checks it before pruning an older generation.
+const PRECACHE_ADMISSION_URL = `${BASE}__sw-precache-complete__`;
// ── Max age / entry limits ───────────────────────────────────
const MAX_AGE_DYNAMIC = 7 * 24 * 60 * 60 * 1000; // 7 days
@@ -112,16 +121,22 @@ async function offlineFallback(request) {
// INSTALL — Precache shell
// ════════════════════════════════════════════════════════════
self.addEventListener('install', (event) => {
- // QNBS-v3: activates immediately, no waiting — register-sw.ts owns the bounded pre-reload flush mitigation, residual risk tracked in #518.
- self.skipWaiting();
// QNBS-v3: Never precache inside Tauri — the desktop app serves its shell from the bundle.
- if (IS_TAURI) return;
+ if (IS_TAURI) {
+ self.skipWaiting();
+ return;
+ }
event.waitUntil(
caches.open(CACHE_STATIC)
- .then((cache) => cache.addAll(PRECACHE_URLS))
+ .then((cache) => cache.addAll(PRECACHE_URLS).then(() => cache.put(PRECACHE_ADMISSION_URL, new Response('ok'))))
+ .then(() => {
+ // QNBS-v3: only claim the update once precache fully succeeded; register-sw.ts owns the pre-reload flush mitigation for the resulting reload.
+ self.skipWaiting();
+ })
.catch((err) => {
- // Some precache entries (e.g. offline.html) may not exist yet; continue anyway
- swLogger.warn('Precache partial failure (non-fatal):', err);
+ // QNBS-v3: rethrow so install() itself fails — a worker that never reaches "installed" can never be waited-on, activated, or SKIP_WAITING'd.
+ swLogger.warn('Precache failed — this installation will not complete:', err);
+ throw err;
})
);
});
@@ -154,19 +169,26 @@ self.addEventListener('activate', (event) => {
return;
}
event.waitUntil(
- caches.keys()
- .then((cacheNames) =>
- Promise.all(
- cacheNames
- // QNBS-v3: prune only owned-and-stale — never delete a cache we don't positively own.
- .filter((name) => isWorldScriptOwnedCache(name) && !ALL_CACHES.includes(name))
- .map((name) => {
- swLogger.log('Pruning old cache:', name);
- return caches.delete(name);
- })
- )
- )
- .then(() => self.clients.claim())
+ (async () => {
+ // QNBS-v3: admission gate — only an admitted generation may prune stale caches or claim clients.
+ const staticCache = await caches.open(CACHE_STATIC);
+ const precacheComplete = Boolean(await staticCache.match(PRECACHE_ADMISSION_URL));
+ if (!precacheComplete) {
+ swLogger.warn('Skipping cache-generation cutover: this generation\'s precache never completed');
+ return;
+ }
+ const cacheNames = await caches.keys();
+ await Promise.all(
+ cacheNames
+ // QNBS-v3: prune only owned-and-stale — never delete a cache we don't positively own.
+ .filter((name) => isWorldScriptOwnedCache(name) && !ALL_CACHES.includes(name))
+ .map((name) => {
+ swLogger.log('Pruning old cache:', name);
+ return caches.delete(name);
+ })
+ );
+ await self.clients.claim();
+ })()
);
});
diff --git a/tests/unit/serviceWorkerCacheOwnership.test.ts b/tests/unit/serviceWorkerCacheOwnership.test.ts
index 6376223d4..065ccb6ae 100644
--- a/tests/unit/serviceWorkerCacheOwnership.test.ts
+++ b/tests/unit/serviceWorkerCacheOwnership.test.ts
@@ -13,6 +13,16 @@ const extractedVersion = appVersionMatch?.[1];
if (!extractedVersion) throw new Error('Could not extract APP_VERSION from public/sw.js');
const APP_VERSION = extractedVersion;
+// QNBS-v3: the one BASE every loadServiceWorker() call below uses via selfMock.location.pathname — shared so ADMISSION_MARKER_URL can never silently diverge from it.
+const TEST_BASE = '/WorldScript-Studio/';
+
+// QNBS-v3: only the suffix is extracted from source; TEST_BASE above is the single hardcoded value both this constant and selfMock.location.pathname derive from.
+const admissionUrlMatch = swSource.match(/const PRECACHE_ADMISSION_URL\s*=\s*`\$\{BASE\}([^`]+)`/);
+const extractedAdmissionSuffix = admissionUrlMatch?.[1];
+if (!extractedAdmissionSuffix)
+ throw new Error('Could not extract PRECACHE_ADMISSION_URL from public/sw.js');
+const ADMISSION_MARKER_URL = `${TEST_BASE}${extractedAdmissionSuffix}`;
+
const CURRENT_STATIC = `worldscript-static-v${APP_VERSION}`;
const CURRENT_DYNAMIC = `worldscript-dynamic-v${APP_VERSION}`;
const CURRENT_IMAGES = `worldscript-images-v${APP_VERSION}`;
@@ -21,38 +31,73 @@ const FOREIGN_CACHE = 'some-other-github-pages-app-cache-v1';
// QNBS-v3: a startsWith('worldscript-static-v') predicate would wrongly treat this as owned.
const COLLIDING_FOREIGN_CACHE = 'worldscript-static-vendor-cache';
+interface FakeCache {
+ addAll: (urls: string[]) => Promise;
+ match: (key: string) => Promise<{ ok: true } | undefined>;
+ put: (key: string, value: unknown) => Promise;
+ keys: () => Promise;
+}
+
interface FakeCaches {
keys(): Promise;
- open(name: string): Promise<{
- addAll: () => Promise;
- match: () => Promise;
- put: () => Promise;
- keys: () => Promise;
- }>;
+ open(name: string): Promise;
delete(name: string): Promise;
match(): Promise;
attemptedDeletes: string[];
names(): string[];
}
-function createFakeCaches(initialNames: string[], rejectOnDelete?: string): FakeCaches {
+function createFakeCaches(
+ initialNames: string[],
+ opts: {
+ rejectOnDelete?: string | undefined;
+ failAddAllFor?: string | undefined;
+ failAddAllTimes?: number | undefined;
+ swHref: string;
+ },
+): FakeCaches {
const store = new Set(initialNames);
+ const entries = new Map>();
const attemptedDeletes: string[] = [];
+ // QNBS-v3: a countdown (not a fixed boolean) so the SAME fake caches instance can simulate a real second install attempt succeeding after an earlier one failed.
+ let remainingAddAllFailures = opts.failAddAllTimes ?? (opts.failAddAllFor ? 1 : 0);
return {
async keys() {
return [...store];
},
- async open() {
+ async open(name: string) {
+ store.add(name);
+ if (!entries.has(name)) entries.set(name, new Set());
+ const bucket = entries.get(name);
+ if (!bucket) throw new Error('unreachable: bucket just inserted');
return {
- addAll: async () => {},
- match: async () => undefined,
- put: async () => {},
+ addAll: async (urls: string[]) => {
+ if (opts.failAddAllFor === name && remainingAddAllFailures > 0) {
+ remainingAddAllFailures--;
+ throw new Error(`simulated precache failure for ${name}`);
+ }
+ // QNBS-v3: mirrors real Cache.addAll() — rejects when two entries resolve to the same absolute URL, even if their literal strings differ.
+ const resolvedSeen = new Set();
+ for (const url of urls) {
+ const resolved = new URL(url, opts.swHref).href;
+ if (resolvedSeen.has(resolved)) {
+ throw new Error(`simulated InvalidStateError: duplicate request for ${resolved}`);
+ }
+ resolvedSeen.add(resolved);
+ bucket.add(url);
+ }
+ },
+ match: async (key: string) => (bucket.has(key) ? { ok: true } : undefined),
+ put: async (key: string) => {
+ bucket.add(key);
+ },
keys: async () => [],
};
},
async delete(name: string) {
attemptedDeletes.push(name);
- if (name === rejectOnDelete) throw new Error(`simulated delete failure for ${name}`);
+ if (name === opts.rejectOnDelete) throw new Error(`simulated delete failure for ${name}`);
+ entries.delete(name);
return store.delete(name);
},
async match() {
@@ -70,37 +115,84 @@ function loadServiceWorker(opts: {
hostname: string;
initialCacheNames: string[];
rejectOnDelete?: string;
+ failAddAllFor?: string;
+ failAddAllTimes?: number;
+ manifest?: Array;
}) {
const handlers: Record = {};
- const fakeCaches = createFakeCaches(opts.initialCacheNames, opts.rejectOnDelete);
+ // QNBS-v3: real service-worker scripts resolve bare manifest URLs relative to their own script location — the fake needs the same href to detect duplicate-request rejections realistically.
+ const swHref = `${opts.protocol}//${opts.hostname}${TEST_BASE}sw.js`;
+ const fakeCaches = createFakeCaches(opts.initialCacheNames, {
+ rejectOnDelete: opts.rejectOnDelete,
+ failAddAllFor: opts.failAddAllFor,
+ failAddAllTimes: opts.failAddAllTimes,
+ swHref,
+ });
+ let skipWaitingCallCount = 0;
+ let clientsClaimCallCount = 0;
const selfMock = {
- location: { protocol: opts.protocol, hostname: opts.hostname, pathname: '/WorldScript-Studio/sw.js' },
+ location: {
+ protocol: opts.protocol,
+ hostname: opts.hostname,
+ pathname: `${TEST_BASE}sw.js`,
+ href: swHref,
+ },
console: { log: () => {}, warn: () => {}, error: () => {} },
addEventListener: (type: string, handler: SwHandler) => {
handlers[type] = handler;
},
- clients: { claim: async () => {} },
+ clients: {
+ claim: async () => {
+ clientsClaimCallCount++;
+ },
+ },
registration: { unregister: async () => {} },
- skipWaiting: () => {},
- __WB_MANIFEST: [],
+ skipWaiting: () => {
+ skipWaitingCallCount++;
+ },
+ __WB_MANIFEST: opts.manifest ?? [],
};
- const context = vm.createContext({ self: selfMock, caches: fakeCaches, console: selfMock.console });
+ const context = vm.createContext({
+ self: selfMock,
+ caches: fakeCaches,
+ console: selfMock.console,
+ URL,
+ Response: class {
+ constructor(public body?: unknown) {}
+ },
+ });
vm.runInContext(swSource, context);
// QNBS-v3: bracket-index + explicit throw satisfies noUncheckedIndexedAccess and yields non-optional SwHandler.
- const getHandler = (type: 'activate' | 'message'): SwHandler => {
+ const getHandler = (type: 'install' | 'activate' | 'message'): SwHandler => {
const handler = handlers[type];
if (!handler) throw new Error(`sw.js never registered a "${type}" listener`);
return handler;
};
- return { getHandler, fakeCaches };
+ return {
+ getHandler,
+ fakeCaches,
+ skipWaitingCalls: () => skipWaitingCallCount,
+ clientsClaimCalls: () => clientsClaimCallCount,
+ };
}
async function runWaitUntil(handler: SwHandler, event: Record = {}) {
let captured: unknown;
- await handler({ ...event, waitUntil: (p: unknown) => { captured = p; } });
+ await handler({
+ ...event,
+ waitUntil: (p: unknown) => {
+ captured = p;
+ },
+ });
await captured;
}
+/** Seeds CACHE_STATIC with the admission marker directly, simulating "a prior install already completed successfully" without re-running install. */
+async function admitPrecache(fakeCaches: FakeCaches) {
+ const cache = await fakeCaches.open(CURRENT_STATIC);
+ await cache.put(ADMISSION_MARKER_URL, { ok: true });
+}
+
describe('service worker — cache ownership (activate / CLEAR_CACHE never delete unowned caches)', () => {
beforeAll(() => {
expect(APP_VERSION.length).toBeGreaterThan(0);
@@ -110,8 +202,15 @@ describe('service worker — cache ownership (activate / CLEAR_CACHE never delet
const { getHandler, fakeCaches } = loadServiceWorker({
protocol: 'https:',
hostname: 'qnbs.github.io',
- initialCacheNames: [STALE_STATIC, CURRENT_STATIC, CURRENT_DYNAMIC, CURRENT_IMAGES, FOREIGN_CACHE],
+ initialCacheNames: [
+ STALE_STATIC,
+ CURRENT_STATIC,
+ CURRENT_DYNAMIC,
+ CURRENT_IMAGES,
+ FOREIGN_CACHE,
+ ],
});
+ await admitPrecache(fakeCaches);
await runWaitUntil(getHandler('activate'));
const remaining = fakeCaches.names();
expect(remaining).not.toContain(STALE_STATIC);
@@ -127,6 +226,7 @@ describe('service worker — cache ownership (activate / CLEAR_CACHE never delet
hostname: 'qnbs.github.io',
initialCacheNames: [STALE_STATIC, CURRENT_STATIC, FOREIGN_CACHE],
});
+ await admitPrecache(fakeCaches);
await runWaitUntil(getHandler('activate'));
expect(fakeCaches.attemptedDeletes).not.toContain(FOREIGN_CACHE);
});
@@ -152,7 +252,10 @@ describe('service worker — cache ownership (activate / CLEAR_CACHE never delet
hostname: 'qnbs.github.io',
initialCacheNames: [STALE_STATIC, CURRENT_STATIC, CURRENT_IMAGES, FOREIGN_CACHE],
});
- await getHandler('message')({ data: { type: 'CLEAR_CACHE' }, source: { postMessage: () => {} } });
+ await getHandler('message')({
+ data: { type: 'CLEAR_CACHE' },
+ source: { postMessage: () => {} },
+ });
await new Promise((resolve) => setTimeout(resolve, 0));
const remaining = fakeCaches.names();
expect(remaining).not.toContain(STALE_STATIC);
@@ -167,7 +270,10 @@ describe('service worker — cache ownership (activate / CLEAR_CACHE never delet
hostname: 'qnbs.github.io',
initialCacheNames: [CURRENT_STATIC, FOREIGN_CACHE],
});
- await getHandler('message')({ data: { type: 'CLEAR_CACHE' }, source: { postMessage: () => {} } });
+ await getHandler('message')({
+ data: { type: 'CLEAR_CACHE' },
+ source: { postMessage: () => {} },
+ });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(fakeCaches.attemptedDeletes).not.toContain(FOREIGN_CACHE);
});
@@ -179,6 +285,7 @@ describe('service worker — cache ownership (activate / CLEAR_CACHE never delet
initialCacheNames: [STALE_STATIC, CURRENT_STATIC, FOREIGN_CACHE],
rejectOnDelete: STALE_STATIC,
});
+ await admitPrecache(fakeCaches);
// QNBS-v3: Promise.all rejects on the simulated failure — activate's own promise chain rejects too.
await expect(runWaitUntil(getHandler('activate'))).rejects.toThrow();
expect(fakeCaches.attemptedDeletes).not.toContain(FOREIGN_CACHE);
@@ -204,6 +311,7 @@ describe('service worker — cache ownership (activate / CLEAR_CACHE never delet
hostname: 'qnbs.github.io',
initialCacheNames: [CURRENT_STATIC, COLLIDING_FOREIGN_CACHE],
});
+ await admitPrecache(fakeCaches);
await runWaitUntil(getHandler('activate'));
expect(fakeCaches.attemptedDeletes).not.toContain(COLLIDING_FOREIGN_CACHE);
expect(fakeCaches.names()).toContain(COLLIDING_FOREIGN_CACHE);
@@ -215,9 +323,123 @@ describe('service worker — cache ownership (activate / CLEAR_CACHE never delet
hostname: 'qnbs.github.io',
initialCacheNames: [CURRENT_STATIC, COLLIDING_FOREIGN_CACHE],
});
- await getHandler('message')({ data: { type: 'CLEAR_CACHE' }, source: { postMessage: () => {} } });
+ await getHandler('message')({
+ data: { type: 'CLEAR_CACHE' },
+ source: { postMessage: () => {} },
+ });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(fakeCaches.attemptedDeletes).not.toContain(COLLIDING_FOREIGN_CACHE);
expect(fakeCaches.names()).toContain(COLLIDING_FOREIGN_CACHE);
});
});
+
+// QNBS-v3: a rejected install can never reach 'installed'/'waiting', so register-sw.ts's SKIP_WAITING message can't reach an incomplete generation either — no extra gating needed there.
+describe('service worker — precache admission gate (#525)', () => {
+ it('failed addAll() causes the install waitUntil() promise to reject', async () => {
+ const { getHandler } = loadServiceWorker({
+ protocol: 'https:',
+ hostname: 'qnbs.github.io',
+ initialCacheNames: [],
+ failAddAllFor: CURRENT_STATIC,
+ failAddAllTimes: 1,
+ });
+ await expect(runWaitUntil(getHandler('install'))).rejects.toThrow();
+ });
+
+ it('skipWaiting() is not called for a failed installation', async () => {
+ const { getHandler, skipWaitingCalls } = loadServiceWorker({
+ protocol: 'https:',
+ hostname: 'qnbs.github.io',
+ initialCacheNames: [],
+ failAddAllFor: CURRENT_STATIC,
+ failAddAllTimes: 1,
+ });
+ await expect(runWaitUntil(getHandler('install'))).rejects.toThrow();
+ expect(skipWaitingCalls()).toBe(0);
+ });
+
+ it('successful install completes, writes the admission marker and invokes skipWaiting()', async () => {
+ const { getHandler, fakeCaches, skipWaitingCalls } = loadServiceWorker({
+ protocol: 'https:',
+ hostname: 'qnbs.github.io',
+ initialCacheNames: [],
+ });
+ await runWaitUntil(getHandler('install'));
+ const staticCache = await fakeCaches.open(CURRENT_STATIC);
+ expect(await staticCache.match(ADMISSION_MARKER_URL)).toBeTruthy();
+ expect(skipWaitingCalls()).toBe(1);
+ });
+
+ it('successful activate after an admitted install prunes the previous owned generation', async () => {
+ const { getHandler, fakeCaches } = loadServiceWorker({
+ protocol: 'https:',
+ hostname: 'qnbs.github.io',
+ initialCacheNames: [STALE_STATIC],
+ });
+ await runWaitUntil(getHandler('install'));
+ await runWaitUntil(getHandler('activate'));
+ expect(fakeCaches.names()).not.toContain(STALE_STATIC);
+ expect(fakeCaches.names()).toContain(CURRENT_STATIC);
+ });
+
+ it('a later real successful install attempt after a failed one succeeds normally (no permanent stuck state)', async () => {
+ const { getHandler, fakeCaches, clientsClaimCalls } = loadServiceWorker({
+ protocol: 'https:',
+ hostname: 'qnbs.github.io',
+ initialCacheNames: [STALE_STATIC],
+ failAddAllFor: CURRENT_STATIC,
+ failAddAllTimes: 1,
+ });
+ // QNBS-v3: the first attempt fails and must reject; activate must never run for a rejected install in real life, but even if reached the marker check still preserves the previous generation and never claims clients, as defense in depth.
+ await expect(runWaitUntil(getHandler('install'))).rejects.toThrow();
+ await runWaitUntil(getHandler('activate'));
+ expect(fakeCaches.names()).toContain(STALE_STATIC);
+ expect(fakeCaches.attemptedDeletes).not.toContain(STALE_STATIC);
+ expect(clientsClaimCalls()).toBe(0);
+
+ // QNBS-v3: a real second install attempt (same worker source and fake caches, not a marker shortcut) now succeeds because the failure countdown is exhausted.
+ await runWaitUntil(getHandler('install'));
+ await runWaitUntil(getHandler('activate'));
+ expect(clientsClaimCalls()).toBe(1);
+ expect(fakeCaches.names()).not.toContain(STALE_STATIC);
+ expect(fakeCaches.names()).toContain(CURRENT_STATIC);
+ });
+});
+
+// QNBS-v3: regression coverage for a review finding on the #525 fix itself — VitePWA's injected manifest independently discovers index.html/offline.html/favicon.svg, which must not collide with PRECACHE_URLS's own explicit entries for the same files.
+describe('service worker — precache manifest deduplication (#525 follow-up)', () => {
+ it('a manifest entry resolving to the same URL as an explicit shell asset does not trigger a duplicate-request rejection', async () => {
+ const { getHandler, fakeCaches, skipWaitingCalls } = loadServiceWorker({
+ protocol: 'https:',
+ hostname: 'qnbs.github.io',
+ initialCacheNames: [],
+ manifest: [
+ { url: 'index.html', revision: 'abc123' },
+ { url: 'offline.html', revision: 'def456' },
+ { url: 'favicon.svg', revision: 'ghi789' },
+ { url: 'assets/app-somehash.js', revision: '' },
+ ],
+ });
+ await runWaitUntil(getHandler('install'));
+ const staticCache = await fakeCaches.open(CURRENT_STATIC);
+ expect(await staticCache.match(ADMISSION_MARKER_URL)).toBeTruthy();
+ expect(skipWaitingCalls()).toBe(1);
+ });
+
+ it('two manifest entries that resolve to the same URL as each other (not the explicit list) do not trigger a duplicate-request rejection', async () => {
+ // QNBS-v3: exercises the fake's InvalidStateError branch through real production dedup logic, not just the explicit-list case above — proves the rejection path is actually reachable and correctly avoided.
+ const { getHandler, fakeCaches, skipWaitingCalls } = loadServiceWorker({
+ protocol: 'https:',
+ hostname: 'qnbs.github.io',
+ initialCacheNames: [],
+ manifest: [
+ { url: 'assets/app-somehash.js', revision: '' },
+ { url: 'assets/app-somehash.js', revision: '' },
+ ],
+ });
+ await runWaitUntil(getHandler('install'));
+ const staticCache = await fakeCaches.open(CURRENT_STATIC);
+ expect(await staticCache.match(ADMISSION_MARKER_URL)).toBeTruthy();
+ expect(skipWaitingCalls()).toBe(1);
+ });
+});