import { expect, test } from "@playwright/test"; const root = "/three-dimensional/zjb/v29/"; const preview = root + "preview.html?host=platform2&v=36-resumable-cache"; const isAsset = (url: string) => /\.glb(?:\?|$)|water-network-v28\.json/.test(url); test.setTimeout(120_000); test("HTTP 200 的错误元数据不会进入缓存,服务器恢复后重试成功", async ({ page }) => { let requests = 0; await page.route("**/water-network-v28.json*", async (route) => { requests++; if (requests === 1) return route.fulfill({ json: { error: "temporary proxy failure" } }); return route.continue(); }); await page.goto(preview); await expect(page.locator("#boot")).toHaveAttribute("data-state", "error", { timeout: 60_000 }); const cached = await page.evaluate((base) => new Promise((resolve, reject) => { const request = indexedDB.open("tjwater-scene-assets-v1", 1); request.onerror = () => reject(request.error); request.onsuccess = () => { const database = request.result; const read = database.transaction("resources").objectStore("resources").get(new URL(base + "water-network-v28.json", location.origin).href); read.onsuccess = () => { resolve(Boolean(read.result)); database.close(); }; read.onerror = () => { reject(read.error); database.close(); }; }; }), root); expect(cached).toBe(false); await page.getByRole("button", { name: "重新载入场景" }).click(); await expect(page.locator("#boot")).toBeHidden({ timeout: 60_000 }); expect(requests).toBe(5); }); test("模型和元数据跨页面复用持久缓存,即使服务器资源不可用也能重开场景", async ({ page, context }) => { const requests: string[] = []; let block = false; // Routing disables HTTP cache, so this checks IndexedDB rather than browser HTTP caching. await context.route("**/three-dimensional/**", (route) => { if (isAsset(route.request().url())) { requests.push(route.request().url()); if (block) return route.abort("failed"); } return route.continue(); }); const coldStart = Date.now(); await page.goto(preview); await expect(page.locator("#boot")).toBeHidden({ timeout: 60_000 }); const coldMs = Date.now() - coldStart; expect(new Set(requests).size).toBe(10); requests.length = 0; block = true; await page.close(); const reopened = await context.newPage(); const warmStart = Date.now(); await reopened.goto(preview); await expect(reopened.locator("#boot")).toBeHidden({ timeout: 60_000 }); expect(await reopened.evaluate(() => (window as typeof window & { webQA: { camera: { active: string } } }).webQA.camera.active, )).toBe("overview"); expect(requests).toEqual([]); console.log(JSON.stringify({ coldMs, warmMs: Date.now() - warmStart, warmAssetRequests: requests.length })); }); test("首次载入显示真实进度,失败保留提示,重试可以恢复", async ({ page }) => { let release: () => void = () => {}; const pending = new Promise((resolve) => { release = resolve; }); const failedModel = "**/models/detail_roof.glb*"; await page.route(failedModel, async (route) => { await pending; await route.abort("failed"); }); await page.goto(preview); await expect(page.locator("#boot-title")).toHaveText("正在载入场景资源 9 / 10", { timeout: 60_000 }); await expect(page.getByRole("progressbar")).toHaveAttribute("value", "9"); await expect(page.locator("#boot-detail")).toContainText(/已读取 \d+\.\d MB/); await page.screenshot({ path: "test-results/scene-loading-desktop.png" }); await page.setViewportSize({ width: 320, height: 640 }); await page.screenshot({ path: "test-results/scene-loading-mobile.png" }); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); release(); await expect(page.locator("#boot-title")).toHaveText("场景资源加载失败"); await expect(page.locator("#boot-detail")).toContainText("detail_roof.glb"); await expect(page.locator("#boot")).toHaveAttribute("aria-busy", "false"); await expect(page.getByRole("button", { name: "重新载入场景" })).toBeVisible(); await page.screenshot({ path: "test-results/scene-loading-error.png" }); await page.unroute(failedModel); await page.getByRole("button", { name: "重新载入场景" }).click(); await expect(page.locator("#boot")).toBeHidden({ timeout: 60_000 }); }); test("同一资源合并并发请求,摘要改变后重新下载并覆盖旧缓存", async ({ page }) => { let requests = 0; await page.route("**/cache-probe.json*", (route) => { requests++; return route.fulfill({ json: { revision: new URL(route.request().url()).searchParams.get("v") } }); }); await page.goto(root + "manifest.json"); const result = await page.evaluate(async (base) => { const moduleUrl = base + "resourceCache.mjs"; const { createSceneResourceCache } = await import(moduleUrl); const cache = createSceneResourceCache(location.origin + base); const decode = (data: ArrayBuffer) => JSON.parse(new TextDecoder().decode(data)); const first = await Promise.all([ cache.load("cache-probe.json", "first", decode), cache.load("cache-probe.json", "first", decode), ]); const updated = await cache.load("cache-probe.json", "second", decode); const freshClient = createSceneResourceCache(location.origin + base); const cached = await freshClient.load("cache-probe.json", "second", decode); return { first, updated, cached }; }, root); expect(requests).toBe(2); expect(result.first).toEqual([{ revision: "first" }, { revision: "first" }]); expect(result.updated).toEqual({ revision: "second" }); expect(result.cached).toEqual({ revision: "second" }); }); for (const failure of ["denied", "quota"] as const) { test(`浏览器缓存${failure === "denied" ? "被禁用" : "空间不足"}时仍可下载并解析资源`, async ({ page }) => { await page.goto(root + "manifest.json"); await page.route("**/cache-probe.json*", (route) => route.fulfill({ json: { ok: true } })); const result = await page.evaluate(async ({ base, failure }) => { if (failure === "denied") { Object.defineProperty(window, "indexedDB", { get() { throw new Error("denied"); } }); } else { const transaction = IDBDatabase.prototype.transaction; IDBDatabase.prototype.transaction = function (...args: Parameters) { if (args[1] === "readwrite") throw new DOMException("Full", "QuotaExceededError"); return transaction.apply(this, args); }; } const moduleUrl = base + "resourceCache.mjs"; const { createSceneResourceCache } = await import(moduleUrl); const cache = createSceneResourceCache(location.origin + base); return cache.load("cache-probe.json", "first", (data: ArrayBuffer) => JSON.parse(new TextDecoder().decode(data))); }, { base: root, failure }); expect(result).toEqual({ ok: true }); }); } test("HTTP 错误和解析失败不会写入缓存,下一次请求可恢复", async ({ page }) => { let requests = 0; await page.route("**/cache-probe.json*", (route) => { requests++; return requests === 1 ? route.fulfill({ status: 404, body: "not found" }) : requests === 2 ? route.fulfill({ body: "invalid json" }) : route.fulfill({ json: { ok: true } }); }); await page.goto(root + "manifest.json"); const result = await page.evaluate(async (base) => { const moduleUrl = base + "resourceCache.mjs"; const { createSceneResourceCache } = await import(moduleUrl); const cache = createSceneResourceCache(location.origin + base); const decode = (data: ArrayBuffer) => JSON.parse(new TextDecoder().decode(data)); const errors: string[] = []; for (let attempt = 0; attempt < 2; attempt++) { try { await cache.load("cache-probe.json", "first", decode); } catch (error) { errors.push(String(error)); } } return { errors, recovered: await cache.load("cache-probe.json", "first", decode) }; }, root); expect(requests).toBe(3); expect(result.errors).toHaveLength(2); expect(result.errors[0]).toContain("HTTP 404"); expect(result.recovered).toEqual({ ok: true }); }); test("公网整文件中断时分段下载,重试失败分段并缓存完整模型", async ({ page }) => { const { readFile } = await import("node:fs/promises"); const { createHash } = await import("node:crypto"); const bytes = await readFile("public/three-dimensional/zjb/v29/models/detail_interior.glb"); const ranges: string[] = []; let interrupted = false; await page.route("**/models/detail_interior.glb*", async (route) => { const range = route.request().headers().range; ranges.push(range ?? "whole-file"); // The public proxy drops whole-file responses, and one partial request drops too. if (!range) return route.abort("failed"); if (range.startsWith("bytes=1048576-") && !interrupted) { interrupted = true; return route.abort("failed"); } const match = /^bytes=(\d+)-(\d+)$/.exec(range)!; const start = Number(match[1]); const end = Math.min(Number(match[2]), bytes.length - 1); return route.fulfill({ status: 206, headers: { "content-type": "model/gltf-binary", "content-range": `bytes ${start}-${end}/${bytes.length}`, }, body: bytes.subarray(start, end + 1) }); }); await page.goto(root + "manifest.json"); const result = await page.evaluate(async (base) => { const { createSceneResourceCache } = await import(base + "resourceCache.mjs"); const load = () => createSceneResourceCache(location.origin + base).load("models/detail_interior.glb", "range-recovery"); const data = await load(); const cached = await load(); return { length: data.byteLength, cachedLength: cached.byteLength, sha256: Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", data)), n => n.toString(16).padStart(2, "0")).join("") }; }, root); expect(result).toEqual({ length: bytes.length, cachedLength: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex") }); expect(ranges).toHaveLength(7); expect(ranges[0]).toBe("bytes=0-1048575"); expect(ranges[1]).toBe(ranges[2]); expect(interrupted).toBe(true); }); for (const fault of ["offset", "total", "truncated", "whole-response"] as const) { test(`无效分段响应 ${fault} 不会进入缓存,恢复后可重新下载`, async ({ page }) => { const bytes = Buffer.alloc(1048576 + 16, 7); let broken = true; let requests = 0; await page.route("**/range-probe.bin*", (route) => { requests++; const range = route.request().headers().range; if (!range) return route.abort("failed"); const match = /^bytes=(\d+)-(\d+)$/.exec(range)!; const start = Number(match[1]); const end = Math.min(Number(match[2]), bytes.length - 1); if (broken && start > 0 && fault === "whole-response") return route.fulfill({ body: bytes }); return route.fulfill({ status: 206, headers: { "content-range": `bytes ${broken && start > 0 && fault === "offset" ? 0 : start}-${end}/${bytes.length + (broken && start > 0 && fault === "total" ? 1 : 0)}`, }, body: bytes.subarray(start, end + (broken && start > 0 && fault === "truncated" ? 0 : 1)) }); }); await page.goto(root + "manifest.json"); const failed = await page.evaluate(async (base) => { const { createSceneResourceCache } = await import(base + "resourceCache.mjs"); try { await createSceneResourceCache(location.origin + base).load("range-probe.bin", "first"); return false; } catch { return true; } }, root); expect(failed).toBe(true); const before = requests; broken = false; const length = await page.evaluate(async (base) => { const { createSceneResourceCache } = await import(base + "resourceCache.mjs"); return (await createSceneResourceCache(location.origin + base).load("range-probe.bin", "first")).byteLength; }, root); expect(length).toBe(bytes.length); // The valid first segment survives; only the rejected final segment is fetched again. expect(requests - before).toBe(1); }); } test("下载中断后刷新页面,从已保存的分段继续下载", async ({ page }) => { const bytes = Buffer.alloc(2 * 1048576 + 16, 9); let disconnected = true; const ranges: string[] = []; await page.route("**/resume-probe.bin*", (route) => { const range = route.request().headers().range; ranges.push(range ?? "whole-file"); if (!range) return route.abort("failed"); const match = /^bytes=(\d+)-(\d+)$/.exec(range)!; const start = Number(match[1]); const end = Math.min(Number(match[2]), bytes.length - 1); if (disconnected && start > 0) return route.abort("failed"); return route.fulfill({ status: 206, headers: { "content-range": `bytes ${start}-${end}/${bytes.length}` }, body: bytes.subarray(start, end + 1) }); }); await page.goto(root + "manifest.json"); const load = (base: string) => page.evaluate(async (base) => { const { createSceneResourceCache } = await import(base + "resourceCache.mjs"); try { const data = await createSceneResourceCache(location.origin + base).load("resume-probe.bin", "first"); return { length: data.byteLength, failed: false }; } catch { return { failed: true }; } }, base); expect(await load(root)).toEqual({ failed: true }); expect(ranges.filter(range => range === "bytes=1048576-2097151")).toHaveLength(3); disconnected = false; ranges.length = 0; await page.reload(); expect(await load(root)).toEqual({ length: bytes.length, failed: false }); expect(ranges).toEqual(["bytes=1048576-2097151", "bytes=2097152-2097167"]); }); test("临时 HTTP 503 自动重试,成功后才写入缓存", async ({ page }) => { let requests = 0; await page.route("**/retry-probe.json*", (route) => { requests++; return requests < 3 ? route.fulfill({ status: 503, body: "unavailable" }) : route.fulfill({ json: { ok: true } }); }); await page.goto(root + "manifest.json"); const result = await page.evaluate(async (base) => { const { createSceneResourceCache } = await import(base + "resourceCache.mjs"); const stages: string[] = []; const cache = createSceneResourceCache(location.origin + base, ({ stage }: { stage: string }) => stages.push(stage)); const data = await cache.load("retry-probe.json", "first", (data: ArrayBuffer) => JSON.parse(new TextDecoder().decode(data))); return { data, retries: stages.filter(stage => stage === "retry").length }; }, root); expect(result).toEqual({ data: { ok: true }, retries: 2 }); expect(requests).toBe(3); }); test("服务器返回空的完整响应时只解析一次,不循环下载", async ({ page }) => { let requests = 0; await page.route("**/empty-probe.json*", (route) => { requests++; return route.fulfill({ body: "" }); }); await page.goto(root + "manifest.json"); const failed = await page.evaluate(async (base) => { const { createSceneResourceCache } = await import(base + "resourceCache.mjs"); try { await createSceneResourceCache(location.origin + base).load("empty-probe.json", "first", (data: ArrayBuffer) => JSON.parse(new TextDecoder().decode(data))); return false; } catch { return true; } }, root); expect(failed).toBe(true); expect(requests).toBe(1); });