Skip to content

Commit 34fb857

Browse files
committed
test(motion): 加 GSAP 懒加载三件套守卫
1 parent 3978e2b commit 34fb857

2 files changed

Lines changed: 286 additions & 2 deletions

File tree

‎app/src/build/manualChunks.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -250,8 +250,8 @@ describe("接线面全量一致(manualChunks 不许有第二套逻辑)", ()
250250
});
251251
});
252252

253-
// Task 5(不装 GSAP):预留槽 + 循环 chunk 的**反例守卫**。槽位本身见 EXACT 的 gsap 两行。
254-
describe("批 6 预留:GSAP 必须落进独立 chunk(本批不安装 GSAP)", () => {
253+
// 批 6 已装 GSAP(T4 按 R15.4② 更正标签):验收动作 = 去构建一次核对 vendor-gsap 独立生成且只经 import() 到达;槽位见 EXACT 两行。
254+
describe("批 6 已装 GSAP:GSAP 必须落进独立 chunk(槽位在表 + package.json 已装)", () => {
255255
it("GSAP 的两个包名都归 vendor-gsap,且与 react 不同组", () => {
256256
const gsap = vendorGroupOf("/r/node_modules/gsap/index.js");
257257
const gsapReact = vendorGroupOf("/r/node_modules/@gsap/react/dist/index.js");
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
/**
2+
* engine.guard.test.ts — GSAP **懒加载三件套守卫**(R2.1 ①②③ · R11.1 闭包交集 · R11.5 产物用例 · R14.7-I-2 插件逐序)
3+
* + 控制方 2026-09-13 追加的 **③‴ 动态绕开禁令**(与静态闭包判据**互补**,二者缺一不可)。
4+
*
5+
* @ai-context 业务背景:规格 §13 风险表逐字要求「GSAP 独立 chunk 懒加载」。本文件把它变成**常驻机器判据** ——
6+
* T3 建了全仓唯一入口 `app/src/motion/engine.ts`(顶层 `registerPlugin`),本文件就是那把锁:谁把 GSAP 拉进
7+
* 首屏静态闭包 / 谁绕开 engine 直接用 GSAP / 谁改掉插件集合或顺序,谁就在这里变红。
8+
* @ai-context 判据本体(**不是白名单**;R11.1):「`app/src/**` 中**静态** import `gsap` / `@gsap/react` 的文件
9+
* 集合 ∩ **首屏静态可达闭包** = ∅」。importer 集合「恰等于 {motion/engine.ts}」只是**诊断读数**(易腐化,
10+
* 供失败定位),判据取**交集** ⇒ 这是**今天无条件可跑**的那一半(导出树 / 新克隆里也有牙)。
11+
* @ai-context ③‴:**动态** gsap 家族说明符(`import("gsap")` 等)只许出现在 `motion/engine.ts`。静态判据看不见
12+
* 「绕开 engine 的动态 import」—— 那种写法让 `registerPlugin` **从未执行**、插件静默退化(陷阱 B1-6),
13+
* 而 ①②③ 全绿。**判据域 = 生产文件**(`*.test.ts(x)` 除外):测试不进产物,且 T3 的 `engine.test.ts:104/:115`
14+
* 用 `await import("gsap/CustomEase")` **读**注册结果(观察 ≠ 绕开)。
15+
* @ai-context 产物判据 ①②(R11.5):`app/dist` **不入库** ⇒ 导出树里必然不运行,故取「`runIf(existsSync(dist))`
16+
* 用例 + **真实构建强制命令行** + 未运行计入报告**「未验证」单列**」三轨。🔴 **未运行 ≠ 通过**,本守卫不得
17+
* 静默通过。T3 已在导出树做过注入式正向实测(`void import("./motion/engine")` ⇒ `vendor-gsap-BQLVn3Z6.js`
18+
* 105,584 B / gzip 41,161 B,首屏仍 3 chunk 不含它)⇒ 机理已证;该 chunk **首次真实出现 = 第一个调用点落地时
19+
* (波 B/C)**。今天源图(静态∪动态)到不了 GSAP ⇒ ① 的「存在性分支」**未运行**(用例 `console.warn` 点名)。
20+
* @ai-context 插件集合与顺序(R14.7-I-2):ADR-035 `:48` 自称「恰 4 个、**顺序逐字**」⇒ 对拍必须是**逐序数组
21+
* 相等**(集合相等测不出纯换序)。四插件「真的注册了」的**行为级**判据在 `engine.test.ts`(B3/B4/B5)。
22+
* @ai-context 仪器:剥注释复用 `ui/primitives/sliceScan.ts` 的状态机(R8.7);静态图遍历是**正则级**(不认
23+
* tsconfig paths、不认非字面量 `import()`)—— 边界见任务报告。副作用:只读 `app/src` 与 `app/dist`;② 起一个
24+
* `check-bundle-budget --no-build` 子进程;遍历自证在 `os.tmpdir()` 建小树并自删,**不碰 `app/src`**。
25+
* @ai-context 🔴 本文件自己也在扫描域内 ⇒ 夹具里的 GSAP 说明符一律**拼接构造**(`fromSpec()`),源码文本里
26+
* **不得**出现字面量的 GSAP import 语句,否则本文件会把自己扫成 importer 而自伤。
27+
*/
28+
import { execFileSync } from "node:child_process";
29+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
30+
import { tmpdir } from "node:os";
31+
import { dirname, join, relative, resolve, sep } from "node:path";
32+
import { fileURLToPath } from "node:url";
33+
import { describe, expect, it } from "vitest";
34+
import { stripComments, walkSources } from "../ui/primitives/sliceScan";
35+
36+
const HERE = dirname(fileURLToPath(import.meta.url));
37+
const SRC = resolve(HERE, ".."); // app/src
38+
const ROOT = resolve(HERE, "..", "..", ".."); // 仓库根
39+
const ENTRY = join(SRC, "main.tsx");
40+
const ENGINE_REL = "motion/engine.ts";
41+
const CLASSROOM_REL = "pages/ClassroomPage.tsx";
42+
const DIST = join(ROOT, "app", "dist");
43+
const ADR = join(ROOT, "docs", "adr", "ADR-035-l4-motion-grammar-and-engine.md");
44+
const relTo = (base: string, file: string): string => relative(base, file).split(sep).join("/");
45+
46+
// ───────────────────────── 仪器 1:GSAP 说明符扫描(静态 + 动态,剥注释) ─────────────────────────
47+
48+
/** `import|export … from "<gsap 家族>"`;排除动态形态与 `import type`(类型边不进产物)。 */
49+
const RE_GSAP_FROM = /\b(?:import|export)\b(?!\s*\()\s(?!type\b)[^;]*?\bfrom\s*["'](gsap(?:\/[^"']*)?|@gsap\/react)["']/g;
50+
/** 裸 `import "<gsap 家族>"`(副作用式静态边)。 */
51+
const RE_GSAP_BARE = /\bimport\s*["'](gsap(?:\/[^"']*)?|@gsap\/react)["']/g;
52+
/** **动态** `import("<gsap 家族>")` —— 绕开 engine 的那条路(③‴ 的判据域)。 */
53+
const RE_GSAP_DYNAMIC = /\bimport\s*\(\s*["'](gsap(?:\/[^"']*)?|@gsap\/react)["']\s*\)/g;
54+
55+
function specsOf(code: string, res: readonly RegExp[]): string[] {
56+
const out: string[] = [];
57+
for (const re of res) {
58+
re.lastIndex = 0;
59+
let m: RegExpExecArray | null;
60+
while ((m = re.exec(code)) !== null) out.push(m[1]);
61+
}
62+
return out;
63+
}
64+
65+
/** 一段源码里的 GSAP **静态**说明符(剥注释后扫;纯函数,行为自证见下方 describe)。 */
66+
const gsapSpecifiers = (src: string): string[] => specsOf(stripComments(src), [RE_GSAP_FROM, RE_GSAP_BARE]);
67+
/** 一段源码里的 GSAP **动态**说明符。 */
68+
const dynamicGsapSpecifiers = (src: string): string[] => specsOf(stripComments(src), [RE_GSAP_DYNAMIC]);
69+
70+
// ───────────────────────── 仪器 2:静态图遍历(正则级) ─────────────────────────
71+
72+
const EXT = ["", ".ts", ".tsx", ".js", ".jsx", "/index.ts", "/index.tsx"];
73+
const RE_STATIC_FROM = /\b(?:import|export)\b(?!\s*\()\s(?!type\b)[^;]*?\bfrom\s*["']([^"']+)["']/g;
74+
const RE_STATIC_BARE = /\bimport\s*["']([^"']+)["']/g;
75+
const RE_DYNAMIC = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g;
76+
77+
function resolveRelative(fromFile: string, spec: string): string | null {
78+
if (!spec.startsWith(".")) return null;
79+
const base = resolve(dirname(fromFile), spec);
80+
for (const e of EXT) {
81+
const p = base + e;
82+
// 用 `!isDirectory()` 而不是 `Stats.isFile()`:本仓 `@types/node` 版本下后者报 TS2339(先例 `nativeButton.ratchet.test.ts:220`)
83+
if (existsSync(p) && !statSync(p).isDirectory()) return p;
84+
}
85+
return null;
86+
}
87+
88+
/** 从入口出发的可达文件集(绝对路径)。`dynamic: false` ⇒ 只沿**静态**边(= 首屏闭包口径)。 */
89+
function reachable(entry: string, opts: { dynamic: boolean }): Set<string> {
90+
const seen = new Set<string>();
91+
const queue: string[] = [entry];
92+
while (queue.length > 0) {
93+
const file = queue.shift() as string;
94+
if (seen.has(file)) continue;
95+
seen.add(file);
96+
let text: string;
97+
try {
98+
text = readFileSync(file, "utf8");
99+
} catch {
100+
continue;
101+
}
102+
const specs = specsOf(text, opts.dynamic ? [RE_STATIC_FROM, RE_STATIC_BARE, RE_DYNAMIC] : [RE_STATIC_FROM, RE_STATIC_BARE]);
103+
for (const spec of specs) {
104+
const r = resolveRelative(file, spec);
105+
if (r !== null && !seen.has(r)) queue.push(r);
106+
}
107+
}
108+
return seen;
109+
}
110+
111+
// ───────────────────────── 开工读数(模块级,只读一次) ─────────────────────────
112+
113+
const allSources = walkSources(SRC);
114+
/** ③‴ 的域 = 生产文件:测试不进产物,且 T3 的 `engine.test.ts` 用动态 gsap import **读**注册结果。 */
115+
const productionSources = allSources.filter((f) => !/\.test\.tsx?$/.test(f));
116+
const importerFiles = allSources
117+
.filter((f) => gsapSpecifiers(readFileSync(f, "utf8")).length > 0)
118+
.map((f) => relTo(SRC, f))
119+
.sort();
120+
const eagerClosure = new Set([...reachable(ENTRY, { dynamic: false })].map((f) => relTo(SRC, f)));
121+
const buildGraph = new Set([...reachable(ENTRY, { dynamic: true })].map((f) => relTo(SRC, f)));
122+
/** 「GSAP 会进构建」的源图口径:任一声明式 importer 落在静态∪动态可达集合里。 */
123+
const gsapInBuildGraph = importerFiles.some((f) => buildGraph.has(f));
124+
const hasDist = existsSync(join(DIST, "index.html"));
125+
const distJs = hasDist ? readdirSync(join(DIST, "assets")).filter((f) => f.endsWith(".js")) : [];
126+
const gsapChunks = distJs.filter((f) => /^vendor-gsap-/.test(f));
127+
128+
// 夹具构造:拼接出语句,源码文本里不出现字面量的 GSAP import(见文件头最后一条)
129+
const Q = '"';
130+
const fromSpec = (spec: string): string => "from " + Q + spec + Q + ";";
131+
const importOf = (pkg: string): string => "import { x } " + fromSpec(pkg);
132+
const typeImportOf = (pkg: string): string => "import type { X } " + fromSpec(pkg);
133+
const dynamicOf = (pkg: string): string => "const m = await import(" + Q + pkg + Q + ");";
134+
const PKG = "gsap";
135+
const PKG_REACT = "@gsap/react";
136+
137+
describe("仪器自证(防真空:喂已知样本必须命中、无意义串必须 0)", () => {
138+
it("说明符扫描器:静态具名 / 多行 / 裸 import / export…from / 子路径 与 动态形态 都必须命中", () => {
139+
expect(gsapSpecifiers(importOf(PKG))).toEqual([PKG]);
140+
expect(gsapSpecifiers(importOf(PKG_REACT))).toEqual([PKG_REACT]);
141+
expect(gsapSpecifiers(importOf(PKG + "/Flip"))).toEqual([PKG + "/Flip"]);
142+
expect(gsapSpecifiers("import {\n Flip,\n} " + fromSpec(PKG + "/CustomEase"))).toEqual([PKG + "/CustomEase"]);
143+
expect(gsapSpecifiers("import " + Q + PKG + Q + ";")).toEqual([PKG]);
144+
expect(gsapSpecifiers("export { x } " + fromSpec(PKG))).toEqual([PKG]);
145+
expect(dynamicGsapSpecifiers(dynamicOf(PKG))).toEqual([PKG]);
146+
expect(dynamicGsapSpecifiers(dynamicOf(PKG + "/ScrollToPlugin"))).toEqual([PKG + "/ScrollToPlugin"]);
147+
});
148+
149+
it("说明符扫描器:注释 / type-only / 无意义串 都必须 0 命中,且静态与动态互不串味", () => {
150+
expect(gsapSpecifiers("// " + importOf(PKG))).toEqual([]);
151+
expect(gsapSpecifiers("/* " + importOf(PKG) + " */")).toEqual([]);
152+
expect(gsapSpecifiers(typeImportOf(PKG))).toEqual([]);
153+
expect(gsapSpecifiers("export type { X } " + fromSpec(PKG_REACT))).toEqual([]);
154+
expect(gsapSpecifiers(importOf("gsap-core"))).toEqual([]);
155+
expect(gsapSpecifiers(importOf("not-gsap"))).toEqual([]);
156+
expect(dynamicGsapSpecifiers(importOf(PKG))).toEqual([]); // 静态 ≠ 动态
157+
expect(dynamicGsapSpecifiers("// " + dynamicOf(PKG))).toEqual([]);
158+
expect(dynamicGsapSpecifiers(dynamicOf("not-gsap"))).toEqual([]);
159+
expect(dynamicGsapSpecifiers("const s = 'gsap';")).toEqual([]);
160+
});
161+
162+
it("静态图遍历:只沿静态边(含传递边与目录 index),动态边只在 dynamic 口径下跟", () => {
163+
const dir = mkdtempSync(join(tmpdir(), "ed-t4-guard-"));
164+
try {
165+
const w = (name: string, text: string): void => {
166+
const p = join(dir, name);
167+
mkdirSync(dirname(p), { recursive: true });
168+
writeFileSync(p, text);
169+
};
170+
w("entry.ts", "import { a } " + fromSpec("./a") + "\n" + dynamicOf("./dyn"));
171+
w("a.ts", "import " + Q + "./sub" + Q + ";");
172+
w("sub/index.tsx", "export const s = 1;");
173+
w("dyn.ts", "import " + Q + "./dynChild" + Q + ";");
174+
w("dynChild.ts", "export const d = 2;");
175+
w("unreached.ts", "export const u = 3;");
176+
const names = (dynamic: boolean): string[] =>
177+
[...reachable(join(dir, "entry.ts"), { dynamic })].map((f) => relTo(dir, f)).sort();
178+
expect(names(false)).toEqual(["a.ts", "entry.ts", "sub/index.tsx"]);
179+
expect(names(true)).toEqual(["a.ts", "dyn.ts", "dynChild.ts", "entry.ts", "sub/index.tsx"]);
180+
expect(names(true)).not.toContain("unreached.ts");
181+
} finally {
182+
rmSync(dir, { recursive: true, force: true });
183+
}
184+
});
185+
});
186+
187+
describe("③ 懒加载边界(R2.1 判据 ③ → R11.1 的闭包交集;今天无条件可跑的那一半)", () => {
188+
it("③′ 诊断读数:静态 import GSAP 家族的文件集恰 = {motion/engine.ts},且说明符 ≥ 4(防真空)", () => {
189+
expect(importerFiles).toEqual([ENGINE_REL]);
190+
expect(gsapSpecifiers(readFileSync(join(SRC, ENGINE_REL), "utf8")).length).toBeGreaterThanOrEqual(4);
191+
});
192+
193+
it("③ 判据本体:静态 GSAP importer 集合 ∩ 首屏静态可达闭包 = ∅(含两个阳性对照)", () => {
194+
// 阳性对照:遍历必须能到达两个**已知在首屏**的文件 —— 否则「交集为空」可能只是遍历静默失效
195+
expect(eagerClosure.has("ui/primitives/index.ts"), "遍历到不了 ui/primitives/index.ts ⇒ 仪器失效,交集读数不可信").toBe(true);
196+
expect(eagerClosure.has(CLASSROOM_REL), "遍历到不了课堂页 ⇒ 仪器失效,交集读数不可信").toBe(true);
197+
const inter = importerFiles.filter((f) => eagerClosure.has(f));
198+
expect(inter, `交集非空 ⇒ GSAP 进了首屏静态闭包(vendor-gsap 独立懒 chunk 的承诺落空):${inter.join(", ")}`).toEqual([]);
199+
});
200+
201+
it("③″ 派生禁令(R11.1 连带③):课堂页(首屏静态页)自身零 GSAP 静态说明符", () => {
202+
expect(gsapSpecifiers(readFileSync(join(SRC, CLASSROOM_REL), "utf8"))).toEqual([]);
203+
});
204+
205+
it("③‴ 动态绕开禁令(控制方追加):生产文件里的**动态** gsap 说明符只许出现在 motion/engine.ts", () => {
206+
expect(productionSources.length, "域为空 ⇒ 判据恒真,先修域").toBeGreaterThan(100);
207+
const offenders = productionSources
208+
.map((f) => [relTo(SRC, f), dynamicGsapSpecifiers(readFileSync(f, "utf8"))] as const)
209+
.filter(([, hits]) => hits.length > 0)
210+
.map(([f]) => f)
211+
.sort();
212+
expect(offenders, `动态 gsap 绕开 engine ⇒ registerPlugin 从未执行(陷阱 B1-6 插件静默退化):${offenders.join(", ")}`).toEqual([]);
213+
});
214+
});
215+
216+
interface BudgetJson {
217+
firstScreen: { count: number; chunks: { name: string }[] };
218+
}
219+
220+
describe("①② 产物判据(R11.5:runIf(existsSync(dist));🔴 未运行 ≠ 通过)", () => {
221+
it.runIf(hasDist)(
222+
"① 产物独立性:源图到达 GSAP ⇔ 出现独立的 vendor-gsap-*.js(今天源图未到达 ⇒ 存在性分支未运行,见报告「未验证」单列)",
223+
() => {
224+
if (!gsapInBuildGraph) {
225+
// 今天的真相:源图(静态∪动态)零边到达 GSAP ⇒ 下面那条存在性断言今天**跑不到**(R11.5:不计入绿)
226+
console.warn(
227+
"[guard①] 存在性分支今天未运行:源图未到达任何 GSAP 静态 importer ⇒ 本分支计入报告「未验证」单列。" +
228+
"机理已证(T3 注入树:vendor-gsap-BQLVn3Z6.js 105,584 B / gzip 41,161 B,首屏 3 chunk 不含它);" +
229+
"首次真实出现 = 第一个调用点落地时(波 B/C)。",
230+
);
231+
} else {
232+
expect(gsapChunks, "源图已到达 GSAP ⇒ 产物必须出现 vendor-gsap-*.js;实得零个").not.toEqual([]);
233+
}
234+
// 反向(今天就跑):产物不得出现源图到不了的「幽灵 gsap chunk」—— 或源图漏判,或 dist 过期
235+
expect(
236+
gsapChunks.length > 0 && !gsapInBuildGraph,
237+
`产物出现 ${gsapChunks.join(", ")},但源图(静态∪动态)没有任何边到达 GSAP ⇒ 遍历漏判或 app/dist 过期`,
238+
).toBe(false);
239+
},
240+
);
241+
242+
it.runIf(hasDist)("② firstScreen.chunks 不含 vendor-gsap(仓库自己的 check-bundle-budget --no-build --json)", () => {
243+
const raw = execFileSync(process.execPath, [join(ROOT, "scripts", "check-bundle-budget.mjs"), "--no-build", "--json"], {
244+
cwd: ROOT,
245+
encoding: "utf8",
246+
maxBuffer: 8 * 1024 * 1024,
247+
});
248+
const budget = JSON.parse(raw) as BudgetJson;
249+
expect(budget.firstScreen.count, "首屏 chunk 读数为 0 ⇒ JSON 解析或产物异常,别把空读数当绿").toBeGreaterThan(0);
250+
const names = budget.firstScreen.chunks.map((c) => c.name);
251+
expect(names.filter((n) => n.includes("vendor-gsap")), `首屏出现 vendor-gsap:${names.join(", ")}`).toEqual([]);
252+
});
253+
});
254+
255+
/** `registerPlugin(...)` 的实参序列(只认**标识符**级实参;`…` 这类占位注释形态天然被过滤)。 */
256+
function pluginArgVariants(code: string): string[][] {
257+
const out: string[] = [];
258+
for (const m of code.matchAll(/registerPlugin\(([^)]*)\)/g)) {
259+
const args = m[1].split(",").map((s) => s.trim());
260+
if (args.length > 0 && args.every((a) => /^[A-Za-z_$][\w$]*$/.test(a))) out.push(JSON.stringify(args));
261+
}
262+
return [...new Set(out)].map((s) => JSON.parse(s) as string[]);
263+
}
264+
265+
describe("ADR-035 §2 ⇄ engine.ts 插件**逐序**数组相等(R14.7-I-2;ADR:48「恰 4 个、顺序逐字」)", () => {
266+
it("抽取仪器自证:标识符级实参、注释里的占位调用不算、无调用返回空", () => {
267+
expect(pluginArgVariants("gsap.registerPlugin(A, B);")).toEqual([["A", "B"]]);
268+
expect(pluginArgVariants("// registerPlugin(useGSAP, …)")).toEqual([]);
269+
expect(pluginArgVariants("const x = 1;")).toEqual([]);
270+
// 剥注释后只剩真调用(engine.ts 的头注里也写了同名调用 ⇒ 不剥注释会取到注释那份)
271+
expect(pluginArgVariants(stripComments("// registerPlugin(A, B)\ngsap.registerPlugin(A, C);"))).toEqual([["A", "C"]]);
272+
});
273+
274+
it("engine.ts 的 registerPlugin 实参序列 == ADR-035 §2 冻结快照的实参序列(逐序,恰 4 个)", () => {
275+
const fence = /```ts\r?\n([\s\S]*?)```/.exec(readFileSync(ADR, "utf8"));
276+
expect(fence, "ADR-035 §2 的 ts 冻结快照代码块不见了").not.toBeNull();
277+
const adrVariants = pluginArgVariants((fence as RegExpExecArray)[1]);
278+
const engineVariants = pluginArgVariants(stripComments(readFileSync(join(SRC, ENGINE_REL), "utf8")));
279+
expect(adrVariants, "ADR-035 §2 里出现互相不一致的插件序列(标题与代码块漂移)").toHaveLength(1);
280+
expect(engineVariants, "engine.ts 里出现互相不一致的插件序列").toHaveLength(1);
281+
expect(engineVariants[0]).toHaveLength(4);
282+
expect(engineVariants[0]).toEqual(adrVariants[0]);
283+
});
284+
});

0 commit comments

Comments
 (0)