| 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 |