返回 DeepSeek-Reasonix
check-motion-ci-contract.mjs
根目录 / desktop / frontend / scripts / check-motion-ci-contract.mjs
1 #!/usr/bin/env node
2
3 import { ciUnitScripts } from "./ci-test-plan.mjs";
4 import { existsSync, readFileSync } from "node:fs";
5 import { dirname, resolve } from "node:path";
6 import { fileURLToPath } from "node:url";
7
8 const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
9 const workflow = readFileSync(resolve(repoRoot, ".github/workflows/ci.yml"), "utf8");
10 const packageJSON = JSON.parse(readFileSync(resolve(repoRoot, "desktop/frontend/package.json"), "utf8"));
11 const appSource = readFileSync(resolve(repoRoot, "desktop/frontend/src/App.tsx"), "utf8");
12 const bridgeSource = readFileSync(resolve(repoRoot, "desktop/frontend/src/lib/bridge.ts"), "utf8");
13 const desktopMainSource = readFileSync(resolve(repoRoot, "desktop/main.go"), "utf8");
14 const transcriptScrollBenchSource = readFileSync(resolve(repoRoot, "desktop/frontend/bench/chat-transcript.mjs"), "utf8");
15 const transcriptPerformanceSource = readFileSync(resolve(repoRoot, "desktop/frontend/bench/transcript-performance.mjs"), "utf8");
16
17 function jobBody(name) {
18 const match = workflow.match(new RegExp(`\\n ${name}:\\n([\\s\\S]*?)(?=\\n [a-z][a-z0-9-]*:|$)`));
19 if (!match) throw new Error(`motion-ci-contract: could not locate ${name} job`);
20 return match[1];
21 }
22
23 for (const [job, body, command] of [
24 ["desktop-frontend", jobBody("desktop-frontend"), "node frontend/scripts/run-ci-tests.mjs"],
25 ["required lint", jobBody("lint"), "FRONTEND_RESULT: ${{ needs.desktop-frontend.result }}"],
26 ]) {
27 if (!body.includes(command)) {
28 throw new Error(`motion-ci-contract: ${job} must run test:motion`);
29 }
30 }
31 if (jobBody("lint-code").includes("test:motion") || jobBody("lint").includes("test:motion")) {
32 throw new Error("motion-ci-contract: test:motion must run only through the deduplicated frontend plan");
33 }
34
35 const windowsJob = jobBody("desktop-windows", "lint");
36 for (const required of [
37 "node packaging/package.mjs windows/amd64 v0.0.0-ci canary",
38 "Smoke-test Electron native startup",
39 "node packaging/smoke.mjs build/electron/windows-amd64/app --service build/bin/reasonix-desktop.exe",
40 ]) {
41 if (!windowsJob.includes(required)) {
42 throw new Error(`motion-ci-contract: desktop-windows must include ${required}`);
43 }
44 }
45 for (const retired of ["WebView2", "webview2", "test-transcript-selection"]) {
46 if (windowsJob.includes(retired)) {
47 throw new Error(`motion-ci-contract: desktop-windows must not reference the retired native smoke harness (${retired})`);
48 }
49 }
50 // Electron ships one Chromium on every OS, so the Playwright replays that
51 // desktop-frontend and desktop-browser run on ubuntu are the renderer
52 // evidence; the Windows leg keeps only the native Electron steps.
53 for (const linuxOnly of ["test:motion", "test:transcript-browser", "test:settings-browser"]) {
54 if (windowsJob.includes(`pnpm --dir frontend ${linuxOnly}`)) {
55 throw new Error(`motion-ci-contract: desktop-windows must not repeat the ubuntu Chromium suite ${linuxOnly}`);
56 }
57 }
58
59 for (const [path, source] of [
60 ["desktop/main.go", desktopMainSource],
61 ["desktop/frontend/src/App.tsx", appSource],
62 ["desktop/frontend/src/lib/bridge.ts", bridgeSource],
63 ]) {
64 for (const forbidden of [
65 "REASONIX_WEBVIEW2_APPROVAL_SMOKE",
66 "__REASONIX_WEBVIEW2_APPROVAL_SMOKE__",
67 "WebView2ApprovalSmokeBridge",
68 "__reasonixSelectionSmoke",
69 "reasonix_transcript_smoke",
70 ]) {
71 if (source.includes(forbidden)) {
72 throw new Error(`motion-ci-contract: ${path} must not embed test-only WebView2 instrumentation (${forbidden})`);
73 }
74 }
75 }
76 // The Wails-era native selection smoke (WebView2 host + contract script) left
77 // with the old shell; selection geometry is covered by the Chromium browser
78 // bench above and the packaged Electron startup smoke.
79 for (const retiredPath of [
80 "desktop/cmd/transcript-selection-smoke",
81 "desktop/cmd/transcript-native-smoke",
82 "desktop/transcript_selection_smoke_contract.js",
83 "scripts/test-transcript-selection-webview2.ps1",
84 "scripts/test-webview2-native-smoke.ps1",
85 ]) {
86 if (existsSync(resolve(repoRoot, retiredPath))) {
87 throw new Error(`motion-ci-contract: retired native smoke harness still exists: ${retiredPath}`);
88 }
89 }
90 for (const retiredPath of [
91 "desktop/webview2_approval_smoke.go",
92 "desktop/frontend/src/lib/useWebView2ApprovalSmoke.ts",
93 "desktop/frontend/src/lib/webView2ApprovalSmoke.ts",
94 "scripts/test-webview2-approval-smoke.ps1",
95 ]) {
96 if (existsSync(resolve(repoRoot, retiredPath))) {
97 throw new Error(`motion-ci-contract: retired production smoke path still exists: ${retiredPath}`);
98 }
99 }
100
101 const motionScript = packageJSON.scripts?.["test:motion"] ?? "";
102 for (const required of [
103 "check-waapi-contract.mjs --self-test",
104 "native-motion.test.tsx",
105 "approval-animation.test.tsx",
106 ]) {
107 if (!motionScript.includes(required)) {
108 throw new Error(`motion-ci-contract: test:motion must include ${required}`);
109 }
110 }
111
112 if (motionScript.includes("transcript-virtualization.test.tsx")) {
113 throw new Error("motion-ci-contract: test:motion must not include the transcript virtualization suite");
114 }
115
116 const motionBrowserCommand = "pnpm --dir frontend test:motion-browser";
117 const motionBrowserRuns = workflow.match(/pnpm --dir frontend test:motion-browser(?:\s|$)/g)?.length ?? 0;
118 if (!jobBody("desktop-browser-group").includes(motionBrowserCommand) || motionBrowserRuns !== 1) {
119 throw new Error("motion-ci-contract: the Linux browser job must run test:motion-browser exactly once");
120 }
121 if (!packageJSON.scripts?.["test:motion-browser"]?.includes("approval-animation.mjs")) {
122 throw new Error("motion-ci-contract: test:motion-browser must exercise the approval animation in real Chromium");
123 }
124
125 const transcriptScript = packageJSON.scripts?.["test:transcript"] ?? "";
126 for (const required of [
127 "chat-view-source.test.ts", "chat-scroll-controller.test.ts", "chat-natural-flow.test.tsx",
128 "chat-content-loader.test.ts", "markdown-natural-flow.test.tsx",
129 "typography-overflow-contract.test.ts", "markdown-pipeline.test.tsx", "transcript-store.test.ts",
130 ]) {
131 if (!transcriptScript.includes(required)) {
132 throw new Error(`motion-ci-contract: test:transcript must include ${required}`);
133 }
134 }
135
136 const transcriptBrowserScript = packageJSON.scripts?.["test:transcript-browser"] ?? "";
137 for (const required of ["chat-transcript.mjs"]) {
138 if (!transcriptBrowserScript.includes(required)) {
139 throw new Error(`motion-ci-contract: test:transcript-browser must include ${required}`);
140 }
141 }
142
143 // A near-zero `transition: all` still starts from the old value, so same-frame
144 // geometry reads miss transform/padding writes and the transcript guards
145 // compound. The global reduced-motion reset must remove transitions outright.
146 const stylesSource = readFileSync(resolve(repoRoot, "desktop/frontend/src/styles.css"), "utf8");
147 const globalReducedMotion = stylesSource.match(
148 /@media \(prefers-reduced-motion: reduce\) \{\s*\*,\s*\*::before,\s*\*::after \{([^}]*)\}/,
149 );
150 if (!globalReducedMotion) {
151 throw new Error("motion-ci-contract: styles.css must keep the global universal prefers-reduced-motion reset");
152 }
153 if (!globalReducedMotion[1].includes("transition: none !important")) {
154 throw new Error("motion-ci-contract: the global reduced-motion reset must use `transition: none !important`");
155 }
156 if (/transition-duration/.test(globalReducedMotion[1])) {
157 throw new Error("motion-ci-contract: the global reduced-motion reset must not shorten transitions (same-frame geometry reads would lag)");
158 }
159
160 if (!ciUnitScripts.includes("test:motion") || !ciUnitScripts.includes("test:transcript")) {
161 throw new Error("motion-ci-contract: Linux CI must include all dedicated motion and transcript suites");
162 }
163 const desktopLinuxJob = jobBody("desktop-browser-group");
164
165 const transcriptBrowserCommand = "pnpm --dir frontend test:transcript-browser";
166 const transcriptBrowserRuns = desktopLinuxJob.match(/pnpm --dir frontend test:transcript-browser(?:\s|$)/g)?.length ?? 0;
167 if (!desktopLinuxJob.includes(transcriptBrowserCommand) || transcriptBrowserRuns !== 1) {
168 throw new Error("motion-ci-contract: the Linux desktop job must run test:transcript-browser exactly once");
169 }
170 if (!desktopLinuxJob.includes("PLAYWRIGHT_BROWSERS_PATH=.pw-browsers pnpm --dir frontend exec playwright install")) {
171 throw new Error("motion-ci-contract: Chromium must install into the path used by frontend browser tests");
172 }
173 for (const required of ["chat-transcript.mjs"]) {
174 if (!packageJSON.scripts?.["test:transcript-browser"]?.includes(required)) {
175 throw new Error(`motion-ci-contract: test:transcript-browser must include ${required}`);
176 }
177 }
178 for (const required of [
179 "stream anchor drift", "prepend anchor drift", "native selection survives stream settlement",
180 "percentile(switches) <= 300",
181 "heapGrowth <= 20 * 1024 * 1024", "settled layout queue converges",
182 ]) {
183 if (!transcriptScrollBenchSource.includes(required)) {
184 throw new Error(`motion-ci-contract: transcript scroll browser gate must retain block-kernel assertion ${required}`);
185 }
186 }
187 for (const required of [
188 "INPUT_P95_LIMIT_MS = 200", "LONG_TASK_LIMIT_MS = 500", "attempts.length === 3",
189 "attempt.inputP95 > INPUT_P95_LIMIT_MS", "inputP95Median",
190 "passed-after-bounded-retry", "failed-sustained-regression",
191 ]) {
192 if (!transcriptPerformanceSource.includes(required)) {
193 throw new Error(`motion-ci-contract: transcript performance gate must retain ${required}`);
194 }
195 }
196
197 console.log("motion-ci-contract: browser gates and packaged Electron startup are separate release gates without production smoke instrumentation");
198
198 lines Plain Text