Files
ttstd-infinite-canvas/web/test/director-repro-runtime.test.ts

449 lines
20 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, test } from "bun:test";
import { projectPrevisWebgl, readPrevisReproRuntime, releaseProbeContext, safeReproText } from "../src/lib/canvas/previs/previs-repro-runtime";
import { PREVIS_REPRO_LOCAL_MODEL_URL, PREVIS_REPRO_MATRIX, PREVIS_REPRO_MISSING_MODEL_URL, createPrevisReproScene, previsReproSceneIsOffline, injectPrevisReproModel } from "../src/lib/canvas/previs/previs-repro-fixture";
import { PREVIS_PLACEMENT_MARGIN, previsObjectFootprint } from "../src/lib/canvas/previs/previs-placement";
import type { PrevisObject } from "../src/types/previs";
const LEAK_PROBES = [
"https://cdn.example.com/actor.glb?token=abc123#frag",
"http://localhost:3000/api/tasks?authorization=Bearer%20xyz",
"authorization: Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig",
"cookie=session=deadbeef; Path=/",
"api_key=sk-live-0000",
"secret_key: hunter2",
"password=letmein",
];
/** XZ 占位是否相交(含 margin);Y 完全不参与,与放置模块同一判据。 */
function overlapsInjectedXZ(a: PrevisObject, b: PrevisObject) {
const fa = previsObjectFootprint(a);
const fb = previsObjectFootprint(b);
const dx = Math.abs(a.transform.position[0] - b.transform.position[0]);
const dz = Math.abs(a.transform.position[2] - b.transform.position[2]);
return dx < (fa.width + fb.width) / 2 + PREVIS_PLACEMENT_MARGIN && dz < (fa.depth + fb.depth) / 2 + PREVIS_PLACEMENT_MARGIN;
}
/** 构造一个可控的 WebGL 双替身:参数常量用小整数便于断言。 */
function makeGl(overrides: Partial<Record<string, unknown>> = {}) {
const values: Record<number, unknown> = {
1: "WebGL 2.0 (OpenGL ES 3.0 Chromium)",
2: "Google Inc. (Apple)",
3: "ANGLE (Apple, Apple M2 Pro, OpenGL 4.1)",
4: 16384,
5: 16384,
6: [32767, 32767],
};
return {
VERSION: 1,
VENDOR: 2,
RENDERER: 3,
MAX_TEXTURE_SIZE: 4,
MAX_RENDERBUFFER_SIZE: 5,
MAX_VIEWPORT_DIMS: 6,
getParameter: (name: number) => values[name],
getExtension: () => null,
...overrides,
};
}
describe("safeReproText", () => {
test("整段 URL 被替换为固定 [URL],不保留域名与路径", () => {
expect(safeReproText("https://cdn.example.com/a.glb?token=abc#frag")).toBe("[URL]");
expect(safeReproText("http://driver.example.com/x?k=v")).toBe("[URL]");
expect(safeReproText("ANGLE renderer https://driver.example.com/x?k=v build")).toBe("ANGLE renderer [URL] build");
for (const probe of ["https://cdn.example.com/a.glb?token=abc123#frag", "http://driver.example.com/x?k=v"]) {
const safe = safeReproText(probe);
expect(safe).not.toContain("cdn.example.com");
expect(safe).not.toContain("driver.example.com");
expect(safe).not.toContain("abc123");
expect(safe).not.toContain("token");
}
});
test("Bearer token 整体被遮蔽,密钥值不残留", () => {
const safe = safeReproText("Authorization: Bearer abc123");
expect(safe).toContain("[REDACTED]");
expect(safe).not.toContain("abc123");
const jwt = safeReproText("authorization: Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig");
expect(jwt).not.toContain("payload.sig");
expect(jwt).not.toContain("eyJhbGciOiJIUzI1NiJ9");
expect(safeReproText("bearer SECRETVALUE")).toBe("Bearer [REDACTED]");
});
test("key[:=]value 形态凭证不残留值", () => {
for (const [input, secret] of [
["api_key=sk-live-0000", "sk-live-0000"],
["cookie=session=deadbeef; Path=/", "deadbeef"],
["secret_key: hunter2", "hunter2"],
["password=letmein", "letmein"],
["token=abc123", "abc123"],
]) {
const safe = safeReproText(input);
expect(safe).toContain("[REDACTED]");
expect(safe).not.toContain(secret);
}
});
test("裸 query/hash 片段同样不保留", () => {
expect(safeReproText("renderer?token=abc")).toBe("renderer");
expect(safeReproText("renderer#secret")).toBe("renderer");
});
test("限长并压缩空白", () => {
expect(safeReproText("a".repeat(500)).length).toBe(160);
expect(safeReproText("a".repeat(500), 32).length).toBe(32);
expect(safeReproText(" a b \n c ")).toBe("a b c");
});
test("非字符串与空值稳定返回空串", () => {
for (const bad of [null, undefined, 42, {}, []]) expect(safeReproText(bad)).toBe("");
});
});
describe("runtime 投影", () => {
test("字段齐全且 DPR 有界", () => {
const runtime = readPrevisReproRuntime();
expect(typeof runtime.appVersion).toBe("string");
expect(typeof runtime.buildCommit).toBe("string");
expect(typeof runtime.browser).toBe("string");
expect(typeof runtime.os).toBe("string");
expect(typeof runtime.timezone).toBe("string");
expect(runtime.devicePixelRatio).toBeGreaterThanOrEqual(0.1);
expect(runtime.devicePixelRatio).toBeLessThanOrEqual(8);
});
test("所有字符串都已限长", () => {
const runtime = readPrevisReproRuntime();
expect(runtime.appVersion.length).toBeLessThanOrEqual(48);
expect(runtime.buildCommit.length).toBeLessThanOrEqual(48);
expect(runtime.browser.length).toBeLessThanOrEqual(160);
expect(runtime.os.length).toBeLessThanOrEqual(48);
expect(runtime.timezone.length).toBeLessThanOrEqual(64);
});
});
describe("WebGL 投影", () => {
test("可用上下文产出安全能力数值", () => {
const webgl = projectPrevisWebgl(makeGl());
expect(webgl.available).toBe(true);
if (!webgl.available) return;
expect(webgl.version).toContain("WebGL 2.0");
expect(webgl.vendor).toContain("Google");
expect(webgl.maxTextureSize).toBe(16384);
expect(webgl.maxRenderbufferSize).toBe(16384);
expect(webgl.maxViewportWidth).toBe(32767);
expect(webgl.maxViewportHeight).toBe(32767);
});
test("缺失上下文稳定降级为 unsupported", () => {
for (const bad of [null, undefined, 42, "webgl", {}]) {
expect(projectPrevisWebgl(bad)).toEqual({ available: false, reason: "unsupported" });
}
});
test("getParameter 抛错降级为 context-failed 而不外泄异常", () => {
const gl = makeGl({
getParameter: () => {
throw new Error("context lost");
},
});
expect(() => projectPrevisWebgl(gl)).not.toThrow();
expect(projectPrevisWebgl(gl)).toEqual({ available: false, reason: "context-failed" });
});
test("getExtension 抛错不影响整体可用性", () => {
const gl = makeGl({
getExtension: () => {
throw new Error("no extension");
},
});
const webgl = projectPrevisWebgl(gl);
expect(webgl.available).toBe(true);
if (!webgl.available) return;
expect(webgl.vendor).toContain("Google");
});
test("UNMASKED 值优先但仍经过安全文本处理", () => {
const values: Record<number, unknown> = { 7: "Apple?token=abc", 8: "Apple M2 Pro https://driver.example.com/x?k=v" };
const gl = makeGl({
getExtension: () => ({ UNMASKED_VENDOR_WEBGL: 7, UNMASKED_RENDERER_WEBGL: 8 }),
getParameter: (name: number) => (name in values ? values[name] : makeGl().getParameter(name)),
});
const webgl = projectPrevisWebgl(gl);
expect(webgl.available).toBe(true);
if (!webgl.available) return;
expect(webgl.vendor).toBe("Apple");
expect(webgl.renderer).not.toContain("?k=v");
expect(webgl.renderer).not.toContain("token");
});
test("非有限能力数值收敛为 0(未知),绝不产出 NaN/Infinity", () => {
const gl = makeGl({
getParameter: (name: number) => (name === 4 ? Number.NaN : name === 5 ? Number.POSITIVE_INFINITY : name === 6 ? "not-an-array" : makeGl().getParameter(name)),
});
const webgl = projectPrevisWebgl(gl);
expect(webgl.available).toBe(true);
if (!webgl.available) return;
// 非有限一律当「未知」记 0,而不是编造一个驱动从未声明的能力上限。
expect(Number.isFinite(webgl.maxTextureSize)).toBe(true);
expect(Number.isFinite(webgl.maxRenderbufferSize)).toBe(true);
expect(webgl.maxTextureSize).toBe(0);
expect(webgl.maxRenderbufferSize).toBe(0);
expect(webgl.maxViewportWidth).toBe(0);
expect(webgl.maxViewportHeight).toBe(0);
});
test("超大但有限的能力数值被夹到上界", () => {
const gl = makeGl({
getParameter: (name: number) => (name === 4 ? 9_999_999 : name === 6 ? [9_999_999, -5] : makeGl().getParameter(name)),
});
const webgl = projectPrevisWebgl(gl);
expect(webgl.available).toBe(true);
if (!webgl.available) return;
expect(webgl.maxTextureSize).toBe(1_048_576);
expect(webgl.maxViewportWidth).toBe(1_048_576);
// 负值夹到下界 0。
expect(webgl.maxViewportHeight).toBe(0);
});
test("伪造的敏感 vendor/renderer 不会原样进入输出", () => {
for (const probe of LEAK_PROBES) {
const gl = makeGl({ getParameter: (name: number) => (name === 2 || name === 3 ? probe : makeGl().getParameter(name)) });
const webgl = projectPrevisWebgl(gl);
expect(webgl.available).toBe(true);
if (!webgl.available) continue;
const text = webgl.vendor + " " + webgl.renderer;
for (const secret of ["abc123", "payload.sig", "cdn.example.com", "driver.example.com", "token=abc", "sk-live-0000", "deadbeef", "hunter2", "letmein", "#frag", "eyJhbGciOiJIUzI1NiJ9"]) {
expect(text).not.toContain(secret);
}
}
});
test("探测后归还上下文:支持扩展时调用 loseContext", () => {
let losed = 0;
releaseProbeContext({
getExtension: (name: string) =>
name === "WEBGL_lose_context"
? {
loseContext: () => {
losed += 1;
},
}
: null,
});
expect(losed).toBe(1);
});
test("扩展不可用或抛错时归还是无操作,不外泄异常", () => {
expect(() => releaseProbeContext({ getExtension: () => null })).not.toThrow();
expect(() => releaseProbeContext({ getExtension: () => ({}) })).not.toThrow();
expect(() =>
releaseProbeContext({
getExtension: () => {
throw new Error("no extension");
},
}),
).not.toThrow();
expect(() =>
releaseProbeContext({
getExtension: () => ({
loseContext: () => {
throw new Error("lose failed");
},
}),
}),
).not.toThrow();
for (const bad of [null, undefined, 42, {}, "gl"]) {
expect(() => releaseProbeContext(bad)).not.toThrow();
}
});
});
describe("fixture 确定性与离线性", () => {
test("两次构造结构完全一致", () => {
expect(createPrevisReproScene()).toEqual(createPrevisReproScene());
});
test("返回全新对象,改写不影响后续构造", () => {
const first = createPrevisReproScene();
first.objects[0].transform.position[0] = 99;
first.title = "被改写";
const second = createPrevisReproScene();
expect(second.objects[0].transform.position[0]).toBe(0);
expect(second.title).toBe("P0 复现场景");
});
test("不含任何远端资产:无 url / storageKey / assetId", () => {
const scene = createPrevisReproScene();
expect(previsReproSceneIsOffline(scene)).toBe(true);
const serialized = JSON.stringify(scene);
expect(serialized).not.toContain("http");
expect(serialized).not.toContain(".glb");
expect(serialized).not.toContain("cdn");
});
test("场景结构可操作:有对象、摄影机、灯光、镜头且 activeShotId 自洽", () => {
const scene = createPrevisReproScene();
expect(scene.objects.length).toBeGreaterThanOrEqual(3);
expect(scene.cameras.length).toBeGreaterThanOrEqual(1);
expect(scene.lights.length).toBeGreaterThanOrEqual(1);
expect(scene.shots.length).toBeGreaterThanOrEqual(1);
expect(scene.shots.some((shot) => shot.id === scene.activeShotId)).toBe(true);
expect(scene.shots.every((shot) => scene.cameras.some((camera) => camera.id === shot.cameraId))).toBe(true);
expect(scene.objects.every((object) => object.transform.position.every((value) => Number.isFinite(value)))).toBe(true);
});
test("对象初始不重叠,便于复现连续新增语义", () => {
const scene = createPrevisReproScene();
const xs = scene.objects.map((object) => object.transform.position[0]);
expect(new Set(xs).size).toBe(xs.length);
});
test("复现矩阵覆盖全部 P0 场景且 id 唯一", () => {
const ids = PREVIS_REPRO_MATRIX.map((item) => item.id);
expect(new Set(ids).size).toBe(ids.length);
for (const required of [
"empty-click",
"right-click",
"switch-object",
"release-outside",
"blur",
"escape",
"playing",
"non-zero-time",
"autokey-on",
"autokey-off",
"add-move-delete-undo",
"delete-while-loading",
"model-load-failed",
"save-failed",
"close-recovery",
]) {
expect(ids).toContain(required);
}
for (const item of PREVIS_REPRO_MATRIX) {
expect(item.title.length).toBeGreaterThan(0);
expect(item.steps.length).toBeGreaterThan(0);
expect(item.expected.length).toBeGreaterThan(0);
}
});
test("矩阵模型两行是可执行步骤,不再是待办", () => {
const rows = PREVIS_REPRO_MATRIX.filter((item) => item.id === "delete-while-loading" || item.id === "model-load-failed");
expect(rows).toHaveLength(2);
for (const row of rows) {
expect(row.steps).not.toContain("需注入");
expect(row.steps).not.toContain("Stage D");
}
expect(PREVIS_REPRO_MATRIX.find((item) => item.id === "delete-while-loading")?.steps).toContain("注入本地模型");
expect(PREVIS_REPRO_MATRIX.find((item) => item.id === "model-load-failed")?.steps).toContain("注入缺失模型");
});
});
describe("模型注入", () => {
test("local 变体使用本地 public 资产地址", () => {
const scene = injectPrevisReproModel(createPrevisReproScene(), "local");
const model = scene.objects.find((object) => object.id === "repro-model-local");
expect(model?.url).toBe(PREVIS_REPRO_LOCAL_MODEL_URL);
expect(model?.url).toBe("/canvas/models/previs-repro-triangle.gltf");
expect(model?.mimeType).toBe("model/gltf+json");
expect(model?.name).toBe("本地模型 repro triangle");
// 无压缩扩展/无纹理,因此不需要 KTX2Loader 或 DRACOLoader。
expect(model?.url).not.toContain(".glb");
});
test("missing 变体使用同源不可达地址", () => {
const scene = injectPrevisReproModel(createPrevisReproScene(), "missing");
const model = scene.objects.find((object) => object.id === "repro-model-missing");
expect(model?.url).toBe(PREVIS_REPRO_MISSING_MODEL_URL);
expect(model?.url).toBe("/__previs-repro-missing.glb");
// 同源相对路径,不依赖外网可达性。
expect(model?.url?.startsWith("/")).toBe(true);
});
test("两个变体都是 model kind 且 id 稳定", () => {
for (const [variant, id] of [
["local", "repro-model-local"],
["missing", "repro-model-missing"],
] as const) {
const scene = injectPrevisReproModel(createPrevisReproScene(), variant);
const model = scene.objects.find((object) => object.id === id);
expect(model).toBeDefined();
expect(model?.kind).toBe("model");
expect(model?.primitive).toBeUndefined();
}
});
test("模型 Y 保持 0(贴地),只改 XZ", () => {
for (const variant of ["local", "missing"] as const) {
const scene = injectPrevisReproModel(createPrevisReproScene(), variant);
const model = scene.objects.find((object) => object.kind === "model");
expect(model?.transform.position[1]).toBe(0);
expect(model?.transform.position.every((value) => Number.isFinite(value))).toBe(true);
}
});
test("纯函数:不改写入参 scene", () => {
const original = createPrevisReproScene();
const before = JSON.stringify(original);
injectPrevisReproModel(original, "local");
injectPrevisReproModel(original, "missing");
expect(JSON.stringify(original)).toBe(before);
expect(original.objects.some((object) => object.kind === "model")).toBe(false);
});
test("重复注入同一变体只保留一个对象", () => {
let scene = injectPrevisReproModel(createPrevisReproScene(), "local");
const afterFirst = scene.objects.length;
scene = injectPrevisReproModel(scene, "local");
scene = injectPrevisReproModel(scene, "local");
expect(scene.objects.filter((object) => object.id === "repro-model-local")).toHaveLength(1);
expect(scene.objects.length).toBe(afterFirst);
});
test("先 local 再 missing:两个都在且位置不相交", () => {
const scene = injectPrevisReproModel(injectPrevisReproModel(createPrevisReproScene(), "local"), "missing");
const local = scene.objects.find((object) => object.id === "repro-model-local");
const missing = scene.objects.find((object) => object.id === "repro-model-missing");
expect(local).toBeDefined();
expect(missing).toBeDefined();
if (!local || !missing) return;
const others = scene.objects.filter((object) => object.id !== missing.id);
for (const other of others) {
expect(overlapsInjectedXZ(missing, other)).toBe(false);
}
expect(overlapsInjectedXZ(local, missing)).toBe(false);
});
test("注入不与初始 primitive 重叠", () => {
const scene = injectPrevisReproModel(createPrevisReproScene(), "local");
const model = scene.objects.find((object) => object.id === "repro-model-local");
expect(model).toBeDefined();
if (!model) return;
for (const other of scene.objects.filter((object) => object.id !== model.id)) {
expect(overlapsInjectedXZ(model, other)).toBe(false);
}
});
test("初始 fixture 离线,注入后不再声称离线", () => {
const base = createPrevisReproScene();
expect(previsReproSceneIsOffline(base)).toBe(true);
expect(previsReproSceneIsOffline(injectPrevisReproModel(base, "local"))).toBe(false);
expect(previsReproSceneIsOffline(injectPrevisReproModel(base, "missing"))).toBe(false);
});
test("注入会推进 updatedAt,使保存链路视为真实变化", () => {
const base = createPrevisReproScene();
const injected = injectPrevisReproModel(base, "local");
expect(injected.updatedAt >= base.updatedAt).toBe(true);
expect(injected.id).toBe(base.id);
});
test("矩阵条数不因注入功能变化", () => {
expect(PREVIS_REPRO_MATRIX).toHaveLength(15);
});
});