Files
TJWaterFrontend_Refine/e2e/three-dimensional-loading.spec.ts
T
jiang d013f34493
Generic Container CI/CD / test-build-publish (push) Successful in 1m4s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m4s
fix(scene): resume chunk downloads after proxy interruptions
Whole-file loading still failed after adding caching because the public proxy interrupted large GLB response bodies. Download validated 1 MiB ranges, retry transient failures up to three attempts, and persist completed chunks so reloads resume from their last boundary.

Add regression cases observed failing before the change for interrupted downloads, cross-reload resume, and temporary HTTP failures. Verify range integrity, full-cache reuse, permanent errors, and storage fallback.
2026-10-16 18:37:24 +08:00

305 lines
15 KiB
TypeScript

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<void>((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<typeof transaction>) {
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);
});