@vgai/editor 0.2.0 → 0.4.0-canary.20260715.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (430) hide show
  1. package/README.md +24 -0
  2. package/dist/assets/axe-C7YBtOuM.js +30 -0
  3. package/dist/assets/browserAll-DDtGGE48.js +1 -0
  4. package/dist/assets/create-runtime-DbTlBlKG.js +1 -0
  5. package/dist/assets/{game-BI111o5f.js → game-BE0r7DMI.js} +3 -3
  6. package/dist/assets/game-BdTHR2_X.js +1 -0
  7. package/dist/assets/{game-pt_1lQLv.js → game-C3iQGSqo.js} +1 -1
  8. package/dist/assets/game-CcHfYxjP.js +1 -0
  9. package/dist/assets/game-Cqzj_lpD.js +1 -0
  10. package/dist/assets/game-DSZdeCHu.js +1 -0
  11. package/dist/assets/game-Dch9sAPc.js +1 -0
  12. package/dist/assets/{game-xsOho_wg.js → game-SAFn90pA.js} +3 -3
  13. package/dist/assets/game-loop-DMIgSMV-.js +1 -0
  14. package/dist/assets/{game-source-CvidN7AB.js → game-source-Dk83w26t.js} +1 -1
  15. package/dist/assets/{index-Bp5cecD6.js → index-4idIsHHJ.js} +2 -2
  16. package/dist/assets/{index-DL7RcQ0n.js → index-C-LhOTBN.js} +1 -1
  17. package/dist/assets/{index-Cs8xwv3e.js → index-CDkxceqE.js} +1 -1
  18. package/dist/assets/index-CQEfi217.js +15412 -0
  19. package/dist/assets/index-Dvi-V0NE.css +1 -0
  20. package/dist/assets/ingest-siblings-s0P5I1Ub.js +1 -0
  21. package/dist/assets/mount-manifest-DXMOlF79.js +1 -0
  22. package/dist/assets/react-18-C9eHn8x1.js +1 -0
  23. package/dist/assets/webworkerAll-Fn0e-ouF.js +1 -0
  24. package/dist/assets/world-documents-GM0a9Kv7.js +1 -0
  25. package/dist/index.html +15 -13
  26. package/dist/ingest/racing-game/vgai.game.json +5 -4
  27. package/dist/ingest/simcity/vgai.game.json +5 -4
  28. package/dist/ingest/tanks/vgai.game.json +5 -4
  29. package/dist/project-scripts.bundle.js +3917 -0
  30. package/dist/scenes/untitled.vscn.json +2 -0
  31. package/dist-server/packaged.mjs +7697 -3435
  32. package/package.json +31 -4
  33. package/scripts/probe-reporter.ts +349 -0
  34. package/scripts/run-probe.ts +2260 -0
  35. package/server/asset-catalog-audit.ts +268 -0
  36. package/server/asset-catalog-v2.ts +146 -0
  37. package/server/asset-library-routes.ts +276 -58
  38. package/server/canonical-path.ts +1 -1
  39. package/server/dev.ts +148 -44
  40. package/server/editor-server.ts +888 -61
  41. package/server/editor-sse.ts +115 -4
  42. package/server/harness-chat-service.ts +796 -0
  43. package/server/local-asset-catalog.ts +157 -22
  44. package/server/packaged.ts +51 -21
  45. package/server/process-shutdown.ts +136 -0
  46. package/server/prod.ts +9 -6
  47. package/server/project-hmr-files.ts +41 -0
  48. package/server/project-operations.ts +343 -0
  49. package/server/project-optimize-deps-entries.ts +4 -4
  50. package/server/project-output-writer.ts +156 -0
  51. package/server/project-validation.ts +140 -0
  52. package/server/project-view.ts +8 -50
  53. package/server/server-utils.ts +75 -10
  54. package/server/session-registry.ts +12 -0
  55. package/src/EditorContext.tsx +107 -3
  56. package/src/action-registry.ts +115 -17
  57. package/src/adapter-resolver.ts +213 -478
  58. package/src/asset-events.ts +64 -1
  59. package/src/asset-preview.ts +906 -0
  60. package/src/asset-selection.ts +121 -0
  61. package/src/asset-workflow/asset-capabilities.ts +259 -0
  62. package/src/asset-workflow/asset-editor-errors.ts +46 -0
  63. package/src/asset-workflow/asset-import-pipeline.ts +176 -0
  64. package/src/asset-workflow/asset-library-query.ts +107 -0
  65. package/src/asset-workflow/asset-types.ts +192 -0
  66. package/src/asset-workflow/asset-workflow-quality.ts +65 -0
  67. package/src/asset-workflow/import-contract.ts +113 -0
  68. package/src/asset-workflow/model-conversion.ts +126 -0
  69. package/src/asset-workflow/model-inspection.ts +185 -0
  70. package/src/asset-workflow/project-asset-commands.ts +23 -0
  71. package/src/asset-workflow/project-asset-health.ts +153 -0
  72. package/src/asset-workflow/project-asset-operations.ts +119 -0
  73. package/src/asset-workflow/thumbnail-system.ts +172 -0
  74. package/src/authoring/active-systems.ts +15 -0
  75. package/src/authoring/boundary-authoring-adapter.ts +16 -5
  76. package/src/authoring/composite-authoring-adapter.ts +305 -124
  77. package/src/authoring/design-time-layers.ts +300 -117
  78. package/src/authoring/edit-mode-authoring.ts +278 -293
  79. package/src/authoring/embed-only-adapter.ts +4 -2
  80. package/src/authoring/first-party-hierarchy-menu.ts +7 -2
  81. package/src/authoring/first-party-inspector/AddSectionMenu.tsx +5 -12
  82. package/src/authoring/first-party-inspector/EnvironmentSection.tsx +53 -126
  83. package/src/authoring/first-party-inspector/HeaderSection.tsx +54 -65
  84. package/src/authoring/first-party-inspector/is-first-party-selection.ts +30 -11
  85. package/src/authoring/first-party-inspector/register.tsx +13 -2
  86. package/src/authoring/ingest-authoring-adapter.ts +142 -45
  87. package/src/authoring/mount-failure-report.ts +5 -5
  88. package/src/authoring/provenance.ts +41 -0
  89. package/src/authoring/react-dom-authoring-adapter.ts +92 -92
  90. package/src/authoring/react-inspector-section.tsx +489 -51
  91. package/src/authoring/react-world-authoring-adapter.ts +541 -306
  92. package/src/authoring/story-overlay.ts +1 -1
  93. package/src/authoring/story-types.ts +11 -0
  94. package/src/authoring/vgai-scene-authoring-adapter.ts +54 -25
  95. package/src/authoring/viewport-tool-context.ts +78 -0
  96. package/src/authoring/world-canvas-viewport-state.ts +35 -0
  97. package/src/authoring/world-hidden-viewport.ts +186 -0
  98. package/src/authoring/world-pan-state.ts +33 -17
  99. package/src/authoring/world-session-state.ts +1 -1
  100. package/src/authoring/world2d-authoring-adapter.ts +109 -52
  101. package/src/availability-tick.ts +56 -0
  102. package/src/browser-play-entry.ts +5 -5
  103. package/src/browser-project-scripts.ts +1 -0
  104. package/src/build-identity.ts +15 -0
  105. package/src/command-listener.ts +893 -19
  106. package/src/components/AlignToolbar.tsx +74 -63
  107. package/src/components/AppRoot.tsx +193 -26
  108. package/src/components/ApplicationMenus.tsx +272 -0
  109. package/src/components/AssetBrowser.tsx +1450 -293
  110. package/src/components/AssetEditorShell.tsx +141 -0
  111. package/src/components/AssetImportDetails.tsx +218 -0
  112. package/src/components/AssetLibraryPanel.tsx +14 -0
  113. package/src/components/CameraInfo.tsx +11 -20
  114. package/src/components/CenterDocuments.tsx +585 -0
  115. package/src/components/CommandPalette.tsx +75 -30
  116. package/src/components/ConsolePanel.css +55 -0
  117. package/src/components/ConsolePanel.tsx +57 -112
  118. package/src/components/DebugConsolePanel.tsx +461 -0
  119. package/src/components/DebuggerPanel.tsx +165 -0
  120. package/src/components/DefaultEditorLayout.tsx +167 -231
  121. package/src/components/GameHierarchy.tsx +873 -290
  122. package/src/components/GamePerformanceOverlay.tsx +46 -0
  123. package/src/components/HarnessChatPanel.tsx +2352 -0
  124. package/src/components/HistoryPanel.tsx +226 -0
  125. package/src/components/Inspector.tsx +153 -99
  126. package/src/components/InspectorToolSection.tsx +6 -4
  127. package/src/components/MountFailureBanner.tsx +31 -48
  128. package/src/components/NetworkSection.tsx +25 -14
  129. package/src/components/OnlineAssetBrowser.tsx +794 -343
  130. package/src/components/OverlayReportBanner.tsx +38 -64
  131. package/src/components/PerformancePanel.tsx +206 -0
  132. package/src/components/PlayBar.tsx +93 -263
  133. package/src/components/PrefabSaveDialog.tsx +55 -127
  134. package/src/components/ProjectHeader.css +60 -0
  135. package/src/components/ProjectHeader.tsx +170 -105
  136. package/src/components/ProjectScreen.tsx +246 -184
  137. package/src/components/ReactCanvasControls.tsx +185 -0
  138. package/src/components/ResolutionPicker.tsx +85 -0
  139. package/src/components/RootActivateDialog.tsx +39 -68
  140. package/src/components/SaveDialog.tsx +55 -127
  141. package/src/components/SaveStatus.tsx +11 -29
  142. package/src/components/StartupLoadingScreen.tsx +96 -0
  143. package/src/components/StateWatchPanel.tsx +302 -0
  144. package/src/components/StatsOverlay.tsx +12 -5
  145. package/src/components/StatusBar.tsx +64 -0
  146. package/src/components/ThemeManagerDialog.css +89 -0
  147. package/src/components/ThemeManagerDialog.tsx +371 -0
  148. package/src/components/ToolHost.tsx +26 -15
  149. package/src/components/Toolbar.tsx +117 -214
  150. package/src/components/VgaiVoxelLogo.tsx +89 -0
  151. package/src/components/ViewportOverlay.tsx +97 -172
  152. package/src/components/ViewportPanel.tsx +130 -31
  153. package/src/components/WorkspaceDock.tsx +891 -0
  154. package/src/components/WorldSelectionOverlay.tsx +51 -63
  155. package/src/components/WorldTextEditor.tsx +9 -5
  156. package/src/components/asset-documents.tsx +390 -0
  157. package/src/components/asset-editor-persistence.ts +44 -0
  158. package/src/components/asset-selection-section.tsx +338 -0
  159. package/src/components/asset-viewers/AudioViewer.tsx +73 -21
  160. package/src/components/asset-viewers/GenericJsonViewer.tsx +26 -11
  161. package/src/components/asset-viewers/ImageViewer.tsx +43 -11
  162. package/src/components/asset-viewers/InputMapViewer.tsx +512 -134
  163. package/src/components/asset-viewers/MaterialAssetEditor.tsx +540 -0
  164. package/src/components/asset-viewers/ModelViewer.tsx +805 -121
  165. package/src/components/asset-viewers/OnlineAssetDetail.tsx +177 -60
  166. package/src/components/asset-viewers/SourceAssetViewer.tsx +121 -0
  167. package/src/components/asset-workflow.css +506 -0
  168. package/src/components/build-documents.tsx +696 -0
  169. package/src/components/core-utilities.tsx +75 -0
  170. package/src/components/data-documents.tsx +364 -0
  171. package/src/components/{DataPanel.tsx → data-editor.tsx} +138 -288
  172. package/src/components/debugger-panel-state.ts +57 -0
  173. package/src/components/inspector-widgets/AlignmentGrid.tsx +8 -14
  174. package/src/components/inspector-widgets/BorderEditor.tsx +41 -33
  175. package/src/components/inspector-widgets/ColorPicker.tsx +39 -39
  176. package/src/components/inspector-widgets/FontPicker.tsx +13 -21
  177. package/src/components/inspector-widgets/GradientEditor.tsx +11 -19
  178. package/src/components/inspector-widgets/ScrubbableInput.tsx +6 -3
  179. package/src/components/inspector-widgets/ShadowEditor.tsx +20 -33
  180. package/src/components/inspector-widgets/shared.tsx +90 -49
  181. package/src/components/inspectors/AnimationSection.tsx +39 -40
  182. package/src/components/inspectors/AudioSection.tsx +28 -39
  183. package/src/components/inspectors/BoneAttachmentSection.tsx +19 -24
  184. package/src/components/inspectors/CameraSection.tsx +14 -23
  185. package/src/components/inspectors/ComponentsSection.tsx +39 -43
  186. package/src/components/inspectors/JointsSection.tsx +34 -43
  187. package/src/components/inspectors/LightSection.tsx +90 -35
  188. package/src/components/inspectors/MaterialSection.tsx +137 -174
  189. package/src/components/inspectors/MeshSection.tsx +9 -17
  190. package/src/components/inspectors/NavigationSection.tsx +8 -15
  191. package/src/components/inspectors/ParticleSection.tsx +91 -76
  192. package/src/components/inspectors/PhysicsSection.tsx +42 -46
  193. package/src/components/inspectors/PostProcessingSection.tsx +28 -36
  194. package/src/components/inspectors/RenderingSection.tsx +12 -20
  195. package/src/components/inspectors/ShadowSection.tsx +7 -8
  196. package/src/components/inspectors/SplineSection.tsx +31 -42
  197. package/src/components/inspectors/TransformSection.tsx +14 -22
  198. package/src/components/patterns/Dialog.tsx +95 -0
  199. package/src/components/patterns/Fields.tsx +44 -0
  200. package/src/components/patterns/List.tsx +25 -0
  201. package/src/components/patterns/StateSurface.tsx +40 -0
  202. package/src/components/patterns/Surfaces.tsx +115 -0
  203. package/src/components/patterns/Tabs.tsx +80 -0
  204. package/src/components/patterns/Toolbar.tsx +51 -0
  205. package/src/components/patterns/Tree.tsx +72 -0
  206. package/src/components/primitives/Button.tsx +46 -0
  207. package/src/components/primitives/ColorInput.tsx +11 -47
  208. package/src/components/primitives/EditorIcon.tsx +99 -0
  209. package/src/components/primitives/FormControls.tsx +71 -0
  210. package/src/components/primitives/JsonInput.tsx +15 -2
  211. package/src/components/primitives/Layout.tsx +100 -0
  212. package/src/components/primitives/Menu.tsx +105 -0
  213. package/src/components/primitives/NumberInput.tsx +8 -35
  214. package/src/components/primitives/Panel.tsx +49 -44
  215. package/src/components/primitives/SectionHeader.tsx +10 -28
  216. package/src/components/primitives/Text.tsx +46 -0
  217. package/src/components/primitives/Tooltip.tsx +20 -12
  218. package/src/components/primitives/Vec3Input.tsx +3 -0
  219. package/src/components/primitives/banner-tones.ts +33 -0
  220. package/src/components/primitives/editor-icons.ts +102 -0
  221. package/src/components/primitives/panel-header-styles.ts +30 -0
  222. package/src/components/project-operation-documents.tsx +226 -0
  223. package/src/components/status-contributions.tsx +175 -0
  224. package/src/components/story-documents-register.ts +15 -0
  225. package/src/components/story-documents.tsx +1185 -0
  226. package/src/components/tool-documents.tsx +110 -0
  227. package/src/components/workspace-dock.css +293 -0
  228. package/src/components/workspace-history.ts +33 -0
  229. package/src/components/world-documents.tsx +145 -0
  230. package/src/components/xstate-documents.tsx +209 -0
  231. package/src/composite-screenshot.ts +248 -0
  232. package/src/design-system/index.ts +70 -0
  233. package/src/design-system-stories/Foundation.stories.tsx +183 -0
  234. package/src/design-system-stories/Patterns.stories.tsx +251 -0
  235. package/src/design-system-stories/Workspace.stories.tsx +538 -0
  236. package/src/doctor/report.ts +57 -13
  237. package/src/editor-api.ts +295 -31
  238. package/src/editor-compatibility.ts +200 -0
  239. package/src/editor-console.ts +35 -17
  240. package/src/editor-hotkeys.ts +85 -33
  241. package/src/editor-store.ts +538 -233
  242. package/src/editor-viewport.ts +160 -34
  243. package/src/entity-factory.ts +14 -3
  244. package/src/gizmo-registry.ts +21 -5
  245. package/src/harness-chat-editor-context.ts +37 -0
  246. package/src/harness-chat-indicator.ts +68 -0
  247. package/src/harness-chat-layout.ts +53 -0
  248. package/src/harness-chat-lifecycle.ts +170 -0
  249. package/src/harness-chat-reconnect.ts +85 -0
  250. package/src/harness-chat-types.ts +409 -0
  251. package/src/hierarchy-drop.ts +50 -0
  252. package/src/hierarchy-expansion-state.ts +100 -0
  253. package/src/hierarchy-menu-registry.ts +1 -1
  254. package/src/hierarchy-projection.ts +76 -0
  255. package/src/history/editor-session.ts +21 -0
  256. package/src/history/history-commands.ts +147 -0
  257. package/src/history/history-service.ts +904 -0
  258. package/src/history/json-history-resource.ts +165 -0
  259. package/src/history/persistence-coordinator.ts +35 -0
  260. package/src/history/project-file-history.ts +266 -0
  261. package/src/history/project-root-history-backends.ts +53 -0
  262. package/src/history/resource-registry.ts +209 -0
  263. package/src/history/snapshot-store.ts +108 -0
  264. package/src/history/source-history-backend.ts +374 -0
  265. package/src/history/types.ts +100 -0
  266. package/src/hmr-registration-group.ts +67 -0
  267. package/src/hmr-stable-react-context.ts +23 -0
  268. package/src/hosted-example.ts +12 -2
  269. package/src/hotkeys.ts +89 -18
  270. package/src/ingest/discovery-react.ts +9 -10
  271. package/src/ingest/discovery.ts +4 -4
  272. package/src/ingest/discovery2d.ts +5 -5
  273. package/src/ingest/games/bloom-composer/vgai.game.json +3 -3
  274. package/src/ingest/games/embed-bundle/vgai.game.json +3 -3
  275. package/src/ingest/games/games-fps/vgai.game.json +3 -3
  276. package/src/ingest/games/iframe-reachable/vgai.game.json +3 -3
  277. package/src/ingest/games/interactive-cubes/vgai.game.json +3 -3
  278. package/src/ingest/games/mismatched-three/vgai.game.json +3 -3
  279. package/src/ingest/games/own-three-bundled/vgai.game.json +3 -3
  280. package/src/ingest/games/pointerlock-maze/vgai.game.json +7 -4
  281. package/src/ingest/games-2d/bubbo-bubbo/vgai.game.json +3 -3
  282. package/src/ingest/games-2d/bunnymark/vgai.game.json +3 -3
  283. package/src/ingest/games-2d/flappy/vgai.game.json +3 -3
  284. package/src/ingest/games-2d/flappy-pixi/vgai.game.json +3 -3
  285. package/src/ingest/games-2d/puzzling-potions/vgai.game.json +3 -3
  286. package/src/ingest/games-2d/tilemap-camera/vgai.game.json +3 -3
  287. package/src/ingest/games-react/react-rpg/vgai.game.json +3 -3
  288. package/src/ingest/types.ts +1 -1
  289. package/src/ingest-mode.ts +47 -52
  290. package/src/ingest-siblings.ts +23 -33
  291. package/src/initial-scene.ts +1 -2
  292. package/src/main.tsx +16 -8
  293. package/src/manifest-project.ts +10 -12
  294. package/src/model-thumbnail.ts +261 -26
  295. package/src/module-mode.ts +44 -50
  296. package/src/play-log-events.ts +26 -0
  297. package/src/play-mode.ts +495 -401
  298. package/src/play-scene-adoption.ts +23 -0
  299. package/src/playground/main.ts +68 -24
  300. package/src/playwright-shim.ts +473 -0
  301. package/src/project-manager.ts +90 -10
  302. package/src/project-operations.ts +112 -0
  303. package/src/project-story-discovery.ts +25 -0
  304. package/src/project-tool-discovery.ts +43 -0
  305. package/src/scene-serializer.ts +0 -6
  306. package/src/scene-sync.ts +7 -2
  307. package/src/script-loader.ts +3 -3
  308. package/src/skeleton-helper.ts +57 -0
  309. package/src/storage/seed.ts +68 -8
  310. package/src/stories/StoryPreviewMount.tsx +265 -0
  311. package/src/stories/action-stories.fixture.tsx +44 -0
  312. package/src/stories/button-stories.fixture.tsx +50 -0
  313. package/src/stories/compose-project-stories.ts +182 -0
  314. package/src/stories/loader-stories.fixture.tsx +53 -0
  315. package/src/stories/story-discovery.ts +130 -0
  316. package/src/stories/story-registry.ts +146 -0
  317. package/src/theme-css.ts +1049 -0
  318. package/src/theme-library.ts +238 -0
  319. package/src/theme-preference.ts +130 -0
  320. package/src/theme.ts +527 -0
  321. package/src/tool-loader.ts +80 -24
  322. package/src/ui-source/inspect.ts +27 -4
  323. package/src/ui-source/source-write-backend.ts +83 -22
  324. package/src/widgets/index.ts +9 -18
  325. package/src/workspace-aux-commands.ts +11 -0
  326. package/src/workspace-dock-controller.ts +64 -0
  327. package/src/workspace-document-ids.ts +5 -0
  328. package/src/workspace-document-registry.ts +367 -0
  329. package/src/workspace-state-persistence.ts +455 -0
  330. package/src/workspace-status-registry.ts +110 -0
  331. package/src/workspace-utility-commands.ts +49 -0
  332. package/src/workspace-utility-registry.ts +161 -0
  333. package/src/world-document-routing.ts +16 -0
  334. package/src/xstate-inspect/XStateMachineInspector.tsx +544 -0
  335. package/src/xstate-inspect/character-animation-machine.fixture.ts +74 -0
  336. package/src/xstate-inspect/use-live-actor-state.ts +39 -0
  337. package/src/xstate-inspect/xstate-graph.ts +265 -0
  338. package/src/xstate-inspect/xstate-layout.ts +76 -0
  339. package/src/z-index.ts +26 -0
  340. package/template/.claude/skills/editor/SKILL.md +129 -90
  341. package/template/AGENTS.md +1110 -0
  342. package/template/CLAUDE.md +1 -512
  343. package/template/IDIOMS.md +50 -0
  344. package/template/check-idioms.ts +706 -0
  345. package/template/manifest-entry-modules-plugin.ts +53 -0
  346. package/template/package.json +13 -4
  347. package/template/public/scenes/default.scn2d.json +6 -0
  348. package/template/public/scenes/default.vscn.json +7 -1
  349. package/template/public/scenes/untitled.vscn.json +10 -0
  350. package/template/scripts/playtest.ts +40 -0
  351. package/template/server/loopback.ts +75 -0
  352. package/template/server/rooms/arena-room.ts +69 -1
  353. package/template/server/rooms/game-room.ts +68 -1
  354. package/template/src/data/README.md +9 -4
  355. package/template/src/data/data-ref.ts +35 -0
  356. package/template/src/data/tuning.schema.ts +1 -1
  357. package/template/src/main.ts +41 -62
  358. package/template/src/manifest-entry-modules.d.ts +3 -0
  359. package/template/src/operations/README.md +88 -0
  360. package/template/src/runtime/pixi-adapter.ts +24 -0
  361. package/template/src/scripts/components/data-spinner.ts +43 -2
  362. package/template/src/scripts/components/spin-lap-math.ts +27 -0
  363. package/template/src/tools/README.md +5 -3
  364. package/template/src/tools/example.tool.tsx +13 -7
  365. package/template/src/ui/Button.stories.tsx +66 -0
  366. package/template/src/ui/RebindPanel.tsx +2 -6
  367. package/template/src/ui/game.tsx +17 -0
  368. package/template/tests/acceptance/example.spec.ts +64 -0
  369. package/template/tests/logic/example.test.ts +31 -0
  370. package/template/tests/logic/room.test.ts +100 -0
  371. package/template/tests/tsconfig.json +30 -0
  372. package/template/validate-manifest.ts +5 -5
  373. package/template/validate-scenes.ts +50 -18
  374. package/template/vgai.game.json +20 -13
  375. package/template/vite.config.ts +77 -16
  376. package/vite-plugin-ui-oid.ts +261 -17
  377. package/dist/assets/CCDIKSolver-Dz-ysxZF.js +0 -1
  378. package/dist/assets/SelectionBox-BXsv_6aH.js +0 -1
  379. package/dist/assets/browser-project-scripts-DOru4L1x.js +0 -2
  380. package/dist/assets/browserAll-CEkWYlFk.js +0 -1
  381. package/dist/assets/create-runtime-DYIWr3va.js +0 -1
  382. package/dist/assets/data-asset-Bb9sIM7i.js +0 -5
  383. package/dist/assets/game-8SDZPPgH.js +0 -1
  384. package/dist/assets/game-CevorxyV.js +0 -1
  385. package/dist/assets/game-Di7XlywD.js +0 -1
  386. package/dist/assets/game-DjnCaw8R.js +0 -1
  387. package/dist/assets/game-Q9A0aXSV.js +0 -1
  388. package/dist/assets/index-CPVV6pjw.js +0 -1
  389. package/dist/assets/index-CpOnYyeD.js +0 -213
  390. package/dist/assets/index-Dr8H_6Rl.js +0 -1
  391. package/dist/assets/index-_bCaipUk.js +0 -13650
  392. package/dist/assets/ingest-siblings-Dz8eEqrV.js +0 -1
  393. package/dist/assets/mount-manifest-Bhk0ohsl.js +0 -1
  394. package/dist/assets/scene-query-BivAHhJu.js +0 -1
  395. package/dist/assets/webworkerAll-BLrH0F6F.js +0 -1
  396. package/dist/assets/xstate-animation-binding-1NGLmw6y.js +0 -1
  397. package/dist/assets/xstate.esm-BgUD33nl.js +0 -3
  398. package/src/authoring/scene-ui-layer.ts +0 -485
  399. package/src/authoring/ui-authoring-adapter.ts +0 -325
  400. package/src/authoring/ui-hierarchy-menu.ts +0 -40
  401. package/src/authoring/ui-inspector-sections.tsx +0 -156
  402. package/src/authoring/undo-timeline.ts +0 -145
  403. package/src/components/BottomPanel.tsx +0 -326
  404. package/src/components/BuildPanel.tsx +0 -455
  405. package/src/components/FolderTree.tsx +0 -179
  406. package/src/components/RightPanel.tsx +0 -166
  407. package/src/components/WorldContextMenu.tsx +0 -257
  408. package/src/components/asset-viewers/AssetViewer.tsx +0 -38
  409. package/src/ingest/games-react/react-rpg/src/ui/game-state.tsx +0 -129
  410. package/src/scene-ui-mode/active-scene-ui.ts +0 -58
  411. package/src/scene-ui-mode/scene-ui-palette-panel.tsx +0 -152
  412. package/src/scene-ui-mode/scene-ui-timeline-tab.tsx +0 -29
  413. package/src/scene-ui-mode/ui-prefab-dialog-host.tsx +0 -74
  414. package/src/scene-ui-mode/ui-prefab-hierarchy-menu.ts +0 -49
  415. package/src/scene-ui-mode/ui-prefab-io.ts +0 -76
  416. package/src/ui-editor/inspector.tsx +0 -563
  417. package/src/ui-editor/node-ops.ts +0 -266
  418. package/src/ui-editor/overlay.tsx +0 -380
  419. package/src/ui-editor/palette.tsx +0 -114
  420. package/src/ui-editor/react-store.ts +0 -78
  421. package/src/ui-editor/timeline-panel.tsx +0 -443
  422. package/src/ui-editor/ui-edit-store.ts +0 -552
  423. package/src/ui-editor/ui-prefab.ts +0 -95
  424. package/template/project.json +0 -9
  425. package/template/src/scripts/main.ts +0 -94
  426. package/template/src/ui/DefaultHud.tsx +0 -40
  427. package/template/src/ui/game-state.tsx +0 -37
  428. package/template/src/ui/mount-default-hud.tsx +0 -19
  429. package/template/src/ui/mount-ui.ts +0 -15
  430. package/vite-plugin-react-world-provider.ts +0 -81
@@ -0,0 +1,2260 @@
1
+ /**
2
+ * `vgai probe` — Task 3.4/3.5 of docs/ACCEPTANCE-DRIVER-BUILD-PLAN.md, run
3
+ * sequence per docs/ACCEPTANCE-DRIVER-SPEC.md §2.2. Doctor-shaped (same
4
+ * harness idioms as `run-doctor.ts`: deadline-clamped waits, CLI-owned
5
+ * headless Chromium + SwiftShader flags, `stopProcess` for teardown), but
6
+ * answers a different question: doctor asks "does this project mount in the
7
+ * editor?"; probe asks "do this game's acceptance specs
8
+ * (`tests/acceptance/*.spec.ts`) pass on the standalone game page?"
9
+ *
10
+ * Run sequence (each step's own exit path; exit codes below):
11
+ * 1. zero-specs usage check -> `checkZeroSpecs`
12
+ * 2. `tsc --noEmit -p tests/tsconfig.json` -> `runAcceptanceTypecheck`
13
+ * 3. pre-boot lint (4 bans + D16) -> `lintAcceptanceSpecs`
14
+ * 4. boot/reuse the standalone game server
15
+ * (+ the project's Colyseus room, if declared) -> `resolveServers`
16
+ * 5. generate the Playwright config -> `writeProbeConfig`
17
+ * 6. run the suite, watch for a harness wedge -> `runPlaywrightSuite`
18
+ * 7. read back `last-run.json`, print, propagate the exit code
19
+ *
20
+ * Exit codes: 0 all pass · 1 spec failure (including step 2's type errors) ·
21
+ * 2 usage (zero specs, pre-boot lint, bad args) · 3 missing capability (the
22
+ * page loaded but `window.__vgai` never appeared) · 5 harness wedge (the
23
+ * spawned `playwright test` child stopped making progress).
24
+ *
25
+ * Invoked via `npx tsx run-probe.ts <projectDir> [file] [-g <pattern>]
26
+ * [--init] [--last-failure] [--junit] [--fresh] [--json] [--headed] [--headless]
27
+ * [--headed-auto] [--live-session-hint]` from the repo root
28
+ * (`packages/vgai-cli/src/index.ts` spawns this exact script, mirroring
29
+ * `runDoctor`/`runConformance`/`runBundle`) — `<projectDir>` is always
30
+ * supplied by the CLI wrapper: the user's optional `[folder]` positional.
31
+ * `--headed-auto`/`--live-session-hint` are CLI-INTERNAL plumbing (#125 item
32
+ * 1, `ProbeCliArgs`'s own doc comments) — never part of the public `vgai
33
+ * probe` flag surface a person types.
34
+ * resolved against their OWN cwd, defaulting to the cwd itself when absent
35
+ * (the `npm test` convention), so argv[0] here is always the project folder
36
+ * and any later positional is a Playwright passthrough.
37
+ *
38
+ * KNOWN GAPS (disclosed, not silently narrowed):
39
+ * - The renderer-wedge/hidden-tab watchdog hollowstone's `bot-probe.mjs`
40
+ * implements assumes a LIVE Page/Browser handle (it launches Chromium
41
+ * itself). This runner instead spawns `npx playwright test` as a child
42
+ * process and never holds a page handle of its own — the fixture that
43
+ * does (`@vgai/probe`'s `fixture.ts`) is another package's territory.
44
+ * `runPlaywrightSuite` below therefore adapts the SEMANTICS (2 silent
45
+ * 90s windows -> kill -> exit 5) onto the one signal available at this
46
+ * layer: the child's stdout/stderr liveness. True page-level recovery
47
+ * (bringToFront, an eval-race health check) is not implemented here.
48
+ * - Full per-test fps/frame-time-percentile/sim-speed perf capture needs
49
+ * page-side instrumentation in `@vgai/probe`'s fixture teardown (also not
50
+ * this package's territory) — `probe-reporter.ts` honestly records only
51
+ * wall-clock duration per test, never a fabricated number.
52
+ * - `packages/editor/scripts/*.ts` is not covered by any `tsc` project
53
+ * today (verified: neither `tsconfig.json` nor `tsconfig.test.json`
54
+ * `include`s `scripts/`, matching the pre-existing `run-doctor.ts`/
55
+ * `run-conformance.ts` — this is a pre-existing gap, not one this file
56
+ * introduces).
57
+ */
58
+
59
+ import { type ChildProcess, spawn } from 'node:child_process';
60
+ import { createHash } from 'node:crypto';
61
+ import {
62
+ existsSync,
63
+ mkdirSync,
64
+ readdirSync,
65
+ readFileSync,
66
+ realpathSync,
67
+ renameSync,
68
+ rmSync,
69
+ statSync,
70
+ writeFileSync,
71
+ } from 'node:fs';
72
+ import { createRequire } from 'node:module';
73
+ import net from 'node:net';
74
+ import { homedir } from 'node:os';
75
+ import { join, relative, resolve, sep } from 'node:path';
76
+ import { fileURLToPath } from 'node:url';
77
+ import type { ResolvedGameManifest } from '@vgai/engine/manifest/load';
78
+ import { loadGameManifestFile } from '@vgai/engine/manifest/load-file';
79
+ import { stopProcess } from '../server/process-shutdown';
80
+ import {
81
+ type EditorSession,
82
+ liveSessions,
83
+ registerSession,
84
+ unregisterSession,
85
+ } from '../server/session-registry';
86
+ import {
87
+ allFailuresAreMissingCapability,
88
+ buildWedgeEnvelope,
89
+ type LastRunEnvelope,
90
+ type ProbeFailureRecord,
91
+ } from './probe-reporter';
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Exit codes (docs/ACCEPTANCE-DRIVER-SPEC.md §2.2)
95
+ // ---------------------------------------------------------------------------
96
+
97
+ export const PROBE_EXIT_CODES = {
98
+ PASS: 0,
99
+ SPEC_FAILURE: 1,
100
+ USAGE: 2,
101
+ MISSING_CAPABILITY: 3,
102
+ HARNESS_WEDGE: 5,
103
+ } as const;
104
+
105
+ /** Human-readable meaning for each code above — the SAME wording as the
106
+ * `vgai probe --help` / global `--help` exit-code table (kept in sync by
107
+ * hand; both are short and rarely change). Fed to `describeExitCode`,
108
+ * which `main()` uses to print a final `vgai probe: exit <n> (<meaning>)`
109
+ * line on any non-zero exit (Wave-6.1 dry-run friction: a bare non-zero
110
+ * code with no reminder of what it means sent people back to the docs). */
111
+ const EXIT_CODE_MEANINGS: Record<number, string> = {
112
+ [PROBE_EXIT_CODES.PASS]: 'all specs passed',
113
+ [PROBE_EXIT_CODES.SPEC_FAILURE]: 'a spec failed, including a tests/ type error',
114
+ [PROBE_EXIT_CODES.USAGE]: 'usage error, including zero specs',
115
+ [PROBE_EXIT_CODES.MISSING_CAPABILITY]: 'the page loaded but no debug bridge appeared',
116
+ [PROBE_EXIT_CODES.HARNESS_WEDGE]:
117
+ 'the harness stopped making progress (renderer/test-runner wedge)',
118
+ };
119
+
120
+ /** Pure — maps an exit code to its documented meaning; an unrecognized code
121
+ * (should never happen — defensive only) still names itself rather than
122
+ * throwing. */
123
+ export function describeExitCode(code: number): string {
124
+ return EXIT_CODE_MEANINGS[code] ?? `unrecognized exit code ${code}`;
125
+ }
126
+
127
+ /** Sets `process.exitCode` and — on any NON-zero code — prints a final
128
+ * `vgai probe: exit <n> (<meaning>)` line so a failure is never left to a
129
+ * bare number (Wave-6.1 dry-run friction #3). Centralized here so every
130
+ * exit path in `main()` gets the line for free. */
131
+ function setExit(code: number): void {
132
+ process.exitCode = code;
133
+ if (code !== 0) {
134
+ console.error(`vgai probe: exit ${code} (${describeExitCode(code)})`);
135
+ }
136
+ }
137
+
138
+ export class ProbeUsageError extends Error {}
139
+
140
+ export const PROBE_USAGE_TEXT =
141
+ 'Usage: run-probe.ts <projectDir> [file] [-g <pattern>] [--init] [--last-failure] ' +
142
+ '[--run-file <path>] [--junit] [--fresh] [--json] [--headed] [--headless]';
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Arg parsing
146
+ // ---------------------------------------------------------------------------
147
+
148
+ export interface ProbeCliArgs {
149
+ projectDir: string;
150
+ init: boolean;
151
+ lastFailure: boolean;
152
+ /**
153
+ * `runs/` archive (Wave-6 findings ledger) — an EXPLICIT, always-value
154
+ * `--run-file <path>` flag naming one of the archived `.vgai/probe/runs/
155
+ * <startedAt>-<result>.json` envelopes to render instead of the
156
+ * single-slot `last-run.json`. Deliberately a SEPARATE flag from
157
+ * `--last-failure` rather than an optional positional after it — an
158
+ * earlier design let `--last-failure` optionally swallow the next bare
159
+ * token as a run-file path, which silently ate the `[projectDir]`
160
+ * positional on the common `vgai probe --last-failure <project>` shape
161
+ * (flag-before-project). A `-g`-style "peek at the next token" trick only
162
+ * works when the flag's value is UNAMBIGUOUSLY distinguishable from every
163
+ * other positional; a run-file path is not. `--run-file` always consumes
164
+ * its next argv token, full stop — same discipline as `-g <pattern>`
165
+ * below, but with no ambiguity to resolve. Resolved against `projectDir`
166
+ * when relative (mirrors how `projectDir` itself is always resolved here).
167
+ */
168
+ runFile?: string;
169
+ junit: boolean;
170
+ fresh: boolean;
171
+ json: boolean;
172
+ /** Launch a VISIBLE (non-headless) browser so a human can watch the
173
+ * synthetic player play the game live — same runner, same suite, just
174
+ * `headless: false` in the generated config (see `buildProbeConfigSource`)
175
+ * and no headless-shell executablePath resolution (headed needs full
176
+ * chromium, resolved via Playwright's own default). */
177
+ headed: boolean;
178
+ /**
179
+ * #125 item 1 — CLI-INTERNAL: true when `headed` was chosen by the CLI's
180
+ * default gate (`resolveProbeHeadedMode`, `@vgai/cli`'s
181
+ * `probe-headed-gate.ts`) rather than an explicit `--headed` typed by a
182
+ * person. Not part of the public `vgai probe` flag surface — only
183
+ * `packages/vgai-cli/src/index.ts` ever sets this when it spawns this
184
+ * script. Governs whether a "no display" launch failure hard-fails (an
185
+ * explicit request — unchanged pre-#125 behavior) or silently retries
186
+ * headless (a default nobody typed must never fail the run for display
187
+ * reasons — see `main()`'s headed-fallback handling below).
188
+ */
189
+ headedAuto: boolean;
190
+ /** Explicit `--headless` opt-out (#125): forces headless even if `headed`/
191
+ * `headedAuto` were also (mistakenly) set, and suppresses the "you could
192
+ * have watched this" tip below. Also settable by a human directly. */
193
+ headless: boolean;
194
+ /**
195
+ * #125 item 1 — CLI-INTERNAL: true when the CLI found a live editor
196
+ * session for this project when it decided the launch mode. Used only to
197
+ * decide whether to print the "re-run with --headed to watch" tip when the
198
+ * run ends up headless. Not part of the public flag surface.
199
+ */
200
+ liveSessionHint: boolean;
201
+ /**
202
+ * #140 — play the spec INSIDE the already-live editor session's tab
203
+ * (`vgai probe --in-editor`) instead of booting a standalone vite+browser:
204
+ * skips step 4 (`resolveServers`) entirely and drives the suite over
205
+ * `@vgai/probe`'s `RelayTransport` against `sessionPort` below. Mutually
206
+ * exclusive with `headed`/`headless` in practice (there is no browser this
207
+ * runner itself launches to make headed/headless), enforced by the CLI
208
+ * wrapper, not re-validated here.
209
+ */
210
+ inEditor: boolean;
211
+ /**
212
+ * #140 — CLI-INTERNAL: the live editor session's dev-server port, set by
213
+ * `packages/vgai-cli/src/index.ts` whenever it passes `inEditor`. Not part
214
+ * of the public flag surface a person types (mirrors `headedAuto`/
215
+ * `liveSessionHint`'s own "CLI-internal" convention above).
216
+ */
217
+ sessionPort?: number;
218
+ /** `[file]` / `-g <pattern>` — forwarded to `npx playwright test` untouched.
219
+ * #146 `--bail` is the one mapped flag: it becomes a leading
220
+ * `--max-failures=1` here at parse time (single point of truth — every
221
+ * `runPlaywrightSuite` call site forwards `playwrightArgs` as-is, so the
222
+ * mapping rides into the headed-fallback retry and the in-editor run
223
+ * without each call site knowing about it). */
224
+ playwrightArgs: string[];
225
+ /**
226
+ * D15/T-D15.6 (objection-4 fix) — `--seed <n>`: bakes a deterministic seed
227
+ * into every spec this run drives, via `VGAI_PROBE_SEED` (same
228
+ * env-var-into-the-suite pattern as `VGAI_PROBE_URL`/`VGAI_PROBE_WARM`
229
+ * below) — `@vgai/probe`'s `fixture.ts` appends `&vgai-seed=<n>` to the
230
+ * game URL alongside `?vgai-debug=1`, reaching the SAME
231
+ * `resolveDeterminismSeed` precedence a standalone/editor boot already
232
+ * honors (requires the project to declare `determinism.seededRandom`).
233
+ * `undefined` (the default) leaves the manifest/no-seed precedence
234
+ * untouched.
235
+ */
236
+ seed: number | undefined;
237
+ }
238
+
239
+ /** Pure — `argv` is the script's own `process.argv.slice(2)` (or an
240
+ * equivalent test fixture): `argv[0]` is always the project folder (the CLI
241
+ * wrapper resolves it from the user's cwd before spawning this script). */
242
+ export function parseProbeArgs(argv: string[]): ProbeCliArgs {
243
+ const folder = argv[0];
244
+ if (!folder) {
245
+ throw new ProbeUsageError('run-probe.ts: missing <projectDir> argument');
246
+ }
247
+ let init = false;
248
+ let lastFailure = false;
249
+ let runFile: string | undefined;
250
+ let junit = false;
251
+ let fresh = false;
252
+ let json = false;
253
+ let headed = false;
254
+ let headedAuto = false;
255
+ let headless = false;
256
+ let liveSessionHint = false;
257
+ let inEditor = false;
258
+ let sessionPort: number | undefined;
259
+ let seed: number | undefined;
260
+ let bail = false;
261
+ const playwrightArgs: string[] = [];
262
+ for (let i = 1; i < argv.length; i++) {
263
+ const a = argv[i]!;
264
+ if (a === '--init') init = true;
265
+ else if (a === '--last-failure') lastFailure = true;
266
+ else if (a === '--run-file') {
267
+ const value = argv[++i];
268
+ if (value === undefined) throw new ProbeUsageError('--run-file requires a path argument');
269
+ runFile = value;
270
+ } else if (a === '--junit') junit = true;
271
+ else if (a === '--fresh') fresh = true;
272
+ else if (a === '--json') json = true;
273
+ else if (a === '--headed') headed = true;
274
+ else if (a === '--headed-auto') headedAuto = true;
275
+ else if (a === '--headless') headless = true;
276
+ else if (a === '--live-session-hint') liveSessionHint = true;
277
+ else if (a === '--in-editor') inEditor = true;
278
+ else if (a === '--bail') bail = true;
279
+ else if (a === '--session-port') {
280
+ const raw = argv[++i];
281
+ if (raw === undefined) throw new ProbeUsageError('--session-port requires a value');
282
+ sessionPort = Number(raw);
283
+ if (!Number.isFinite(sessionPort)) {
284
+ throw new ProbeUsageError(`--session-port expects a number, got: ${raw}`);
285
+ }
286
+ } else if (a === '--seed') {
287
+ const raw = argv[++i];
288
+ const n = raw === undefined ? Number.NaN : Number(raw);
289
+ if (!Number.isFinite(n)) {
290
+ throw new ProbeUsageError(`--seed expects a number, got: ${raw ?? '(nothing)'}`);
291
+ }
292
+ seed = n;
293
+ } else if (a === '-g') {
294
+ const pattern = argv[++i];
295
+ if (pattern === undefined) throw new ProbeUsageError('-g requires a pattern argument');
296
+ playwrightArgs.push('-g', pattern);
297
+ } else {
298
+ playwrightArgs.push(a);
299
+ }
300
+ }
301
+ return {
302
+ projectDir: resolve(folder),
303
+ init,
304
+ lastFailure,
305
+ ...(runFile !== undefined ? { runFile } : {}),
306
+ junit,
307
+ fresh,
308
+ json,
309
+ headed,
310
+ headedAuto,
311
+ headless,
312
+ liveSessionHint,
313
+ inEditor,
314
+ ...(sessionPort !== undefined ? { sessionPort } : {}),
315
+ // #146 `--bail`: stop at the first failing spec. Mapped to Playwright's
316
+ // own `--max-failures=1` HERE (see the `playwrightArgs` doc comment).
317
+ playwrightArgs: bail ? ['--max-failures=1', ...playwrightArgs] : playwrightArgs,
318
+ seed,
319
+ };
320
+ }
321
+
322
+ // ---------------------------------------------------------------------------
323
+ // Step 1 — zero-specs usage check
324
+ // ---------------------------------------------------------------------------
325
+
326
+ export const ZERO_SPECS_MESSAGE =
327
+ 'acceptance: 0 specs in tests/acceptance/ — this game has no bot-proven features. ' +
328
+ "Scaffold the worked example with 'vgai probe --init'.";
329
+
330
+ /** Recursive walk of `<projectDir>/tests/acceptance` for `*.spec.ts` files —
331
+ * `[]` when the directory is absent (never throws on a missing dir). */
332
+ export function findAcceptanceSpecFiles(projectDir: string): string[] {
333
+ const dir = join(projectDir, 'tests', 'acceptance');
334
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) return [];
335
+ const out: string[] = [];
336
+ const walk = (d: string): void => {
337
+ for (const entry of readdirSync(d, { withFileTypes: true })) {
338
+ const full = join(d, entry.name);
339
+ if (entry.isDirectory()) walk(full);
340
+ else if (entry.isFile() && entry.name.endsWith('.spec.ts')) out.push(full);
341
+ }
342
+ };
343
+ walk(dir);
344
+ return out.sort();
345
+ }
346
+
347
+ export interface ZeroSpecsCheck {
348
+ zero: boolean;
349
+ specFiles: string[];
350
+ }
351
+
352
+ export function checkZeroSpecs(projectDir: string): ZeroSpecsCheck {
353
+ const specFiles = findAcceptanceSpecFiles(projectDir);
354
+ return { zero: specFiles.length === 0, specFiles };
355
+ }
356
+
357
+ // ---------------------------------------------------------------------------
358
+ // Step 2 — `tsc --noEmit -p tests/tsconfig.json`
359
+ // ---------------------------------------------------------------------------
360
+
361
+ export interface TypecheckResult {
362
+ ok: boolean;
363
+ output: string;
364
+ }
365
+
366
+ /** Spawns the project's OWN installed `typescript` (via `npx tsc`, `cwd:
367
+ * projectDir`) against `tests/tsconfig.json` — AC-B1.5: a type-broken spec
368
+ * fails in seconds, before any browser boots. */
369
+ export async function runAcceptanceTypecheck(projectDir: string): Promise<TypecheckResult> {
370
+ const tsconfigPath = join(projectDir, 'tests', 'tsconfig.json');
371
+ return new Promise((resolvePromise) => {
372
+ const child = spawn('npx', ['tsc', '--noEmit', '-p', tsconfigPath], {
373
+ cwd: projectDir,
374
+ stdio: ['ignore', 'pipe', 'pipe'],
375
+ shell: process.platform === 'win32',
376
+ });
377
+ let output = '';
378
+ child.stdout?.on('data', (d: Buffer) => {
379
+ output += d.toString();
380
+ });
381
+ child.stderr?.on('data', (d: Buffer) => {
382
+ output += d.toString();
383
+ });
384
+ child.on('close', (code) => resolvePromise({ ok: code === 0, output }));
385
+ child.on('error', (err) => resolvePromise({ ok: false, output: `${output}\n${err.message}` }));
386
+ });
387
+ }
388
+
389
+ // ---------------------------------------------------------------------------
390
+ // Step 3 — pre-boot lint: the four bans + D16
391
+ // ---------------------------------------------------------------------------
392
+
393
+ export type LintRule =
394
+ | 'wait-for-timeout'
395
+ | 'networkidle'
396
+ | 'raw-goto'
397
+ | 'command-only'
398
+ | 'wall-clock-budget';
399
+
400
+ export interface LintViolation {
401
+ file: string;
402
+ line: number;
403
+ rule: LintRule;
404
+ message: string;
405
+ }
406
+
407
+ const WAIT_FOR_TIMEOUT_RE = /\.waitForTimeout\s*\(/;
408
+ const NETWORKIDLE_RE = /networkidle/;
409
+ const RAW_GOTO_RE = /\bpage\.goto\s*\(/;
410
+ // A blind validation run re-introduced `test.setTimeout(120_000)` in its own
411
+ // specs under 0.2x sim speed (SwiftShader has no GPU) — the runner's own
412
+ // generated config sets `timeout: 0` for exactly this reason (D1, above), but
413
+ // Playwright's PER-TEST `test.setTimeout`/`test.slow` override that from
414
+ // inside the spec file itself, where this pre-boot lint hadn't been looking.
415
+ const TEST_SET_TIMEOUT_RE = /\btest\.setTimeout\s*\(/;
416
+ const TEST_SLOW_RE = /\btest\.slow\s*\(/;
417
+ const WALL_CLOCK_BUDGET_MESSAGE =
418
+ 'banned: wall-clock test budgets fight the sim clock (the run may be at 0.2x under ' +
419
+ 'SwiftShader); budgets belong in { simSeconds } — the runner already disables ' +
420
+ "Playwright's wall timeout";
421
+ const GAME_INPUT_RE = /\bgame\.input\b/;
422
+ const GAME_WAIT_FOR_RE = /\bgame\.waitFor\s*\(/;
423
+ const GAME_EVENTS_EXPECT_RE = /\bgame\.events\.expect\s*\(/;
424
+ const GAME_PAGE_RE = /\bgame\.page\s*\(/;
425
+ const GAME_COMMAND_OR_SCREENSHOT_RE = /\bgame\.(?:command|screenshot)\s*\(/;
426
+
427
+ /** Pure — lints one spec file's already-read source text. Per-line bans get
428
+ * their own violation per matching line; D16 ("commands are fixtures, never
429
+ * proofs") is a file-level check per the build plan's own wording ("a spec
430
+ * file whose only interactions are commands"). */
431
+ export function lintSpecFile(filePath: string, contents: string): LintViolation[] {
432
+ const violations: LintViolation[] = [];
433
+ const lines = contents.split('\n');
434
+ lines.forEach((lineText, idx) => {
435
+ const line = idx + 1;
436
+ if (WAIT_FOR_TIMEOUT_RE.test(lineText)) {
437
+ violations.push({
438
+ file: filePath,
439
+ line,
440
+ rule: 'wait-for-timeout',
441
+ message:
442
+ 'banned: sleeps wall-clock while the game runs sim-time; use game.waitFor(pred, { simSeconds })',
443
+ });
444
+ }
445
+ if (NETWORKIDLE_RE.test(lineText)) {
446
+ violations.push({
447
+ file: filePath,
448
+ line,
449
+ rule: 'networkidle',
450
+ message: 'banned: hangs forever against Vite dev servers; the game fixture owns page.goto',
451
+ });
452
+ }
453
+ if (RAW_GOTO_RE.test(lineText)) {
454
+ violations.push({
455
+ file: filePath,
456
+ line,
457
+ rule: 'raw-goto',
458
+ message: 'banned: the game fixture owns navigation',
459
+ });
460
+ }
461
+ if (TEST_SET_TIMEOUT_RE.test(lineText) || TEST_SLOW_RE.test(lineText)) {
462
+ violations.push({
463
+ file: filePath,
464
+ line,
465
+ rule: 'wall-clock-budget',
466
+ message: WALL_CLOCK_BUDGET_MESSAGE,
467
+ });
468
+ }
469
+ });
470
+
471
+ const hasProofCall =
472
+ GAME_INPUT_RE.test(contents) ||
473
+ GAME_PAGE_RE.test(contents) ||
474
+ GAME_WAIT_FOR_RE.test(contents) ||
475
+ GAME_EVENTS_EXPECT_RE.test(contents);
476
+ const hasCommandOrScreenshot = GAME_COMMAND_OR_SCREENSHOT_RE.test(contents);
477
+ if (hasCommandOrScreenshot && !hasProofCall) {
478
+ const firstMatchLine = lines.findIndex((l) => GAME_COMMAND_OR_SCREENSHOT_RE.test(l));
479
+ violations.push({
480
+ file: filePath,
481
+ line: firstMatchLine >= 0 ? firstMatchLine + 1 : 1,
482
+ rule: 'command-only',
483
+ message:
484
+ 'this spec only issues debug commands — commands are fixtures, never proofs (D16); ' +
485
+ 'drive input and assert observable state',
486
+ });
487
+ }
488
+ return violations;
489
+ }
490
+
491
+ export function lintAcceptanceSpecs(specFiles: string[]): LintViolation[] {
492
+ const violations: LintViolation[] = [];
493
+ for (const file of specFiles) {
494
+ violations.push(...lintSpecFile(file, readFileSync(file, 'utf-8')));
495
+ }
496
+ return violations;
497
+ }
498
+
499
+ export function formatLintViolation(v: LintViolation, projectDir: string): string {
500
+ return `${relative(projectDir, v.file)}:${v.line}: ${v.message}`;
501
+ }
502
+
503
+ // ---------------------------------------------------------------------------
504
+ // Step 5 — the generated Playwright config
505
+ // ---------------------------------------------------------------------------
506
+
507
+ export interface ProbeConfigOptions {
508
+ projectDir: string;
509
+ testDir: string;
510
+ reporterModulePath: string;
511
+ lastRunJsonPath: string;
512
+ artifactsDir: string;
513
+ junitOutputPath?: string;
514
+ chromiumExecutablePath?: string;
515
+ /** `--headed`: emits `use.headless: false` so a human can watch the run —
516
+ * video/artifacts/SwiftShader launch args stay identical either way. */
517
+ headed?: boolean;
518
+ }
519
+
520
+ /** Pure — returns the generated config's TS SOURCE TEXT (never a live
521
+ * object): `workers: 1` enforced, reporters `[['list'], [<probe reporter>],
522
+ * ...(junit)]` — `'html'` never appears (it blocks the terminal — a wedged
523
+ * Bash call to an agent, per the spec's own reasoning). */
524
+ export function buildProbeConfigSource(opts: ProbeConfigOptions): string {
525
+ const reporterOptions = {
526
+ outputFile: opts.lastRunJsonPath,
527
+ projectDir: opts.projectDir,
528
+ outputDir: opts.artifactsDir,
529
+ };
530
+ const reporterEntries = [
531
+ "['list']",
532
+ `[${JSON.stringify(opts.reporterModulePath)}, ${JSON.stringify(reporterOptions)}]`,
533
+ ];
534
+ if (opts.junitOutputPath) {
535
+ reporterEntries.push(`['junit', ${JSON.stringify({ outputFile: opts.junitOutputPath })}]`);
536
+ }
537
+ const launchOptions: { args: string[]; executablePath?: string } = {
538
+ // The three SwiftShader flags this checkout's `run-doctor.ts` is missing
539
+ // the third of — a newer headless-shell needs it (build plan Task 3.4 anchors).
540
+ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'],
541
+ };
542
+ if (opts.chromiumExecutablePath) launchOptions.executablePath = opts.chromiumExecutablePath;
543
+
544
+ return `// GENERATED by \`vgai probe\` — do not edit by hand; regenerated every run.
545
+ import { defineConfig } from '@playwright/test';
546
+
547
+ export default defineConfig({
548
+ testDir: ${JSON.stringify(opts.testDir)},
549
+ fullyParallel: false,
550
+ workers: 1,
551
+ // D1: Playwright's own per-test wall-clock timeout defaults to 30s, which
552
+ // silently caps every sim-time \`waitFor\`/\`hold\` budget — a test with a
553
+ // genuinely generous sim-time budget (SwiftShader can run well under 1x)
554
+ // would die as a raw 'Test timeout of 30000ms exceeded' with none of this
555
+ // package's own failure-block rendering. There is no wall-clock budget to
556
+ // protect here BY DESIGN (see wait-for.ts's WAIT_FOR_TIMEOUT_OPTION_MESSAGE)
557
+ // — the backstops are this runner's own renderer-wedge watchdog (2x90s,
558
+ // above) and wait-for.ts's internal frozen-clock stall guard (D2).
559
+ timeout: 0,
560
+ reporter: [
561
+ ${reporterEntries.map((e) => ` ${e},`).join('\n')}
562
+ ],
563
+ outputDir: ${JSON.stringify(opts.artifactsDir)},
564
+ use: {
565
+ video: 'retain-on-failure',
566
+ launchOptions: ${JSON.stringify(launchOptions)},${
567
+ opts.headed
568
+ ? `
569
+ // --headed: a human is watching this run live — SwiftShader launch args
570
+ // above are harmless headed (they only pick the GL backend) and stay on.
571
+ headless: false,`
572
+ : ''
573
+ }
574
+ },
575
+ });
576
+ `;
577
+ }
578
+
579
+ export function computeConfigHash(opts: ProbeConfigOptions): string {
580
+ return createHash('sha1').update(JSON.stringify(opts)).digest('hex').slice(0, 8);
581
+ }
582
+
583
+ /** Writes the generated config to `<projectDir>/.vgai/probe/config.<hash>.ts`
584
+ * and returns its absolute path. */
585
+ export function writeProbeConfig(opts: ProbeConfigOptions): string {
586
+ const dir = join(opts.projectDir, '.vgai', 'probe');
587
+ mkdirSync(dir, { recursive: true });
588
+ const configPath = join(dir, `config.${computeConfigHash(opts)}.ts`);
589
+ writeFileSync(configPath, buildProbeConfigSource(opts));
590
+ return configPath;
591
+ }
592
+
593
+ /**
594
+ * Playwright preflights custom ESM reporters by importing a virtual
595
+ * `<reporter>.esm.preflight` sibling. Node rejects that import before
596
+ * Playwright's TypeScript loader can see it when the reporter lives under
597
+ * `node_modules` (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`). A packaged
598
+ * editor necessarily ships this source under `node_modules`, so stage the
599
+ * small reporter beside the generated config in the project's own probe
600
+ * workspace. In-repo and packaged runs then exercise the same path.
601
+ */
602
+ export function stageProbeReporter(projectDir: string, sourcePath: string): string {
603
+ const dir = join(projectDir, '.vgai', 'probe');
604
+ mkdirSync(dir, { recursive: true });
605
+ const stagedPath = join(dir, 'probe-reporter.ts');
606
+ writeFileSync(stagedPath, readFileSync(sourcePath));
607
+ return stagedPath;
608
+ }
609
+
610
+ // ---------------------------------------------------------------------------
611
+ // resolveHeadlessShell — ported from hollowstone's bot-probe.mjs semantics.
612
+ // The actual hollowstone checkout was not reachable from this worktree/agent
613
+ // sandbox (verified: no ../hollowstone dir, no bot-probe.mjs anywhere on
614
+ // disk here) — this is a faithful REIMPLEMENTATION from the build plan's
615
+ // description of that file's `resolveHeadlessShell` (env override renamed
616
+ // `VGAI_CHROME`, linux64 cache path, `undefined` fallback so CI's own
617
+ // `playwright install chromium` still resolves via Playwright's default
618
+ // lookup), not a byte-for-byte port. Flagged as a deviation in the task report.
619
+ // ---------------------------------------------------------------------------
620
+
621
+ export function resolveHeadlessShell(env: NodeJS.ProcessEnv = process.env): string | undefined {
622
+ const override = env['VGAI_CHROME'];
623
+ if (override) return override;
624
+ if (process.platform !== 'linux') return undefined;
625
+ const cacheDir = join(env['HOME'] ?? homedir(), '.cache', 'ms-playwright');
626
+ try {
627
+ if (!existsSync(cacheDir)) return undefined;
628
+ const entries = readdirSync(cacheDir).filter(
629
+ (n) => n.startsWith('chromium_headless_shell-') || n.startsWith('chromium-'),
630
+ );
631
+ if (entries.length === 0) return undefined;
632
+ const headlessOnly = entries.filter((n) => n.startsWith('chromium_headless_shell-')).sort();
633
+ const chosen = headlessOnly[headlessOnly.length - 1] ?? entries.sort()[entries.length - 1];
634
+ if (!chosen) return undefined;
635
+ const shellPath = join(cacheDir, chosen, 'chrome-linux', 'headless_shell');
636
+ if (existsSync(shellPath)) return shellPath;
637
+ const chromePath = join(cacheDir, chosen, 'chrome-linux', 'chrome');
638
+ return existsSync(chromePath) ? chromePath : undefined;
639
+ } catch {
640
+ return undefined;
641
+ }
642
+ }
643
+
644
+ // ---------------------------------------------------------------------------
645
+ // --headed on a display-less host — Playwright's own launch failure, mapped
646
+ // to a teaching hint. This layer never holds a live Page/Browser handle (see
647
+ // this file's header "KNOWN GAPS" note), so the spawned child's stdout/stderr
648
+ // text is the only signal available; `classifyHeadedLaunchFailure` is pure so
649
+ // it can be exercised directly against a captured/fake Playwright error
650
+ // string without ever actually launching a headed browser in CI.
651
+ // ---------------------------------------------------------------------------
652
+
653
+ export const HEADED_NO_DISPLAY_HINT =
654
+ 'headed mode needs a display; on WSL use an X server or run from a desktop session';
655
+
656
+ // Matches both Playwright's own launch-time message ("Looks like you
657
+ // launched a headed browser without having a XServer running...") and the
658
+ // lower-level X11/Chromium errors it can surface instead ("Missing X server
659
+ // or $DISPLAY", "cannot open display", "no protocol specified").
660
+ const HEADED_NO_DISPLAY_RE =
661
+ /(headed browser without having a x ?server|missing x server or \$display|cannot open display|no protocol specified)/i;
662
+
663
+ /** Pure — inspects a spawned `playwright test` child's combined stdout+stderr
664
+ * text for its own "no display" launch failure and maps it to
665
+ * `HEADED_NO_DISPLAY_HINT`. Returns `undefined` when the output doesn't
666
+ * match (an ordinary failure, or headed wasn't requested at all). */
667
+ export function classifyHeadedLaunchFailure(output: string): string | undefined {
668
+ return HEADED_NO_DISPLAY_RE.test(output) ? HEADED_NO_DISPLAY_HINT : undefined;
669
+ }
670
+
671
+ // ---------------------------------------------------------------------------
672
+ // Exit-code mapping table
673
+ // ---------------------------------------------------------------------------
674
+
675
+ export interface ExitCodeInputs {
676
+ zeroSpecs?: boolean;
677
+ lintViolationCount?: number;
678
+ typecheckFailed?: boolean;
679
+ wedged?: boolean;
680
+ missingCapability?: boolean;
681
+ anyTestFailed?: boolean;
682
+ }
683
+
684
+ /** Pure decision table mirroring docs/ACCEPTANCE-DRIVER-SPEC.md §2.2's exit
685
+ * code list. Order matters: usage-class failures (steps 1/3) are checked
686
+ * before the type-check's spec-failure class (step 2), before the harness
687
+ * wedge, before the missing-capability/ordinary-failure split from an
688
+ * actual suite run. */
689
+ export function computeExitCode(input: ExitCodeInputs): number {
690
+ if (input.zeroSpecs) return PROBE_EXIT_CODES.USAGE;
691
+ if ((input.lintViolationCount ?? 0) > 0) return PROBE_EXIT_CODES.USAGE;
692
+ if (input.typecheckFailed) return PROBE_EXIT_CODES.SPEC_FAILURE;
693
+ if (input.wedged) return PROBE_EXIT_CODES.HARNESS_WEDGE;
694
+ if (input.missingCapability) return PROBE_EXIT_CODES.MISSING_CAPABILITY;
695
+ if (input.anyTestFailed) return PROBE_EXIT_CODES.SPEC_FAILURE;
696
+ return PROBE_EXIT_CODES.PASS;
697
+ }
698
+
699
+ // ---------------------------------------------------------------------------
700
+ // D4 — stale/incomplete envelope must never read as a false green
701
+ // ---------------------------------------------------------------------------
702
+
703
+ /** D4(a): deletes any `last-run.json` left over from a PREVIOUS run before
704
+ * this run's suite spawns. Without this, a run whose child process crashes
705
+ * before the reporter's `onEnd` ever fires (its own config failed to load,
706
+ * the Playwright CLI's "No tests found" short-circuit, ...) would fall
707
+ * through to step 7's `readFileSync` and silently read the PRIOR run's
708
+ * envelope as if it were this run's result — including a stale `passed`.
709
+ * Best-effort: a missing file (the common case, nothing stale) is a no-op. */
710
+ export function clearStaleLastRun(lastRunJsonPath: string): void {
711
+ try {
712
+ rmSync(lastRunJsonPath, { force: true });
713
+ } catch {
714
+ // best-effort — see doc comment above
715
+ }
716
+ }
717
+
718
+ /**
719
+ * Run-2 frictions fix #4: `.vgai/probe/last-run/` (screenshots, videos,
720
+ * console logs — Playwright's own `outputDir`) gets WIPED at the start of
721
+ * every run — that's Playwright's own default behavior for `outputDir`, not
722
+ * something this file does explicitly — so a green run's PNG evidence
723
+ * vanished the moment the next run started, often before anyone got to
724
+ * review it. Before this run's own `outputDir` is (re)created, archive
725
+ * whatever the PREVIOUS run left in `last-run/` to `prev-run/` — a plain
726
+ * rename, not a copy, so it costs nothing extra on disk. Exactly ONE
727
+ * archived generation is kept (an existing `prev-run/` is deleted first),
728
+ * so this stays disk-bounded no matter how many runs happen back to back —
729
+ * unlike the `runs/` JSON archive above, which intentionally keeps
730
+ * `RUNS_ARCHIVE_KEEP_COUNT` envelopes; screenshots/videos are too large for
731
+ * the same treatment.
732
+ *
733
+ * Must run BEFORE `artifactsDir` is (re)created for this run — call this
734
+ * first, then `mkdirSync(artifactsDir, { recursive: true })`.
735
+ *
736
+ * Best-effort and silent on any failure (permissions, a locked file on
737
+ * Windows, no `last-run/` yet on a project's very first run): losing the
738
+ * archived generation is far cheaper than failing the run over it.
739
+ */
740
+ export function archiveLastRunArtifacts(probeDir: string): void {
741
+ const lastRunDir = join(probeDir, 'last-run');
742
+ const prevRunDir = join(probeDir, 'prev-run');
743
+ if (!existsSync(lastRunDir)) return;
744
+ try {
745
+ rmSync(prevRunDir, { recursive: true, force: true });
746
+ } catch {
747
+ // best-effort — see doc comment above
748
+ }
749
+ try {
750
+ renameSync(lastRunDir, prevRunDir);
751
+ } catch {
752
+ // best-effort — e.g. a cross-device rename, or a file locked by an
753
+ // antivirus/indexer on Windows; the new run simply proceeds without an
754
+ // archived prior generation, same as a project's very first run.
755
+ }
756
+ }
757
+
758
+ // ---------------------------------------------------------------------------
759
+ // `runs/` archive (Wave-6 findings ledger: "last-run.json single-slot erases
760
+ // run history"). `last-run.json` stays EXACTLY as-is — every existing reader
761
+ // (`--last-failure`'s default path, a panel, a human `cat`-ing it) keeps its
762
+ // stable single-slot contract untouched. This is purely additive: every
763
+ // finished run's envelope is ALSO copied into `.vgai/probe/runs/<startedAt-
764
+ // iso>-<result>.json` (colons/dots swapped for `-` — Windows-safe filenames,
765
+ // still lexicographically sortable since every timestamp is the same fixed
766
+ // ISO width), pruned to the newest `RUNS_ARCHIVE_KEEP_COUNT` afterward.
767
+ // ---------------------------------------------------------------------------
768
+
769
+ export const RUNS_ARCHIVE_DIR_NAME = 'runs';
770
+ export const RUNS_ARCHIVE_KEEP_COUNT = 20;
771
+
772
+ /** Windows-safe, still-sortable filename stem from an ISO timestamp — every
773
+ * `:`/`.` (both illegal in a Windows filename) becomes `-`; the result
774
+ * stays lexicographically ordered because `toISOString()` is fixed-width. */
775
+ function sanitizeTimestampForFilename(iso: string): string {
776
+ return iso.replace(/[:.]/g, '-');
777
+ }
778
+
779
+ /** Lowercase, filesystem-safe label for the archive filename — `'wedged'`
780
+ * when the envelope carries `wedged: true` (AC-B1.7's synthetic envelope,
781
+ * or a real one annotated in place — see `handleWedgeOutcome`), else the
782
+ * envelope's own Playwright `status` verbatim. */
783
+ function resultLabelForFilename(envelope: LastRunEnvelope): string {
784
+ const raw = envelope.wedged ? 'wedged' : envelope.status;
785
+ return String(raw)
786
+ .toLowerCase()
787
+ .replace(/[^a-z0-9]+/g, '-');
788
+ }
789
+
790
+ /** Deletes the OLDEST archived run files past `keep` — plain lexicographic
791
+ * sort works because every filename is prefixed with a fixed-width,
792
+ * sanitized ISO timestamp (see `sanitizeTimestampForFilename`), which sorts
793
+ * identically to chronological order. Best-effort: a missing/unreadable
794
+ * directory is a no-op (mirrors `clearStaleLastRun`'s own discipline). */
795
+ export function pruneRunsArchive(runsDir: string, keep: number = RUNS_ARCHIVE_KEEP_COUNT): void {
796
+ let entries: string[];
797
+ try {
798
+ entries = readdirSync(runsDir).filter((f) => f.endsWith('.json'));
799
+ } catch {
800
+ return;
801
+ }
802
+ entries.sort();
803
+ const excessCount = entries.length - keep;
804
+ for (let i = 0; i < excessCount; i++) {
805
+ try {
806
+ rmSync(join(runsDir, entries[i]!), { force: true });
807
+ } catch {
808
+ // best-effort — see doc comment above
809
+ }
810
+ }
811
+ }
812
+
813
+ /**
814
+ * Copies the just-finalized `last-run.json` (already written by either
815
+ * `ProbeJsonReporter.onEnd` or `handleWedgeOutcome`, by the time `main()`
816
+ * calls this) into the `runs/` archive, then prunes to the newest
817
+ * `RUNS_ARCHIVE_KEEP_COUNT`. Best-effort and silent on any failure — an
818
+ * archive-write problem must never fail the run itself or shadow the real
819
+ * exit code; `last-run.json` (this function's INPUT, never touched here) is
820
+ * the one contract callers already depend on.
821
+ */
822
+ export function archiveRun(input: { lastRunPath: string; runsDir: string }): void {
823
+ let envelope: LastRunEnvelope;
824
+ try {
825
+ envelope = JSON.parse(readFileSync(input.lastRunPath, 'utf-8')) as LastRunEnvelope;
826
+ } catch {
827
+ return;
828
+ }
829
+ try {
830
+ mkdirSync(input.runsDir, { recursive: true });
831
+ const fileName = `${sanitizeTimestampForFilename(envelope.startedAt)}-${resultLabelForFilename(envelope)}.json`;
832
+ writeFileSync(join(input.runsDir, fileName), `${JSON.stringify(envelope, null, 2)}\n`);
833
+ } catch {
834
+ return;
835
+ }
836
+ pruneRunsArchive(input.runsDir);
837
+ }
838
+
839
+ /** D4(b): the final exit code must fold in the CHILD's own exit code and the
840
+ * envelope's own `status`, not just `failures.length` — otherwise a run
841
+ * that recorded a freshly-written envelope with zero failures (e.g.
842
+ * Playwright's "No tests found" path, which can still invoke `onEnd` with
843
+ * an empty suite) would compute `anyTestFailed: false` and exit 0 even
844
+ * though the child itself exited non-zero / never actually passed.
845
+ * `envelope === undefined` (no envelope at all — step 7's `readFileSync`
846
+ * failed, likely because `clearStaleLastRun` above removed a stale one and
847
+ * nothing replaced it) falls back to the child's own exit code, exactly as
848
+ * before.
849
+ *
850
+ * D4-follow-up (Wave-6.1 dry-run friction #2): the fold above still leaves
851
+ * one dishonest-green seam — an envelope that reports `status: 'passed'`,
852
+ * `playwrightExit: 0`, zero `failures`, but ALSO zero `verdicts` (e.g. a
853
+ * `-g <pattern>` that matches nothing inside acceptance spec files that DO
854
+ * exist on disk; some Playwright versions treat that as a vacuous pass
855
+ * rather than an error). `specFilesFound` — step 1's own
856
+ * `checkZeroSpecs(...).specFiles.length`, always > 0 by the time this is
857
+ * called since step 1 already returns exit 2 otherwise — makes that case
858
+ * count as a real failure: zero tests actually running is never a real
859
+ * green when specs are known to exist. Optional/defaulted so every
860
+ * existing caller/fixture that doesn't pass `verdicts`/`specFilesFound`
861
+ * keeps its prior behavior untouched. */
862
+ export function computeSuiteExitCode(input: {
863
+ envelope:
864
+ | (Pick<LastRunEnvelope, 'status' | 'failures'> & {
865
+ verdicts?: LastRunEnvelope['verdicts'];
866
+ })
867
+ | undefined;
868
+ playwrightExit: number;
869
+ specFilesFound?: number;
870
+ }): number {
871
+ if (!input.envelope) {
872
+ return input.playwrightExit === 0 ? PROBE_EXIT_CODES.PASS : PROBE_EXIT_CODES.SPEC_FAILURE;
873
+ }
874
+ const zeroVerdictsMismatch =
875
+ (input.specFilesFound ?? 0) > 0 && (input.envelope.verdicts?.length ?? 0) === 0;
876
+ const anyTestFailed =
877
+ input.playwrightExit !== 0 ||
878
+ input.envelope.status !== 'passed' ||
879
+ input.envelope.failures.length > 0 ||
880
+ zeroVerdictsMismatch;
881
+ return computeExitCode({
882
+ anyTestFailed,
883
+ missingCapability:
884
+ !zeroVerdictsMismatch && allFailuresAreMissingCapability(input.envelope.failures),
885
+ });
886
+ }
887
+
888
+ /** Companion to `computeSuiteExitCode`'s zero-verdicts fold above — `main()`
889
+ * calls this separately (same inputs) purely to print a line NAMING the
890
+ * mismatch when it's what forced the failure, so `vgai probe: exit 1 (...)`
891
+ * is never left to speak for itself when the actual cause is "0 tests ran
892
+ * despite N spec files on disk". `undefined` when the condition doesn't
893
+ * hold (an ordinary failure already explains itself via its own output). */
894
+ export function describeZeroVerdictsMismatch(input: {
895
+ envelope: Pick<LastRunEnvelope, 'verdicts'> | undefined;
896
+ specFilesFound: number;
897
+ }): string | undefined {
898
+ if (!input.envelope) return undefined;
899
+ if (input.specFilesFound === 0 || input.envelope.verdicts.length > 0) return undefined;
900
+ return (
901
+ `vgai probe: 0 verdicts recorded despite ${input.specFilesFound} acceptance spec file(s) ` +
902
+ 'in tests/acceptance/ — a vacuous pass is not a real green; treating as a failure.'
903
+ );
904
+ }
905
+
906
+ // ---------------------------------------------------------------------------
907
+ // --last-failure
908
+ // ---------------------------------------------------------------------------
909
+
910
+ // #105b — `@vgai/probe`'s frozen attachment name for the AC-B1.8
911
+ // console/page-error + hidden-recovery + (since #105b) last-state/last-events
912
+ // artifact (`fixture.ts`'s `VGAI_PROBE_CONSOLE_LOG_ATTACHMENT`). Matched by
913
+ // literal string, not an import — this package deliberately never depends on
914
+ // `@vgai/probe` (see this file's module doc: the fixture is "another
915
+ // package's territory"; `probe-reporter.ts`'s `KNOWN_MESSAGE_CODE_PREFIXES`
916
+ // is the same precedent, matching a frozen token by value rather than by
917
+ // cross-package import).
918
+ const CONSOLE_LOG_ATTACHMENT_NAME = 'vgai-probe-console-log';
919
+
920
+ /** Reads a failure's `vgai-probe-console-log` attachment off disk, if one was
921
+ * recorded and its file still exists (a wedge/interrupted run, or a run
922
+ * whose `.vgai/probe/last-run/` output dir was since cleaned, has neither).
923
+ * `undefined` for a failure with no such attachment — never thrown. */
924
+ function readConsoleLogAttachment(f: ProbeFailureRecord): string | undefined {
925
+ const attachment = f.attachments.find((a) => a.name === CONSOLE_LOG_ATTACHMENT_NAME);
926
+ if (!attachment?.path || !existsSync(attachment.path)) return undefined;
927
+ try {
928
+ return readFileSync(attachment.path, 'utf-8').trimEnd();
929
+ } catch {
930
+ return undefined;
931
+ }
932
+ }
933
+
934
+ export function renderLastFailureReport(envelope: LastRunEnvelope): string {
935
+ if (envelope.failures.length === 0) {
936
+ return (
937
+ `vgai probe --last-failure: last run (${envelope.finishedAt}) had no failures ` +
938
+ `(status: ${envelope.status}).`
939
+ );
940
+ }
941
+ const blocks = envelope.failures.map((f: ProbeFailureRecord) => {
942
+ const tag = f.code ? ` [${f.code}]` : '';
943
+ // #105b: a `game.waitFor`/`game.events.expect` failure's `message` is
944
+ // already the full eight-member failure block (headline through
945
+ // console/page errors — see `failure-block.ts`), but a PLAIN `expect()`
946
+ // assertion failure written directly in a spec (never routed through
947
+ // that assembler) has only the bare assertion text here. Either way,
948
+ // append the console-log artifact when one was captured (AC-B1.8, fixture
949
+ // teardown, ALWAYS attempted on a failed test) — it carries capped
950
+ // last-observed state + recent events the bare message never had, so
951
+ // `--last-failure` is actionable without a separate `cat
952
+ // .vgai/probe/last-run/.../console-log.txt`.
953
+ const consoleLog = readConsoleLogAttachment(f);
954
+ const body = consoleLog ? `${f.message}\n\n${consoleLog}` : f.message;
955
+ return `--- ${f.title} (${f.file}:${f.line})${tag} ---\n${body}`;
956
+ });
957
+ return [
958
+ `vgai probe --last-failure: ${envelope.failures.length} failure(s) from the last run ` +
959
+ `(${envelope.finishedAt}):`,
960
+ '',
961
+ ...blocks,
962
+ ].join('\n\n');
963
+ }
964
+
965
+ // ---------------------------------------------------------------------------
966
+ // --init (Task 3.5)
967
+ // ---------------------------------------------------------------------------
968
+
969
+ // M11: this content must be SELF-CONTAINED against @vgai/probe's built-in
970
+ // providers ONLY (`time`, `input.actions` — Layer A, no game code required)
971
+ // — a prior version referenced a fictional `give`/`axe`/`wood`/`attack`
972
+ // game (never even matching the template's own real example component) and
973
+ // failed out of the box on any real project the moment `--init` promised a
974
+ // "worked example". The predicate callback reads `unknown` off `s(name)`
975
+ // (DebugSnapshot's state reader) — the one cast below is exactly what a
976
+ // real spec must do too.
977
+ export const EXAMPLE_SPEC_CONTENT = `import { expect, test } from '@vgai/probe';
978
+
979
+ // Scaffolded by \`vgai probe --init\`. Every acceptance spec goes through the
980
+ // same @vgai/probe fixture (see CLAUDE.md's "Acceptance tests" section).
981
+ // This spec is deliberately generic -- it only touches providers every vgai
982
+ // project gets for free (Layer A), so it passes unmodified on a brand-new
983
+ // project with no gameplay code yet. Swap in YOUR game's own
984
+ // providers/commands/actions (ctx.debug.registerStateProvider /
985
+ // registerCommand -- server-locus commands need { locus: 'server' } -- and
986
+ // your inputmap's declared actions) as soon as you have some. For the full
987
+ // game-specific pattern -- a fixture command (D16: commands are never
988
+ // proofs), the honest-input path via game.input.hold, game.events.expect,
989
+ // and a boot-race-safe waitFor before a one-shot read -- see the vgai-engine
990
+ // repo's own worked example:
991
+ // packages/editor/template/tests/acceptance/example.spec.ts
992
+
993
+ // The built-in providers every project gets for free, no game code required.
994
+ const BUILT_IN_PROVIDERS = ['time', 'input.actions'];
995
+
996
+ test('the debug bridge is alive: sim ticks forward from a cold boot', async ({ game }) => {
997
+ // a one-shot read would race cold boot (the sim may not have ticked yet
998
+ // under load), so this is a waitFor like every other time-dependent read.
999
+ await game.waitFor((s) => (s('time') as { tick: number }).tick > 0, { simSeconds: 10 });
1000
+ });
1001
+
1002
+ // Parametrization over content, not copy-pasted specs per case.
1003
+ for (const name of BUILT_IN_PROVIDERS) {
1004
+ test(\`built-in provider "\${name}" is enumerated\`, async ({ game }) => {
1005
+ const providers = await game.providers();
1006
+ expect(providers.map((p) => p.name)).toContain(name);
1007
+ });
1008
+ }
1009
+
1010
+ test('a mid-run screenshot can be captured', async ({ game }) => {
1011
+ await game.waitFor((s) => (s('time') as { tick: number }).tick > 0, { simSeconds: 10 });
1012
+ await game.screenshot('boot');
1013
+ });
1014
+ `;
1015
+
1016
+ export const TESTS_TSCONFIG_CONTENT = `{
1017
+ "compilerOptions": {
1018
+ "target": "ES2022",
1019
+ "module": "ESNext",
1020
+ "moduleResolution": "bundler",
1021
+ "lib": ["ES2022", "DOM"],
1022
+ "strict": true,
1023
+ "skipLibCheck": true,
1024
+ "esModuleInterop": true,
1025
+ "resolveJsonModule": true,
1026
+ "isolatedModules": true,
1027
+ "noEmit": true
1028
+ },
1029
+ "include": ["acceptance/**/*.ts"]
1030
+ }
1031
+ `;
1032
+
1033
+ export interface InitResult {
1034
+ written: string[];
1035
+ skipped: string[];
1036
+ }
1037
+
1038
+ /** Idempotent — never overwrites an existing file. */
1039
+ export function runInit(projectDir: string): InitResult {
1040
+ const written: string[] = [];
1041
+ const skipped: string[] = [];
1042
+
1043
+ const specDir = join(projectDir, 'tests', 'acceptance');
1044
+ const specPath = join(specDir, 'example.spec.ts');
1045
+ const tsconfigPath = join(projectDir, 'tests', 'tsconfig.json');
1046
+
1047
+ mkdirSync(specDir, { recursive: true });
1048
+ if (existsSync(specPath)) {
1049
+ skipped.push(specPath);
1050
+ } else {
1051
+ writeFileSync(specPath, EXAMPLE_SPEC_CONTENT);
1052
+ written.push(specPath);
1053
+ }
1054
+
1055
+ if (existsSync(tsconfigPath)) {
1056
+ skipped.push(tsconfigPath);
1057
+ } else {
1058
+ writeFileSync(tsconfigPath, TESTS_TSCONFIG_CONTENT);
1059
+ written.push(tsconfigPath);
1060
+ }
1061
+
1062
+ return { written, skipped };
1063
+ }
1064
+
1065
+ // ---------------------------------------------------------------------------
1066
+ // Small process/network helpers (deliberately NOT imported from
1067
+ // e2e/helpers/server.ts beyond `stopProcess` — see that file's own
1068
+ // `spawnDevServer` vs `startEditorServer` split, which `run-doctor.ts`'s
1069
+ // header comment explains: a caller that needs the child handle/pid
1070
+ // immediately writes its own spawn function; `stopProcess` alone is reused).
1071
+ // ---------------------------------------------------------------------------
1072
+
1073
+ function getFreePort(): Promise<number> {
1074
+ return new Promise((res, rej) => {
1075
+ const srv = net.createServer();
1076
+ srv.on('error', rej);
1077
+ srv.listen(0, '127.0.0.1', () => {
1078
+ const addr = srv.address();
1079
+ if (addr && typeof addr === 'object') {
1080
+ const port = addr.port;
1081
+ srv.close(() => res(port));
1082
+ } else {
1083
+ srv.close(() => rej(new Error('run-probe: could not determine a free port.')));
1084
+ }
1085
+ });
1086
+ });
1087
+ }
1088
+
1089
+ function isPortOpen(port: number, host = '127.0.0.1'): Promise<boolean> {
1090
+ return new Promise((resolveProbe) => {
1091
+ const sock = net.connect({ port, host }, () => {
1092
+ sock.end();
1093
+ resolveProbe(true);
1094
+ });
1095
+ sock.on('error', () => {
1096
+ sock.destroy();
1097
+ resolveProbe(false);
1098
+ });
1099
+ });
1100
+ }
1101
+
1102
+ async function waitForPortFree(
1103
+ port: number,
1104
+ host = '127.0.0.1',
1105
+ timeoutMs = 15_000,
1106
+ ): Promise<void> {
1107
+ const start = Date.now();
1108
+ while (Date.now() - start < timeoutMs) {
1109
+ if (!(await isPortOpen(port, host))) return;
1110
+ await new Promise((r) => setTimeout(r, 250));
1111
+ }
1112
+ throw new Error(
1113
+ `run-probe: refusing to start on ${host}:${port} — something is still bound there after ${timeoutMs}ms.`,
1114
+ );
1115
+ }
1116
+
1117
+ async function waitForPortOpenUntil(port: number, host: string, deadline: number): Promise<void> {
1118
+ for (;;) {
1119
+ if (await isPortOpen(port, host)) return;
1120
+ if (Date.now() >= deadline) {
1121
+ throw new Error(`run-probe: timed out waiting for ${host}:${port} to open`);
1122
+ }
1123
+ await new Promise((r) => setTimeout(r, 250));
1124
+ }
1125
+ }
1126
+
1127
+ async function waitForUrlUntil(url: string, deadline: number): Promise<void> {
1128
+ for (;;) {
1129
+ const left = deadline - Date.now();
1130
+ if (left <= 0) throw new Error(`run-probe: timed out waiting for ${url}`);
1131
+ try {
1132
+ const res = await fetch(url, { signal: AbortSignal.timeout(Math.min(left, 5000)) });
1133
+ if (res.ok) return;
1134
+ } catch {
1135
+ // not ready yet, or this probe's own short timeout fired — keep polling
1136
+ }
1137
+ await new Promise((r) => setTimeout(r, Math.min(250, Math.max(0, deadline - Date.now()))));
1138
+ }
1139
+ }
1140
+
1141
+ function canonicalPath(p: string): string {
1142
+ try {
1143
+ return realpathSync(p);
1144
+ } catch {
1145
+ return p;
1146
+ }
1147
+ }
1148
+
1149
+ // ---------------------------------------------------------------------------
1150
+ // Runner hardening preflights (docs/PROBE-EXTERNAL-INSTALL-DESIGN.md T3): a
1151
+ // project scaffolded outside the checkout resolves `@vgai/probe`'s
1152
+ // `@playwright/test` peerDependency via WHATEVER npm materialized
1153
+ // `node_modules/@vgai/probe` as — a COPY under the template's own
1154
+ // `install-links=true` .npmrc pin, or a SYMLINK back into the checkout if
1155
+ // that pin is ever missing/overridden. These preflights turn the two
1156
+ // concrete failure modes the design doc's Problem section found (a stale
1157
+ // raw-TS entry; two live `@playwright/test` instances in one process) into
1158
+ // a named, actionable exit 2 — never Playwright's own opaque "Requiring
1159
+ // @playwright/test second time" crash, and never a `.ts` file handed to
1160
+ // Node's loader.
1161
+ // ---------------------------------------------------------------------------
1162
+
1163
+ const PROBE_REINSTALL_HINT = 'reinstall the project (rm -rf node_modules && npm install)';
1164
+
1165
+ /** Resolves `@playwright/test`'s package.json path as seen from a
1166
+ * `createRequire` rooted at `fromDir` — `undefined` when unresolvable (no
1167
+ * such package reachable from there at all). */
1168
+ function resolvePlaywrightTestPkgFrom(fromDir: string): string | undefined {
1169
+ try {
1170
+ const req = createRequire(join(fromDir, 'package.json'));
1171
+ return req.resolve('@playwright/test/package.json');
1172
+ } catch {
1173
+ return undefined;
1174
+ }
1175
+ }
1176
+
1177
+ /**
1178
+ * Raw-TS preflight (test plan item 2): a pre-T1 scaffold (or a project whose
1179
+ * `@vgai/probe` install predates the shipped-dist change) resolves
1180
+ * `@vgai/probe`'s main entry to a `.ts` file under `node_modules` — Node's
1181
+ * loader has no transform hook for that and throws
1182
+ * `ERR_UNKNOWN_FILE_EXTENSION`. Caught here first so the message names the
1183
+ * actual cause instead of that generic Node error.
1184
+ */
1185
+ export function checkProbeEntryIsBuilt(projectDir: string): string | undefined {
1186
+ let entry: string;
1187
+ try {
1188
+ entry = createRequire(join(projectDir, 'package.json')).resolve('@vgai/probe');
1189
+ } catch {
1190
+ return undefined; // unresolvable entirely — a different failure surfaces downstream
1191
+ }
1192
+ if (!entry.endsWith('.ts')) return undefined;
1193
+ return (
1194
+ `vgai probe: @vgai/probe resolved to a raw TypeScript entry (${entry}) instead of compiled ` +
1195
+ `output — ${PROBE_REINSTALL_HINT} to pick up the current @vgai/probe, which ships dist/.`
1196
+ );
1197
+ }
1198
+
1199
+ /**
1200
+ * Single-@playwright/test-instance preflight (test plan items 2/3): resolves
1201
+ * `@playwright/test` from two vantage points — this project's own root, and
1202
+ * `@vgai/probe`'s own REAL (symlink-resolved) location — and fails the
1203
+ * moment they are two DIFFERENT physical files. Passing (one physical tree)
1204
+ * is the invariant, NOT version equality: a project whose own
1205
+ * `@playwright/test` is merely version-compatible with the one `@vgai/probe`
1206
+ * would reach is still a dual-instance hazard if they're different installs.
1207
+ * `undefined` (no failure) when either side can't be resolved at all —
1208
+ * that's a plain "not installed" case the ordinary run surfaces on its own.
1209
+ */
1210
+ export function checkSinglePlaywrightInstance(projectDir: string): string | undefined {
1211
+ const probeDir = join(projectDir, 'node_modules', '@vgai', 'probe');
1212
+ if (!existsSync(probeDir)) return undefined; // no npm install yet — nothing to compare
1213
+
1214
+ const probeRealDir = canonicalPath(probeDir);
1215
+ const fromProject = resolvePlaywrightTestPkgFrom(projectDir);
1216
+ const fromProbe = resolvePlaywrightTestPkgFrom(probeRealDir);
1217
+ if (!fromProject || !fromProbe) return undefined;
1218
+ if (canonicalPath(fromProject) === canonicalPath(fromProbe)) return undefined;
1219
+
1220
+ return (
1221
+ 'vgai probe: two different @playwright/test installations are reachable — one from this ' +
1222
+ `project (${fromProject}), one from @vgai/probe's own resolution (${fromProbe}). ` +
1223
+ 'Playwright fatals the moment both load in one process ("Requiring @playwright/test ' +
1224
+ 'second time"). This project\'s .npmrc should pin `install-links=true` (copy semantics) so ' +
1225
+ `@vgai/probe resolves this project's OWN @playwright/test — ${PROBE_REINSTALL_HINT} after ` +
1226
+ 'confirming .npmrc is present and un-edited.'
1227
+ );
1228
+ }
1229
+
1230
+ /** Resolves the project-local `@playwright/test` CLI entry (`playwright
1231
+ * test`'s actual implementation) via `createRequire` rooted at the
1232
+ * project's own `package.json` — replaces the former bare `npx playwright
1233
+ * test` spawn, which could silently fall through to npm's registry-fetch
1234
+ * fallback instead of the project's own installed copy. In-checkout
1235
+ * projects still resolve up to the root workspace install, unchanged.
1236
+ * Throws when unresolvable — callers map that to exit 2 naming `npm
1237
+ * install`. */
1238
+ export function resolvePlaywrightCliPath(projectDir: string): string {
1239
+ const req = createRequire(join(projectDir, 'package.json'));
1240
+ return req.resolve('@playwright/test/cli');
1241
+ }
1242
+
1243
+ async function probeGetRoot(port: number, timeoutMs = 1500): Promise<boolean> {
1244
+ try {
1245
+ const res = await fetch(`http://127.0.0.1:${port}/`, {
1246
+ signal: AbortSignal.timeout(timeoutMs),
1247
+ });
1248
+ return res.ok;
1249
+ } catch {
1250
+ return false;
1251
+ }
1252
+ }
1253
+
1254
+ // ---------------------------------------------------------------------------
1255
+ // Step 4 — boot/reuse the standalone game server (+ Colyseus, if declared)
1256
+ // ---------------------------------------------------------------------------
1257
+
1258
+ interface BootedServer {
1259
+ origin: string;
1260
+ port: number;
1261
+ pid: number;
1262
+ stop(): Promise<void>;
1263
+ }
1264
+
1265
+ /** Resolves `vite`'s CLI entry via the monorepo's own installation (same
1266
+ * resolution shape as `e2e/helpers/server.ts`'s `startStandaloneServer`),
1267
+ * duplicated here (rather than imported) because that helper's
1268
+ * `ServerHandle` never exposes the child's pid, which the session registry
1269
+ * needs to register/verify/kill a warm probe session. */
1270
+ function resolveViteBinJs(): string {
1271
+ const req = createRequire(import.meta.url);
1272
+ const viteMain = req.resolve('vite');
1273
+ const viteDir = viteMain.slice(0, viteMain.lastIndexOf(`${sep}vite${sep}`) + `${sep}vite`.length);
1274
+ return join(viteDir, 'bin', 'vite.js');
1275
+ }
1276
+
1277
+ async function bootGameServer(projectDir: string, port: number): Promise<BootedServer> {
1278
+ await waitForPortFree(port);
1279
+ const env: Record<string, string> = { ...(process.env as Record<string, string>) };
1280
+ // Watch disabled (the hollowstone reload-kills-the-run lesson) — harmless
1281
+ // and forward-compatible today: the template vite config learns this flag
1282
+ // in a concurrent Wave-4 task (Task 4.1), not yet landed.
1283
+ env['VGAI_NO_WATCH'] = '1';
1284
+ env['NO_COLOR'] = '1';
1285
+ env['FORCE_COLOR'] = '0';
1286
+ const viteBinJs = resolveViteBinJs();
1287
+ const proc = spawn(
1288
+ process.execPath,
1289
+ [viteBinJs, '--host', '127.0.0.1', '--strictPort', '--port', String(port)],
1290
+ {
1291
+ cwd: projectDir,
1292
+ env,
1293
+ stdio: 'ignore',
1294
+ // Own process group + unref'd: this server is meant to OUTLIVE this
1295
+ // `vgai probe` invocation (warm reuse — "reruns pay page navigation,
1296
+ // not server boot"). Only `--fresh` or `vgai close` tear it down.
1297
+ detached: process.platform !== 'win32',
1298
+ },
1299
+ );
1300
+ proc.unref();
1301
+ if (proc.pid === undefined) {
1302
+ throw new Error('run-probe: game server spawn produced no pid');
1303
+ }
1304
+ await waitForUrlUntil(`http://127.0.0.1:${port}/`, Date.now() + 30_000);
1305
+ const pid = proc.pid;
1306
+ return {
1307
+ origin: `http://127.0.0.1:${port}`,
1308
+ port,
1309
+ pid,
1310
+ stop: () => stopProcess(proc),
1311
+ };
1312
+ }
1313
+
1314
+ const COLYSEUS_DEFAULT_PORT = 2567;
1315
+
1316
+ async function bootColyseusServer(projectDir: string): Promise<BootedServer> {
1317
+ const port = COLYSEUS_DEFAULT_PORT;
1318
+ await waitForPortFree(port);
1319
+ const env: Record<string, string> = { ...(process.env as Record<string, string>) };
1320
+ // Frozen decision (build plan Task 3.4 anchors): the room-side
1321
+ // `__vgai:debugCommand` handler gates on this (landing in Wave 4).
1322
+ env['VGAI_ALLOW_DEBUG_COMMANDS'] = '1';
1323
+ const tsconfigPath = join(projectDir, 'server', 'tsconfig.json');
1324
+ const mainPath = join(projectDir, 'server', 'main.ts');
1325
+ const proc = spawn('npx', ['tsx', '--tsconfig', tsconfigPath, mainPath], {
1326
+ cwd: projectDir,
1327
+ env,
1328
+ stdio: 'ignore',
1329
+ shell: process.platform === 'win32',
1330
+ detached: process.platform !== 'win32',
1331
+ });
1332
+ proc.unref();
1333
+ if (proc.pid === undefined) {
1334
+ throw new Error('run-probe: colyseus server spawn produced no pid');
1335
+ }
1336
+ // Colyseus's HTTP upgrade endpoint doesn't answer a plain GET the way a
1337
+ // Vite server's index page does — a TCP-open probe is the honest readiness
1338
+ // signal here (matches this file's own `waitForPortFree`'s probe style).
1339
+ await waitForPortOpenUntil(port, '127.0.0.1', Date.now() + 30_000);
1340
+ const pid = proc.pid;
1341
+ return {
1342
+ origin: `ws://127.0.0.1:${port}`,
1343
+ port,
1344
+ pid,
1345
+ stop: () => stopProcess(proc),
1346
+ };
1347
+ }
1348
+
1349
+ export interface ResolvedServers {
1350
+ gameServer: { origin: string; port: number };
1351
+ colyseus: { origin: string; port: number } | null;
1352
+ reused: boolean;
1353
+ }
1354
+
1355
+ /**
1356
+ * A warm probe server has file watching disabled so an in-flight acceptance
1357
+ * run cannot be destroyed by HMR. Consequently it must be restarted before
1358
+ * reuse when game inputs changed after the session was registered.
1359
+ */
1360
+ export function projectChangedSinceProbeStarted(projectDir: string, startedAt: string): boolean {
1361
+ const startedMs = Date.parse(startedAt);
1362
+ if (!Number.isFinite(startedMs)) return true;
1363
+ const roots = [
1364
+ 'src',
1365
+ 'public',
1366
+ 'server',
1367
+ 'vgai.game.json',
1368
+ 'vite.config.ts',
1369
+ 'index.html',
1370
+ 'package.json',
1371
+ ];
1372
+ const newer = (path: string): boolean => {
1373
+ if (!existsSync(path)) return false;
1374
+ const stat = statSync(path);
1375
+ if (stat.mtimeMs > startedMs) return true;
1376
+ if (!stat.isDirectory()) return false;
1377
+ return readdirSync(path, { withFileTypes: true }).some((entry) =>
1378
+ newer(join(path, entry.name)),
1379
+ );
1380
+ };
1381
+ return roots.some((path) => newer(join(projectDir, path)));
1382
+ }
1383
+
1384
+ /** Warm reuse (AC-B1.6): a live `kind: 'probe'` session for this project that
1385
+ * answers `GET /` skips the boot entirely. `--fresh` kills it by pid first.
1386
+ * Multiplayer games get a second `kind: 'probe'` session entry for the
1387
+ * Colyseus leg (same `project`, so `vgai close <project>` tears down both
1388
+ * with no changes needed there — per the build plan's frozen decision). */
1389
+ async function resolveServers(
1390
+ projectDir: string,
1391
+ needsColyseus: boolean,
1392
+ fresh: boolean,
1393
+ ): Promise<ResolvedServers> {
1394
+ const canon = canonicalPath(projectDir);
1395
+
1396
+ const killMatchingSessions = (): void => {
1397
+ for (const s of liveSessions()) {
1398
+ if (s.kind === 'probe' && s.project !== null && canonicalPath(s.project) === canon) {
1399
+ try {
1400
+ process.kill(s.pid, 'SIGTERM');
1401
+ } catch {
1402
+ // already gone
1403
+ }
1404
+ unregisterSession(s.pid);
1405
+ }
1406
+ }
1407
+ };
1408
+
1409
+ if (fresh) {
1410
+ killMatchingSessions();
1411
+ } else {
1412
+ const matching = liveSessions().filter(
1413
+ (s: EditorSession) =>
1414
+ s.kind === 'probe' && s.project !== null && canonicalPath(s.project) === canon,
1415
+ );
1416
+ const gamePageSession = matching.find((s) => s.port !== COLYSEUS_DEFAULT_PORT);
1417
+ const projectChanged = gamePageSession
1418
+ ? projectChangedSinceProbeStarted(projectDir, gamePageSession.startedAt)
1419
+ : false;
1420
+ if (gamePageSession && projectChanged) {
1421
+ killMatchingSessions();
1422
+ }
1423
+ if (gamePageSession && !projectChanged && (await probeGetRoot(gamePageSession.port))) {
1424
+ let colyseus: { origin: string; port: number } | null = null;
1425
+ if (needsColyseus) {
1426
+ if (await isPortOpen(COLYSEUS_DEFAULT_PORT)) {
1427
+ colyseus = {
1428
+ origin: `ws://127.0.0.1:${COLYSEUS_DEFAULT_PORT}`,
1429
+ port: COLYSEUS_DEFAULT_PORT,
1430
+ };
1431
+ } else {
1432
+ const booted = await bootColyseusServer(projectDir);
1433
+ registerSession({
1434
+ project: canon,
1435
+ port: booted.port,
1436
+ pid: booted.pid,
1437
+ startedAt: new Date().toISOString(),
1438
+ kind: 'probe',
1439
+ });
1440
+ colyseus = { origin: booted.origin, port: booted.port };
1441
+ }
1442
+ }
1443
+ return {
1444
+ gameServer: {
1445
+ origin: `http://127.0.0.1:${gamePageSession.port}`,
1446
+ port: gamePageSession.port,
1447
+ },
1448
+ colyseus,
1449
+ reused: true,
1450
+ };
1451
+ }
1452
+ }
1453
+
1454
+ const port = await getFreePort();
1455
+ const booted = await bootGameServer(projectDir, port);
1456
+ registerSession({
1457
+ project: canon,
1458
+ port: booted.port,
1459
+ pid: booted.pid,
1460
+ startedAt: new Date().toISOString(),
1461
+ kind: 'probe',
1462
+ });
1463
+
1464
+ let colyseus: { origin: string; port: number } | null = null;
1465
+ if (needsColyseus) {
1466
+ const bootedColyseus = await bootColyseusServer(projectDir);
1467
+ registerSession({
1468
+ project: canon,
1469
+ port: bootedColyseus.port,
1470
+ pid: bootedColyseus.pid,
1471
+ startedAt: new Date().toISOString(),
1472
+ kind: 'probe',
1473
+ });
1474
+ colyseus = { origin: bootedColyseus.origin, port: bootedColyseus.port };
1475
+ }
1476
+
1477
+ return {
1478
+ gameServer: { origin: booted.origin, port: booted.port },
1479
+ colyseus,
1480
+ reused: false,
1481
+ };
1482
+ }
1483
+
1484
+ // ---------------------------------------------------------------------------
1485
+ // Step 6 — run the suite; watch for a harness wedge
1486
+ // ---------------------------------------------------------------------------
1487
+
1488
+ export interface PlaywrightRunResult {
1489
+ exitCode: number;
1490
+ wedged: boolean;
1491
+ /** Set only when `opts.headed` and the child's output matched
1492
+ * `classifyHeadedLaunchFailure` (no display available) — the caller
1493
+ * reports this teaching hint and treats it as a usage-class failure
1494
+ * rather than an ordinary spec failure. */
1495
+ headedNoDisplayHint?: string;
1496
+ /** Set only when `wedged` — the actual silent-window budget (ms) that
1497
+ * triggered the kill (`strikeIntervalMs * strikesToWedge`, whichever
1498
+ * values were in effect for this run), fed to `formatHarnessWedgeMessage`
1499
+ * so the printed message never hardcodes a number the watchdog wasn't
1500
+ * actually using. */
1501
+ wedgeSilenceMs?: number;
1502
+ }
1503
+
1504
+ /** Default silent-window budget: two 90s strikes (3 minutes total) — see
1505
+ * `runPlaywrightSuite`'s doc comment for why. Named here (not just inlined
1506
+ * as `?? 90_000`) so `main()` can reconstruct the same default for the
1507
+ * wedge message when a caller never overrides it. */
1508
+ const DEFAULT_STRIKE_INTERVAL_MS = 90_000;
1509
+ const DEFAULT_STRIKES_TO_WEDGE = 2;
1510
+
1511
+ /** Pure — renders the exit-5 wedge message. Without a live Page/Browser
1512
+ * handle (see this file's header "KNOWN GAPS" note) the runner genuinely
1513
+ * cannot distinguish "the renderer/test-runner truly hung" from "a single
1514
+ * test is legitimately running long and silent under low sim speed" — a
1515
+ * blind validation run's own finding was that the bare "stopped making
1516
+ * progress" wording read as an unconditional hang diagnosis. This names
1517
+ * BOTH possibilities and the remedy for the benign one. */
1518
+ export function formatHarnessWedgeMessage(silenceMs: number): string {
1519
+ const silenceSeconds = Math.round(silenceMs / 1000);
1520
+ return (
1521
+ `vgai probe: the harness produced no output for ${silenceSeconds}s — either a true hang, ` +
1522
+ 'or a single test legitimately running long and silent under low sim speed; if the perf ' +
1523
+ 'lines above show simSpeed well below 1x, re-run with -g on the long test or reduce ' +
1524
+ 'staged work with fixtures.'
1525
+ );
1526
+ }
1527
+
1528
+ /**
1529
+ * AC-B1.7 — a wedge kills the child before Playwright's OWN `--junit`
1530
+ * reporter can ever run its `onEnd` (same lifecycle as our own
1531
+ * `ProbeJsonReporter` — see `buildWedgeEnvelope`'s doc comment), so a
1532
+ * `--junit` run that wedges either writes NO XML at all, or leaves a STALE
1533
+ * file from some earlier run sitting there untouched (nothing clears
1534
+ * `junit.xml` the way `clearStaleLastRun` clears `last-run.json`). Silence
1535
+ * here would let a caller who only checks "does the file exist" believe a
1536
+ * stale leftover is this run's result. Pure — `xmlExists` is the caller's own
1537
+ * `existsSync` read, not performed here, so this is trivially unit-testable
1538
+ * without touching the filesystem. `undefined` when `--junit` was never
1539
+ * requested at all (nothing to say).
1540
+ *
1541
+ * `suiteAlreadyFinished` covers the OTHER wedge shape (see
1542
+ * `handleWedgeOutcome`): the child actually reached Playwright's `onEnd`
1543
+ * (both reporters ran — `last-run.json` already existed at wedge time) and
1544
+ * only hung AFTERWARD, e.g. on trailing teardown that never quiesces the
1545
+ * watchdog's stdout-liveness check. There, an existing `junit.xml` is this
1546
+ * run's real, freshly-written report — calling it "STALE" would be false.
1547
+ */
1548
+ export function describeWedgeJunitOutcome(opts: {
1549
+ junitRequested: boolean;
1550
+ junitOutputPath: string | undefined;
1551
+ xmlExists: boolean;
1552
+ suiteAlreadyFinished?: boolean;
1553
+ }): string | undefined {
1554
+ if (!opts.junitRequested) return undefined;
1555
+ if (opts.xmlExists) {
1556
+ if (opts.suiteAlreadyFinished) {
1557
+ return (
1558
+ `vgai probe: --junit was requested (${opts.junitOutputPath}) and was written — this run ` +
1559
+ "completed (Playwright's reporters already ran onEnd) before the harness wedge fired on " +
1560
+ "trailing silence; the XML is this run's real result, not a stale leftover."
1561
+ );
1562
+ }
1563
+ return (
1564
+ `vgai probe: --junit was requested (${opts.junitOutputPath}) but this run wedged (exit 5) ` +
1565
+ "before Playwright's own junit reporter could run — the file that exists is a STALE " +
1566
+ 'leftover from an earlier run, not this one.'
1567
+ );
1568
+ }
1569
+ return (
1570
+ `vgai probe: --junit was requested (${opts.junitOutputPath}) but no JUnit XML was written — ` +
1571
+ "this run wedged (exit 5) before Playwright's own reporters ever ran onEnd."
1572
+ );
1573
+ }
1574
+
1575
+ export interface WedgeOutcomeInput {
1576
+ /** The actual silent-window budget that triggered the kill, straight off
1577
+ * `runPlaywrightSuite`'s result — `undefined` only defensively (should
1578
+ * always be set alongside `wedged: true`), falls back to the real-world
1579
+ * default. */
1580
+ wedgeSilenceMs: number | undefined;
1581
+ lastRunPath: string;
1582
+ projectDir: string;
1583
+ artifactsDir: string;
1584
+ /** ISO timestamp captured right before step 6 spawned the suite — NOT
1585
+ * computed inside this function, so a caller can pass a fixed value in a
1586
+ * test rather than racing `Date.now()`. */
1587
+ suiteStartedAt: string;
1588
+ junitRequested: boolean;
1589
+ junitOutputPath: string | undefined;
1590
+ }
1591
+
1592
+ /**
1593
+ * AC-B1.7 — everything `main()` does once `runPlaywrightSuite` reports
1594
+ * `wedged: true`: print the wedge message, record the wedge on
1595
+ * `last-run.json`, and print an honest note about the `--junit` XML via
1596
+ * {@link describeWedgeJunitOutcome}. Extracted out of `main()` as its own
1597
+ * function — real fs side effects, real paths, no fake filesystem — purely
1598
+ * so this glue is directly unit-testable without spawning a real child and
1599
+ * waiting out the real-world 90s x2 wedge budget (see `probe-runner.test.ts`'s
1600
+ * wedge-outcome tests, which pass a synthetic `wedgeSilenceMs` the way a
1601
+ * genuinely wedged run would). Returns the exit code `main()` should set
1602
+ * (always `PROBE_EXIT_CODES.HARNESS_WEDGE`) so `setExit` stays the one call
1603
+ * site that touches `process.exitCode`.
1604
+ *
1605
+ * Two distinct shapes hide behind one `wedged: true` result from
1606
+ * `runPlaywrightSuite`, distinguished here by whether `last-run.json` already
1607
+ * exists:
1608
+ *
1609
+ * - **Genuine mid-suite kill** (the common case): `clearStaleLastRun` already
1610
+ * deleted any PRIOR `last-run.json` before the suite spawned, and
1611
+ * `ProbeJsonReporter.onEnd` never got to write a fresh one — the child was
1612
+ * killed before any test reached `onTestEnd`. Without a write here,
1613
+ * `last-run.json` would simply not exist, indistinguishable from "no run
1614
+ * has ever happened" to `--last-failure` or any other reader — so this
1615
+ * writes the synthetic {@link buildWedgeEnvelope}.
1616
+ * - **Finished, then hung** (e.g. dangling teardown handle keeping the
1617
+ * process alive after the suite is done): both reporters' `onEnd` already
1618
+ * ran — `last-run.json` on disk is the REAL envelope, with real
1619
+ * verdicts/failures/status, before the stdout-liveness watchdog ever fired.
1620
+ * Overwriting it with the empty synthetic envelope would silently destroy
1621
+ * a real, possibly all-green result and misreport it as `interrupted`. This
1622
+ * branch instead reads the existing envelope back and merges `wedged: true`
1623
+ * onto it in place — preserving every real field — so a consumer still
1624
+ * learns the harness had to intervene, without losing the actual outcome.
1625
+ */
1626
+ export function handleWedgeOutcome(input: WedgeOutcomeInput): number {
1627
+ const resolvedWedgeSilenceMs =
1628
+ input.wedgeSilenceMs ?? DEFAULT_STRIKE_INTERVAL_MS * DEFAULT_STRIKES_TO_WEDGE;
1629
+ console.error(formatHarnessWedgeMessage(resolvedWedgeSilenceMs));
1630
+ const suiteAlreadyFinished = existsSync(input.lastRunPath);
1631
+ if (suiteAlreadyFinished) {
1632
+ // `onEnd` already ran and wrote the real envelope — annotate, don't clobber.
1633
+ const existingEnvelope = JSON.parse(
1634
+ readFileSync(input.lastRunPath, 'utf-8'),
1635
+ ) as LastRunEnvelope;
1636
+ writeFileSync(
1637
+ input.lastRunPath,
1638
+ `${JSON.stringify({ ...existingEnvelope, wedged: true }, null, 2)}\n`,
1639
+ );
1640
+ } else {
1641
+ writeFileSync(
1642
+ input.lastRunPath,
1643
+ `${JSON.stringify(
1644
+ buildWedgeEnvelope({
1645
+ project: input.projectDir,
1646
+ startedAt: input.suiteStartedAt,
1647
+ wedgeSilenceMs: resolvedWedgeSilenceMs,
1648
+ outputDir: input.artifactsDir,
1649
+ }),
1650
+ null,
1651
+ 2,
1652
+ )}\n`,
1653
+ );
1654
+ }
1655
+ const junitNote = describeWedgeJunitOutcome({
1656
+ junitRequested: input.junitRequested,
1657
+ junitOutputPath: input.junitOutputPath,
1658
+ xmlExists: input.junitOutputPath !== undefined && existsSync(input.junitOutputPath),
1659
+ suiteAlreadyFinished,
1660
+ });
1661
+ if (junitNote) console.error(junitNote);
1662
+ return PROBE_EXIT_CODES.HARNESS_WEDGE;
1663
+ }
1664
+
1665
+ // Bound on how much combined stdout/stderr text `runPlaywrightSuite` retains
1666
+ // purely for the headed-no-display check below — the full output is already
1667
+ // mirrored to this process's own stdout/stderr as it streams; this is a
1668
+ // rolling tail, not a second full copy, so a long-running suite never grows
1669
+ // this buffer unbounded.
1670
+ const HEADED_ERROR_SCAN_TAIL_BYTES = 16_384;
1671
+
1672
+ /** Spawns the project-resolved `@playwright/test` CLI (`node <cliPath> test
1673
+ * --config <configPath>` — see `resolvePlaywrightCliPath`'s doc comment for
1674
+ * why this replaced a bare `npx playwright test` spawn: `npx` can silently
1675
+ * fall through to a registry-fetch fallback instead of the project's own
1676
+ * installed copy, which is exactly the dual-@playwright/test hazard T3's
1677
+ * preflights exist to prevent) and mirrors its stdout/stderr. See this
1678
+ * file's header "KNOWN GAPS" note for why the wedge watchdog here is
1679
+ * stdout-liveness-based (2 silent 90s windows -> kill the child process
1680
+ * TREE -> exit 5) rather than hollowstone's page-level eval-race/
1681
+ * bringToFront recovery — this layer never holds a live Page handle.
1682
+ *
1683
+ * Why 90s x2 (3 minutes of total silence) and not something tighter: the
1684
+ * Playwright list reporter only prints a per-test line when a test ENDS,
1685
+ * not while it runs — so any single test that is legitimately quiet for a
1686
+ * while (the template's ~22s spinner spec under load, any sim-time
1687
+ * `waitFor` with a generous `simSeconds` budget) produces long stretches
1688
+ * with zero stdout even though the suite is making fine progress. The
1689
+ * budget here must clear the longest legitimately-silent test by a wide
1690
+ * margin — it exists to catch a genuinely hung child (renderer wedge,
1691
+ * crashed browser, deadlocked test), not to police slow tests. 3 minutes
1692
+ * is still far below any CI job timeout.
1693
+ * `strikeIntervalMs`/`strikesToWedge` are injectable (tests only) so the
1694
+ * watchdog's real-world defaults never have to be temporarily shrunk to
1695
+ * exercise this logic under fake timers. */
1696
+ export async function runPlaywrightSuite(opts: {
1697
+ cwd: string;
1698
+ configPath: string;
1699
+ env: NodeJS.ProcessEnv;
1700
+ passthroughArgs: string[];
1701
+ /** The resolved `@playwright/test/cli` entry (`resolvePlaywrightCliPath`) —
1702
+ * spawned via `process.execPath`, never `npx`. */
1703
+ cliPath: string;
1704
+ /** `--headed`: on a non-zero exit, the tail of the child's own output is
1705
+ * scanned for its "no display" launch failure (see
1706
+ * `classifyHeadedLaunchFailure`) and surfaced as `headedNoDisplayHint`. */
1707
+ headed?: boolean;
1708
+ /** Test-only override; defaults to 90_000. */
1709
+ strikeIntervalMs?: number;
1710
+ /** Test-only override; defaults to 2. */
1711
+ strikesToWedge?: number;
1712
+ }): Promise<PlaywrightRunResult> {
1713
+ return new Promise((resolvePromise) => {
1714
+ const child: ChildProcess = spawn(
1715
+ process.execPath,
1716
+ [opts.cliPath, 'test', '--config', opts.configPath, ...opts.passthroughArgs],
1717
+ {
1718
+ cwd: opts.cwd,
1719
+ env: opts.env,
1720
+ stdio: ['ignore', 'pipe', 'pipe'],
1721
+ detached: process.platform !== 'win32',
1722
+ shell: process.platform === 'win32',
1723
+ },
1724
+ );
1725
+
1726
+ let settled = false;
1727
+ let lastOutputAt = Date.now();
1728
+ let strikes = 0;
1729
+ let outputTail = '';
1730
+ const STRIKE_INTERVAL_MS = opts.strikeIntervalMs ?? DEFAULT_STRIKE_INTERVAL_MS;
1731
+ const STRIKES_TO_WEDGE = opts.strikesToWedge ?? DEFAULT_STRIKES_TO_WEDGE;
1732
+
1733
+ // Live-proven race (found while writing this file's own wedge test,
1734
+ // AC-B1.7): on a REAL spawned child, sending SIGTERM/SIGKILL
1735
+ // (`stopProcess`, below) makes the OS kill the process directly — its
1736
+ // 'close' event (registered at the bottom of this function) then fires
1737
+ // almost immediately, well before `stopProcess`'s OWN promise resolves
1738
+ // (that promise waits on its own 'exit' listener, attached only once
1739
+ // `stopProcess` is called, or its 5s internal fallback timer). The old
1740
+ // code decided "wedged" ONLY in the `.then()` continuation below — so the
1741
+ // 'close' handler's ordinary `finishWithExit` almost always won that
1742
+ // race in practice and reported a plain exit code (e.g. `1`, from a
1743
+ // signal-killed child's `code === null` folding to `?? 1`), never
1744
+ // `wedged: true`/exit 5. A `FakeChild` test double never emits a real
1745
+ // 'close' event during this scenario (nothing calls `.emit('close', …)`
1746
+ // in the wedge tests), which is why this never surfaced before. Fixed by
1747
+ // deciding "this run is a wedge" the INSTANT the strike budget is
1748
+ // crossed — `wedgeTriggered` below — so whichever completion path fires
1749
+ // first (the real child's 'close', or `stopProcess`'s own fallback
1750
+ // resolution) reports the SAME correct wedged result.
1751
+ let wedgeTriggered = false;
1752
+ let triggeredWedgeSilenceMs = 0;
1753
+ const wedgeResult = (): PlaywrightRunResult => ({
1754
+ exitCode: PROBE_EXIT_CODES.HARNESS_WEDGE,
1755
+ wedged: true,
1756
+ wedgeSilenceMs: triggeredWedgeSilenceMs,
1757
+ });
1758
+
1759
+ const trackTail = (d: Buffer): void => {
1760
+ if (!opts.headed) return;
1761
+ outputTail = (outputTail + d.toString()).slice(-HEADED_ERROR_SCAN_TAIL_BYTES);
1762
+ };
1763
+
1764
+ child.stdout?.on('data', (d: Buffer) => {
1765
+ lastOutputAt = Date.now();
1766
+ trackTail(d);
1767
+ process.stdout.write(d);
1768
+ });
1769
+ child.stderr?.on('data', (d: Buffer) => {
1770
+ lastOutputAt = Date.now();
1771
+ trackTail(d);
1772
+ process.stderr.write(d);
1773
+ });
1774
+
1775
+ const finish = (result: PlaywrightRunResult): void => {
1776
+ if (settled) return;
1777
+ settled = true;
1778
+ clearInterval(watchdog);
1779
+ resolvePromise(result);
1780
+ };
1781
+
1782
+ const watchdog = setInterval(() => {
1783
+ if (settled || wedgeTriggered) return;
1784
+ if (Date.now() - lastOutputAt >= STRIKE_INTERVAL_MS) {
1785
+ strikes += 1;
1786
+ if (strikes >= STRIKES_TO_WEDGE) {
1787
+ wedgeTriggered = true;
1788
+ triggeredWedgeSilenceMs = STRIKE_INTERVAL_MS * STRIKES_TO_WEDGE;
1789
+ // Fire-and-forget: `finishWithExit` below now reports the wedge
1790
+ // the moment the real child's 'close' fires (the common case —
1791
+ // see the doc comment above `wedgeTriggered`'s declaration); this
1792
+ // `.then()` is only the BACKSTOP for a double that never emits
1793
+ // 'close' at all (a test double, or the pathological case of a
1794
+ // real child whose stdio streams somehow never close).
1795
+ void stopProcess(child).then(() => finish(wedgeResult()));
1796
+ }
1797
+ } else {
1798
+ strikes = 0;
1799
+ }
1800
+ }, STRIKE_INTERVAL_MS);
1801
+
1802
+ const finishWithExit = (code: number | null): void => {
1803
+ if (wedgeTriggered) {
1804
+ finish(wedgeResult());
1805
+ return;
1806
+ }
1807
+ const exitCode = code ?? 1;
1808
+ const headedNoDisplayHint =
1809
+ opts.headed && exitCode !== 0 ? classifyHeadedLaunchFailure(outputTail) : undefined;
1810
+ finish({
1811
+ exitCode,
1812
+ wedged: false,
1813
+ ...(headedNoDisplayHint ? { headedNoDisplayHint } : {}),
1814
+ });
1815
+ };
1816
+
1817
+ child.on('close', (code) => finishWithExit(code));
1818
+ child.on('error', () => finishWithExit(1));
1819
+ });
1820
+ }
1821
+
1822
+ // ---------------------------------------------------------------------------
1823
+ // Main
1824
+ // ---------------------------------------------------------------------------
1825
+
1826
+ async function main(): Promise<void> {
1827
+ const argv = process.argv.slice(2);
1828
+ let args: ProbeCliArgs;
1829
+ try {
1830
+ args = parseProbeArgs(argv);
1831
+ } catch (err) {
1832
+ console.error(err instanceof Error ? err.message : String(err));
1833
+ console.error(PROBE_USAGE_TEXT);
1834
+ setExit(PROBE_EXIT_CODES.USAGE);
1835
+ return;
1836
+ }
1837
+ const { projectDir } = args;
1838
+ const jsonMode = args.json;
1839
+ const log = (...a: unknown[]): void => {
1840
+ if (jsonMode) console.error(...a);
1841
+ else console.log(...a);
1842
+ };
1843
+
1844
+ // `--run-file` only means anything alongside `--last-failure` (it names
1845
+ // which archived run to re-print). Without it, it's silently a no-op —
1846
+ // warn once rather than let a typo'd/misordered flag combination look like
1847
+ // it did something. Behavior is unchanged either way.
1848
+ if (args.runFile !== undefined && !args.lastFailure) {
1849
+ console.error('--run-file has no effect without --last-failure');
1850
+ }
1851
+
1852
+ if (!existsSync(projectDir) || !statSync(projectDir).isDirectory()) {
1853
+ console.error(`vgai probe: folder not found: ${projectDir}`);
1854
+ setExit(PROBE_EXIT_CODES.USAGE);
1855
+ return;
1856
+ }
1857
+
1858
+ if (args.init) {
1859
+ const result = runInit(projectDir);
1860
+ for (const f of result.written)
1861
+ console.log(`vgai probe --init: wrote ${relative(projectDir, f)}`);
1862
+ for (const f of result.skipped) {
1863
+ console.log(`vgai probe --init: ${relative(projectDir, f)} already exists — left untouched`);
1864
+ }
1865
+ setExit(PROBE_EXIT_CODES.PASS);
1866
+ return;
1867
+ }
1868
+
1869
+ const lastRunPath = join(projectDir, '.vgai', 'probe', 'last-run.json');
1870
+ const runsDir = join(projectDir, '.vgai', 'probe', RUNS_ARCHIVE_DIR_NAME);
1871
+
1872
+ if (args.lastFailure) {
1873
+ // `runs/` archive: `--run-file <path>` reads one of the archived
1874
+ // envelopes instead of the single-slot `last-run.json` — relative paths
1875
+ // resolve against `projectDir` (same convention as `<projectDir>` itself).
1876
+ const targetPath = args.runFile ? resolve(projectDir, args.runFile) : lastRunPath;
1877
+ if (!existsSync(targetPath)) {
1878
+ if (args.runFile) {
1879
+ // An EXPLICIT --run-file path that doesn't exist is almost always a
1880
+ // typo/bad path, not a "never probed this project" situation — don't
1881
+ // send the user off to re-run the whole suite when the fix is to
1882
+ // check the path they typed.
1883
+ console.error(
1884
+ `vgai probe --run-file: ${relative(projectDir, targetPath)} not found — check the path.`,
1885
+ );
1886
+ } else {
1887
+ console.error(
1888
+ `vgai probe --last-failure: no ${relative(projectDir, targetPath)} found — run \`vgai probe\` first.`,
1889
+ );
1890
+ }
1891
+ setExit(PROBE_EXIT_CODES.USAGE);
1892
+ return;
1893
+ }
1894
+ const envelope = JSON.parse(readFileSync(targetPath, 'utf-8')) as LastRunEnvelope;
1895
+ if (jsonMode) console.log(JSON.stringify(envelope, null, 2));
1896
+ else console.log(renderLastFailureReport(envelope));
1897
+ setExit(
1898
+ computeExitCode({
1899
+ anyTestFailed: envelope.failures.length > 0,
1900
+ missingCapability: allFailuresAreMissingCapability(envelope.failures),
1901
+ }),
1902
+ );
1903
+ return;
1904
+ }
1905
+
1906
+ // Step 1
1907
+ const { zero, specFiles } = checkZeroSpecs(projectDir);
1908
+ if (zero) {
1909
+ console.error(ZERO_SPECS_MESSAGE);
1910
+ setExit(PROBE_EXIT_CODES.USAGE);
1911
+ return;
1912
+ }
1913
+
1914
+ // Step 2
1915
+ log('vgai probe: type-checking tests/acceptance (tests/tsconfig.json)...');
1916
+ const typecheck = await runAcceptanceTypecheck(projectDir);
1917
+ if (!typecheck.ok) {
1918
+ console.error(typecheck.output);
1919
+ console.error('probe: type errors in tests/acceptance — fix before the browser boots');
1920
+ setExit(PROBE_EXIT_CODES.SPEC_FAILURE);
1921
+ return;
1922
+ }
1923
+
1924
+ // Step 3
1925
+ const violations = lintAcceptanceSpecs(specFiles);
1926
+ if (violations.length > 0) {
1927
+ for (const v of violations) console.error(formatLintViolation(v, projectDir));
1928
+ setExit(PROBE_EXIT_CODES.USAGE);
1929
+ return;
1930
+ }
1931
+
1932
+ let manifest: ResolvedGameManifest;
1933
+ try {
1934
+ manifest = loadGameManifestFile(join(projectDir, 'vgai.game.json'));
1935
+ } catch (err) {
1936
+ console.error(
1937
+ `vgai probe: could not load vgai.game.json — ${err instanceof Error ? err.message : err}`,
1938
+ );
1939
+ setExit(PROBE_EXIT_CODES.USAGE);
1940
+ return;
1941
+ }
1942
+ const needsColyseus = !!manifest.server?.room;
1943
+
1944
+ for (const name of ['playwright.config.ts', 'playwright.config.js', 'playwright.config.mjs']) {
1945
+ if (existsSync(join(projectDir, name))) {
1946
+ console.error(`probe: ignoring ${name} — probe owns the runner config`);
1947
+ }
1948
+ }
1949
+
1950
+ // Step 3.5 — external-install preflights (docs/PROBE-EXTERNAL-INSTALL-DESIGN.md
1951
+ // T3): resolve the project's own @playwright/test CLI, and fail loudly on
1952
+ // either of the two hazards a project scaffolded outside the checkout can
1953
+ // hit (a raw-TS @vgai/probe entry; two live @playwright/test instances) —
1954
+ // before booting any server, so a doomed run fails fast.
1955
+ const rawTsFailure = checkProbeEntryIsBuilt(projectDir);
1956
+ if (rawTsFailure) {
1957
+ console.error(rawTsFailure);
1958
+ setExit(PROBE_EXIT_CODES.USAGE);
1959
+ return;
1960
+ }
1961
+ const dualPlaywrightFailure = checkSinglePlaywrightInstance(projectDir);
1962
+ if (dualPlaywrightFailure) {
1963
+ console.error(dualPlaywrightFailure);
1964
+ setExit(PROBE_EXIT_CODES.USAGE);
1965
+ return;
1966
+ }
1967
+ let playwrightCliPath: string;
1968
+ try {
1969
+ playwrightCliPath = resolvePlaywrightCliPath(projectDir);
1970
+ } catch {
1971
+ console.error(
1972
+ 'vgai probe: could not resolve @playwright/test from this project — run `npm install`.',
1973
+ );
1974
+ setExit(PROBE_EXIT_CODES.USAGE);
1975
+ return;
1976
+ }
1977
+
1978
+ // Step 4 — #140: `--in-editor` skips this ENTIRELY (no vite boot, no
1979
+ // Colyseus boot, no new session-registry entry) — the live editor session
1980
+ // named by `args.sessionPort` IS the server this run drives, over
1981
+ // `@vgai/probe`'s `RelayTransport` instead of a page navigated to a
1982
+ // standalone origin. Multiplayer projects (`needsColyseus`) are a known
1983
+ // gap in this mode for now (see the PR body) — their room is whatever the
1984
+ // live session itself has running (or not), never booted here.
1985
+ let servers: ResolvedServers | undefined;
1986
+ if (args.inEditor) {
1987
+ if (args.sessionPort === undefined) {
1988
+ console.error(
1989
+ 'vgai probe --in-editor: no --session-port was supplied — this is CLI-internal ' +
1990
+ 'plumbing (packages/vgai-cli), not something to pass by hand.',
1991
+ );
1992
+ setExit(PROBE_EXIT_CODES.USAGE);
1993
+ return;
1994
+ }
1995
+ log(`vgai probe --in-editor: driving the live editor session on :${args.sessionPort}...`);
1996
+ } else {
1997
+ log('vgai probe: resolving the standalone game server...');
1998
+ try {
1999
+ servers = await resolveServers(projectDir, needsColyseus, args.fresh);
2000
+ } catch (err) {
2001
+ console.error(
2002
+ `vgai probe: failed to boot the standalone game server — ${err instanceof Error ? err.message : err}`,
2003
+ );
2004
+ setExit(PROBE_EXIT_CODES.SPEC_FAILURE);
2005
+ return;
2006
+ }
2007
+ log(
2008
+ servers.reused
2009
+ ? `vgai probe: reusing the warm session at ${servers.gameServer.origin}`
2010
+ : `vgai probe: game server ready at ${servers.gameServer.origin}`,
2011
+ );
2012
+ }
2013
+
2014
+ // Step 5
2015
+ const probeDir = join(projectDir, '.vgai', 'probe');
2016
+ // Run-2 frictions fix #4: archive the PREVIOUS run's screenshots/videos to
2017
+ // prev-run/ before this run's own outputDir gets (re)created — must run
2018
+ // before Playwright's own outputDir-clearing default gets a chance to wipe
2019
+ // last-run/ out from under the prior run's evidence.
2020
+ archiveLastRunArtifacts(probeDir);
2021
+ const artifactsDir = join(probeDir, 'last-run');
2022
+ mkdirSync(artifactsDir, { recursive: true });
2023
+ const reporterModulePath = stageProbeReporter(
2024
+ projectDir,
2025
+ fileURLToPath(new URL('./probe-reporter.ts', import.meta.url)),
2026
+ );
2027
+ const junitOutputPath = args.junit ? join(projectDir, '.vgai', 'probe', 'junit.xml') : undefined;
2028
+ // #125: `--headless` is defensive belt-and-suspenders — it forces headless
2029
+ // even if `--headed`/`--headed-auto` were (mistakenly) also passed down by
2030
+ // the CLI wrapper. `attemptHeaded` is what actually drives this run.
2031
+ // #140: `--in-editor` never launches a browser of its own at all — headed/
2032
+ // headless are meaningless there, forced off regardless of what was passed.
2033
+ const attemptHeaded = !args.inEditor && args.headed && !args.headless;
2034
+ // --headed needs a full chromium (not the headless-shell build) — skip the
2035
+ // executablePath resolution entirely and let Playwright resolve its own
2036
+ // default. The SwiftShader launch args stay on regardless (harmless headed).
2037
+ const chromiumExecutablePath = args.inEditor
2038
+ ? undefined
2039
+ : attemptHeaded
2040
+ ? undefined
2041
+ : resolveHeadlessShell();
2042
+ const configOpts: ProbeConfigOptions = {
2043
+ projectDir,
2044
+ testDir: join(projectDir, 'tests', 'acceptance'),
2045
+ reporterModulePath,
2046
+ lastRunJsonPath: lastRunPath,
2047
+ artifactsDir,
2048
+ ...(junitOutputPath !== undefined ? { junitOutputPath } : {}),
2049
+ ...(chromiumExecutablePath !== undefined ? { chromiumExecutablePath } : {}),
2050
+ ...(attemptHeaded ? { headed: true } : {}),
2051
+ };
2052
+ const configPath = writeProbeConfig(configOpts);
2053
+
2054
+ // D4(a): drop any last-run.json a PREVIOUS invocation left behind before
2055
+ // this run's suite spawns — otherwise a run whose child never reaches the
2056
+ // reporter's onEnd (crashed config, "No tests found", ...) would silently
2057
+ // fall through to step 7 reading the prior run's envelope as this run's
2058
+ // result.
2059
+ clearStaleLastRun(lastRunPath);
2060
+
2061
+ // Step 6
2062
+ const suiteStartedAt = new Date().toISOString();
2063
+ log('vgai probe: running the acceptance suite...');
2064
+ if (attemptHeaded) {
2065
+ log('headed mode: watch the run; timings will differ from headless CI');
2066
+ }
2067
+ const env: Record<string, string> = { ...(process.env as Record<string, string>) };
2068
+ if (args.inEditor) {
2069
+ // #140: selects `@vgai/probe`'s relay-backed fixture set
2070
+ // (`relay-fixture.ts`) over the page-backed one — see `index.ts`'s
2071
+ // module doc for the selection mechanics. `VGAI_PROBE_URL` is
2072
+ // deliberately left UNSET here (no standalone page ever gets navigated).
2073
+ env['VGAI_PROBE_RELAY_PORT'] = String(args.sessionPort);
2074
+ } else {
2075
+ env['VGAI_PROBE_URL'] = servers!.gameServer.origin;
2076
+ }
2077
+ env['VGAI_PROBE_ARTIFACTS'] = artifactsDir;
2078
+ // Blind session B's finding: a DEBUG_COMMAND_NOT_REGISTERED failure against
2079
+ // a REUSED warm session is ambiguous from inside the spec alone — the
2080
+ // command may never have existed, OR it may have been added to the game
2081
+ // since the warm server booted. This runner already knows which case it
2082
+ // is (`servers.reused`, above); threading it through as an env var lets
2083
+ // `@vgai/probe`'s fixture/client append an honest hint ONLY when it's true.
2084
+ if (servers?.reused) env['VGAI_PROBE_WARM'] = '1';
2085
+ // D15/T-D15.6 (objection-4 fix) — `--seed <n>` reaches `@vgai/probe`'s
2086
+ // fixture the same env-var way `VGAI_PROBE_URL`/`VGAI_PROBE_WARM` do.
2087
+ // (In `--in-editor` mode this env var is set but unread — the relay
2088
+ // fixture navigates no URL; the CLI wrapper threads --seed into its own
2089
+ // `play` relay command instead. See `runProbe` in packages/vgai-cli.)
2090
+ if (args.seed !== undefined) env['VGAI_PROBE_SEED'] = String(args.seed);
2091
+ let {
2092
+ exitCode: playwrightExit,
2093
+ wedged,
2094
+ wedgeSilenceMs,
2095
+ headedNoDisplayHint,
2096
+ } = await runPlaywrightSuite({
2097
+ cwd: projectDir,
2098
+ configPath,
2099
+ env,
2100
+ passthroughArgs: args.playwrightArgs,
2101
+ cliPath: playwrightCliPath,
2102
+ headed: attemptHeaded,
2103
+ });
2104
+
2105
+ if (wedged) {
2106
+ const exitCode = handleWedgeOutcome({
2107
+ wedgeSilenceMs,
2108
+ lastRunPath,
2109
+ projectDir,
2110
+ artifactsDir,
2111
+ suiteStartedAt,
2112
+ junitRequested: args.junit,
2113
+ junitOutputPath,
2114
+ });
2115
+ // `runs/` archive: `handleWedgeOutcome` above always leaves SOME
2116
+ // envelope at `lastRunPath` (synthetic wedge, or the real one annotated
2117
+ // in place) — archive it the same as an ordinary finish, below.
2118
+ archiveRun({ lastRunPath, runsDir });
2119
+ setExit(exitCode);
2120
+ return;
2121
+ }
2122
+
2123
+ // Ended up headed for real (no fallback needed) unless the block below flips it.
2124
+ let endedHeaded = attemptHeaded;
2125
+
2126
+ if (headedNoDisplayHint) {
2127
+ if (!args.headedAuto) {
2128
+ // Explicit `--headed` on a display-less host: unchanged pre-#125
2129
+ // behavior — a person asked for headed specifically, so a launch
2130
+ // failure is reported and the run fails (exit 2).
2131
+ console.error(`vgai probe: ${headedNoDisplayHint}`);
2132
+ setExit(PROBE_EXIT_CODES.USAGE);
2133
+ return;
2134
+ }
2135
+ // #125: `headed` here came from the CLI's DEFAULT gate — nobody typed
2136
+ // `--headed` — so a display-less host must never fail the run. Fall back
2137
+ // to a fresh headless re-run instead, transparently, with no change to
2138
+ // the eventual exit code for the same spec results.
2139
+ console.error(
2140
+ 'vgai probe: no display available — running headless ' +
2141
+ '(re-run with --headed after setting up a display to watch)',
2142
+ );
2143
+ endedHeaded = false;
2144
+ const fallbackChromiumPath = resolveHeadlessShell();
2145
+ const fallbackConfigOpts: ProbeConfigOptions = {
2146
+ projectDir,
2147
+ testDir: join(projectDir, 'tests', 'acceptance'),
2148
+ reporterModulePath,
2149
+ lastRunJsonPath: lastRunPath,
2150
+ artifactsDir,
2151
+ ...(junitOutputPath !== undefined ? { junitOutputPath } : {}),
2152
+ ...(fallbackChromiumPath !== undefined
2153
+ ? { chromiumExecutablePath: fallbackChromiumPath }
2154
+ : {}),
2155
+ };
2156
+ const fallbackConfigPath = writeProbeConfig(fallbackConfigOpts);
2157
+ // Same reasoning as the D4(a) clear above: the failed headed attempt
2158
+ // never reached a real spec (Playwright's own browser launch failed
2159
+ // before any test ran), so there is nothing worth preserving from it.
2160
+ clearStaleLastRun(lastRunPath);
2161
+ const retry = await runPlaywrightSuite({
2162
+ cwd: projectDir,
2163
+ configPath: fallbackConfigPath,
2164
+ env,
2165
+ passthroughArgs: args.playwrightArgs,
2166
+ cliPath: playwrightCliPath,
2167
+ headed: false,
2168
+ });
2169
+ if (retry.wedged) {
2170
+ const exitCode = handleWedgeOutcome({
2171
+ wedgeSilenceMs: retry.wedgeSilenceMs,
2172
+ lastRunPath,
2173
+ projectDir,
2174
+ artifactsDir,
2175
+ suiteStartedAt,
2176
+ junitRequested: args.junit,
2177
+ junitOutputPath,
2178
+ });
2179
+ archiveRun({ lastRunPath, runsDir });
2180
+ setExit(exitCode);
2181
+ return;
2182
+ }
2183
+ playwrightExit = retry.exitCode;
2184
+ }
2185
+
2186
+ // #125: someone might have wanted to watch this — nudge them toward
2187
+ // `--headed` next time. Skipped in --json mode (keep stdout parseable) and
2188
+ // whenever `--headless` was explicit (they already told us not to watch).
2189
+ if (!endedHeaded && args.liveSessionHint && !args.headless && !jsonMode) {
2190
+ console.error(
2191
+ 'tip: this project has a live editor session — the synthetic player ran headless; ' +
2192
+ 're-run with --headed to watch it play',
2193
+ );
2194
+ }
2195
+
2196
+ // Step 7
2197
+ let envelope: LastRunEnvelope | undefined;
2198
+ try {
2199
+ envelope = JSON.parse(readFileSync(lastRunPath, 'utf-8')) as LastRunEnvelope;
2200
+ } catch {
2201
+ // The reporter should always have written this — a missing file means
2202
+ // Playwright itself never got far enough to run onEnd (its OWN config
2203
+ // failed to load, say). Fall back to the raw child exit code below.
2204
+ }
2205
+
2206
+ if (envelope) {
2207
+ for (const s of envelope.perf.samples) {
2208
+ log(` ${s.title}: ${s.wallMs}ms wall`);
2209
+ }
2210
+ }
2211
+ // `runs/` archive: best-effort, silent — see `archiveRun`'s own doc
2212
+ // comment for why an archive-write problem never touches the exit code.
2213
+ // No-ops on its own when `lastRunPath` doesn't exist (the `catch` inside
2214
+ // `envelope` above already covers that case; `archiveRun` re-checks).
2215
+ archiveRun({ lastRunPath, runsDir });
2216
+ console.log(lastRunPath);
2217
+
2218
+ // D4(b): folds in the child's own exit code and the envelope's own status
2219
+ // (not just failures.length), AND the zero-verdicts-despite-specs-on-disk
2220
+ // mismatch (D4-follow-up) — see computeSuiteExitCode's doc comment.
2221
+ const exitCode = computeSuiteExitCode({
2222
+ envelope,
2223
+ playwrightExit,
2224
+ specFilesFound: specFiles.length,
2225
+ });
2226
+ const zeroVerdictsMessage = describeZeroVerdictsMismatch({
2227
+ envelope,
2228
+ specFilesFound: specFiles.length,
2229
+ });
2230
+ if (zeroVerdictsMessage) console.error(zeroVerdictsMessage);
2231
+
2232
+ if (jsonMode) {
2233
+ console.log(JSON.stringify({ lastRunPath, exitCode, envelope }, null, 2));
2234
+ }
2235
+
2236
+ setExit(exitCode);
2237
+ }
2238
+
2239
+ // Only run when this file is the actual entry point (`npx tsx run-probe.ts
2240
+ // ...`) — importing it (as `probe-runner.test.ts` does, for the pure
2241
+ // functions above) must never trigger a real run.
2242
+ function isEntryPoint(): boolean {
2243
+ try {
2244
+ const thisFile = fileURLToPath(import.meta.url);
2245
+ const argvPath = process.argv[1];
2246
+ if (!argvPath) return false;
2247
+ return realpathSync(thisFile) === realpathSync(resolve(argvPath));
2248
+ } catch {
2249
+ return false;
2250
+ }
2251
+ }
2252
+
2253
+ if (isEntryPoint()) {
2254
+ main().catch((err: unknown) => {
2255
+ console.error(
2256
+ `vgai probe: unexpected error — ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`,
2257
+ );
2258
+ setExit(PROBE_EXIT_CODES.SPEC_FAILURE);
2259
+ });
2260
+ }