@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.
- package/README.md +24 -0
- package/dist/assets/axe-C7YBtOuM.js +30 -0
- package/dist/assets/browserAll-DDtGGE48.js +1 -0
- package/dist/assets/create-runtime-DbTlBlKG.js +1 -0
- package/dist/assets/{game-BI111o5f.js → game-BE0r7DMI.js} +3 -3
- package/dist/assets/game-BdTHR2_X.js +1 -0
- package/dist/assets/{game-pt_1lQLv.js → game-C3iQGSqo.js} +1 -1
- package/dist/assets/game-CcHfYxjP.js +1 -0
- package/dist/assets/game-Cqzj_lpD.js +1 -0
- package/dist/assets/game-DSZdeCHu.js +1 -0
- package/dist/assets/game-Dch9sAPc.js +1 -0
- package/dist/assets/{game-xsOho_wg.js → game-SAFn90pA.js} +3 -3
- package/dist/assets/game-loop-DMIgSMV-.js +1 -0
- package/dist/assets/{game-source-CvidN7AB.js → game-source-Dk83w26t.js} +1 -1
- package/dist/assets/{index-Bp5cecD6.js → index-4idIsHHJ.js} +2 -2
- package/dist/assets/{index-DL7RcQ0n.js → index-C-LhOTBN.js} +1 -1
- package/dist/assets/{index-Cs8xwv3e.js → index-CDkxceqE.js} +1 -1
- package/dist/assets/index-CQEfi217.js +15412 -0
- package/dist/assets/index-Dvi-V0NE.css +1 -0
- package/dist/assets/ingest-siblings-s0P5I1Ub.js +1 -0
- package/dist/assets/mount-manifest-DXMOlF79.js +1 -0
- package/dist/assets/react-18-C9eHn8x1.js +1 -0
- package/dist/assets/webworkerAll-Fn0e-ouF.js +1 -0
- package/dist/assets/world-documents-GM0a9Kv7.js +1 -0
- package/dist/index.html +15 -13
- package/dist/ingest/racing-game/vgai.game.json +5 -4
- package/dist/ingest/simcity/vgai.game.json +5 -4
- package/dist/ingest/tanks/vgai.game.json +5 -4
- package/dist/project-scripts.bundle.js +3917 -0
- package/dist/scenes/untitled.vscn.json +2 -0
- package/dist-server/packaged.mjs +7697 -3435
- package/package.json +31 -4
- package/scripts/probe-reporter.ts +349 -0
- package/scripts/run-probe.ts +2260 -0
- package/server/asset-catalog-audit.ts +268 -0
- package/server/asset-catalog-v2.ts +146 -0
- package/server/asset-library-routes.ts +276 -58
- package/server/canonical-path.ts +1 -1
- package/server/dev.ts +148 -44
- package/server/editor-server.ts +888 -61
- package/server/editor-sse.ts +115 -4
- package/server/harness-chat-service.ts +796 -0
- package/server/local-asset-catalog.ts +157 -22
- package/server/packaged.ts +51 -21
- package/server/process-shutdown.ts +136 -0
- package/server/prod.ts +9 -6
- package/server/project-hmr-files.ts +41 -0
- package/server/project-operations.ts +343 -0
- package/server/project-optimize-deps-entries.ts +4 -4
- package/server/project-output-writer.ts +156 -0
- package/server/project-validation.ts +140 -0
- package/server/project-view.ts +8 -50
- package/server/server-utils.ts +75 -10
- package/server/session-registry.ts +12 -0
- package/src/EditorContext.tsx +107 -3
- package/src/action-registry.ts +115 -17
- package/src/adapter-resolver.ts +213 -478
- package/src/asset-events.ts +64 -1
- package/src/asset-preview.ts +906 -0
- package/src/asset-selection.ts +121 -0
- package/src/asset-workflow/asset-capabilities.ts +259 -0
- package/src/asset-workflow/asset-editor-errors.ts +46 -0
- package/src/asset-workflow/asset-import-pipeline.ts +176 -0
- package/src/asset-workflow/asset-library-query.ts +107 -0
- package/src/asset-workflow/asset-types.ts +192 -0
- package/src/asset-workflow/asset-workflow-quality.ts +65 -0
- package/src/asset-workflow/import-contract.ts +113 -0
- package/src/asset-workflow/model-conversion.ts +126 -0
- package/src/asset-workflow/model-inspection.ts +185 -0
- package/src/asset-workflow/project-asset-commands.ts +23 -0
- package/src/asset-workflow/project-asset-health.ts +153 -0
- package/src/asset-workflow/project-asset-operations.ts +119 -0
- package/src/asset-workflow/thumbnail-system.ts +172 -0
- package/src/authoring/active-systems.ts +15 -0
- package/src/authoring/boundary-authoring-adapter.ts +16 -5
- package/src/authoring/composite-authoring-adapter.ts +305 -124
- package/src/authoring/design-time-layers.ts +300 -117
- package/src/authoring/edit-mode-authoring.ts +278 -293
- package/src/authoring/embed-only-adapter.ts +4 -2
- package/src/authoring/first-party-hierarchy-menu.ts +7 -2
- package/src/authoring/first-party-inspector/AddSectionMenu.tsx +5 -12
- package/src/authoring/first-party-inspector/EnvironmentSection.tsx +53 -126
- package/src/authoring/first-party-inspector/HeaderSection.tsx +54 -65
- package/src/authoring/first-party-inspector/is-first-party-selection.ts +30 -11
- package/src/authoring/first-party-inspector/register.tsx +13 -2
- package/src/authoring/ingest-authoring-adapter.ts +142 -45
- package/src/authoring/mount-failure-report.ts +5 -5
- package/src/authoring/provenance.ts +41 -0
- package/src/authoring/react-dom-authoring-adapter.ts +92 -92
- package/src/authoring/react-inspector-section.tsx +489 -51
- package/src/authoring/react-world-authoring-adapter.ts +541 -306
- package/src/authoring/story-overlay.ts +1 -1
- package/src/authoring/story-types.ts +11 -0
- package/src/authoring/vgai-scene-authoring-adapter.ts +54 -25
- package/src/authoring/viewport-tool-context.ts +78 -0
- package/src/authoring/world-canvas-viewport-state.ts +35 -0
- package/src/authoring/world-hidden-viewport.ts +186 -0
- package/src/authoring/world-pan-state.ts +33 -17
- package/src/authoring/world-session-state.ts +1 -1
- package/src/authoring/world2d-authoring-adapter.ts +109 -52
- package/src/availability-tick.ts +56 -0
- package/src/browser-play-entry.ts +5 -5
- package/src/browser-project-scripts.ts +1 -0
- package/src/build-identity.ts +15 -0
- package/src/command-listener.ts +893 -19
- package/src/components/AlignToolbar.tsx +74 -63
- package/src/components/AppRoot.tsx +193 -26
- package/src/components/ApplicationMenus.tsx +272 -0
- package/src/components/AssetBrowser.tsx +1450 -293
- package/src/components/AssetEditorShell.tsx +141 -0
- package/src/components/AssetImportDetails.tsx +218 -0
- package/src/components/AssetLibraryPanel.tsx +14 -0
- package/src/components/CameraInfo.tsx +11 -20
- package/src/components/CenterDocuments.tsx +585 -0
- package/src/components/CommandPalette.tsx +75 -30
- package/src/components/ConsolePanel.css +55 -0
- package/src/components/ConsolePanel.tsx +57 -112
- package/src/components/DebugConsolePanel.tsx +461 -0
- package/src/components/DebuggerPanel.tsx +165 -0
- package/src/components/DefaultEditorLayout.tsx +167 -231
- package/src/components/GameHierarchy.tsx +873 -290
- package/src/components/GamePerformanceOverlay.tsx +46 -0
- package/src/components/HarnessChatPanel.tsx +2352 -0
- package/src/components/HistoryPanel.tsx +226 -0
- package/src/components/Inspector.tsx +153 -99
- package/src/components/InspectorToolSection.tsx +6 -4
- package/src/components/MountFailureBanner.tsx +31 -48
- package/src/components/NetworkSection.tsx +25 -14
- package/src/components/OnlineAssetBrowser.tsx +794 -343
- package/src/components/OverlayReportBanner.tsx +38 -64
- package/src/components/PerformancePanel.tsx +206 -0
- package/src/components/PlayBar.tsx +93 -263
- package/src/components/PrefabSaveDialog.tsx +55 -127
- package/src/components/ProjectHeader.css +60 -0
- package/src/components/ProjectHeader.tsx +170 -105
- package/src/components/ProjectScreen.tsx +246 -184
- package/src/components/ReactCanvasControls.tsx +185 -0
- package/src/components/ResolutionPicker.tsx +85 -0
- package/src/components/RootActivateDialog.tsx +39 -68
- package/src/components/SaveDialog.tsx +55 -127
- package/src/components/SaveStatus.tsx +11 -29
- package/src/components/StartupLoadingScreen.tsx +96 -0
- package/src/components/StateWatchPanel.tsx +302 -0
- package/src/components/StatsOverlay.tsx +12 -5
- package/src/components/StatusBar.tsx +64 -0
- package/src/components/ThemeManagerDialog.css +89 -0
- package/src/components/ThemeManagerDialog.tsx +371 -0
- package/src/components/ToolHost.tsx +26 -15
- package/src/components/Toolbar.tsx +117 -214
- package/src/components/VgaiVoxelLogo.tsx +89 -0
- package/src/components/ViewportOverlay.tsx +97 -172
- package/src/components/ViewportPanel.tsx +130 -31
- package/src/components/WorkspaceDock.tsx +891 -0
- package/src/components/WorldSelectionOverlay.tsx +51 -63
- package/src/components/WorldTextEditor.tsx +9 -5
- package/src/components/asset-documents.tsx +390 -0
- package/src/components/asset-editor-persistence.ts +44 -0
- package/src/components/asset-selection-section.tsx +338 -0
- package/src/components/asset-viewers/AudioViewer.tsx +73 -21
- package/src/components/asset-viewers/GenericJsonViewer.tsx +26 -11
- package/src/components/asset-viewers/ImageViewer.tsx +43 -11
- package/src/components/asset-viewers/InputMapViewer.tsx +512 -134
- package/src/components/asset-viewers/MaterialAssetEditor.tsx +540 -0
- package/src/components/asset-viewers/ModelViewer.tsx +805 -121
- package/src/components/asset-viewers/OnlineAssetDetail.tsx +177 -60
- package/src/components/asset-viewers/SourceAssetViewer.tsx +121 -0
- package/src/components/asset-workflow.css +506 -0
- package/src/components/build-documents.tsx +696 -0
- package/src/components/core-utilities.tsx +75 -0
- package/src/components/data-documents.tsx +364 -0
- package/src/components/{DataPanel.tsx → data-editor.tsx} +138 -288
- package/src/components/debugger-panel-state.ts +57 -0
- package/src/components/inspector-widgets/AlignmentGrid.tsx +8 -14
- package/src/components/inspector-widgets/BorderEditor.tsx +41 -33
- package/src/components/inspector-widgets/ColorPicker.tsx +39 -39
- package/src/components/inspector-widgets/FontPicker.tsx +13 -21
- package/src/components/inspector-widgets/GradientEditor.tsx +11 -19
- package/src/components/inspector-widgets/ScrubbableInput.tsx +6 -3
- package/src/components/inspector-widgets/ShadowEditor.tsx +20 -33
- package/src/components/inspector-widgets/shared.tsx +90 -49
- package/src/components/inspectors/AnimationSection.tsx +39 -40
- package/src/components/inspectors/AudioSection.tsx +28 -39
- package/src/components/inspectors/BoneAttachmentSection.tsx +19 -24
- package/src/components/inspectors/CameraSection.tsx +14 -23
- package/src/components/inspectors/ComponentsSection.tsx +39 -43
- package/src/components/inspectors/JointsSection.tsx +34 -43
- package/src/components/inspectors/LightSection.tsx +90 -35
- package/src/components/inspectors/MaterialSection.tsx +137 -174
- package/src/components/inspectors/MeshSection.tsx +9 -17
- package/src/components/inspectors/NavigationSection.tsx +8 -15
- package/src/components/inspectors/ParticleSection.tsx +91 -76
- package/src/components/inspectors/PhysicsSection.tsx +42 -46
- package/src/components/inspectors/PostProcessingSection.tsx +28 -36
- package/src/components/inspectors/RenderingSection.tsx +12 -20
- package/src/components/inspectors/ShadowSection.tsx +7 -8
- package/src/components/inspectors/SplineSection.tsx +31 -42
- package/src/components/inspectors/TransformSection.tsx +14 -22
- package/src/components/patterns/Dialog.tsx +95 -0
- package/src/components/patterns/Fields.tsx +44 -0
- package/src/components/patterns/List.tsx +25 -0
- package/src/components/patterns/StateSurface.tsx +40 -0
- package/src/components/patterns/Surfaces.tsx +115 -0
- package/src/components/patterns/Tabs.tsx +80 -0
- package/src/components/patterns/Toolbar.tsx +51 -0
- package/src/components/patterns/Tree.tsx +72 -0
- package/src/components/primitives/Button.tsx +46 -0
- package/src/components/primitives/ColorInput.tsx +11 -47
- package/src/components/primitives/EditorIcon.tsx +99 -0
- package/src/components/primitives/FormControls.tsx +71 -0
- package/src/components/primitives/JsonInput.tsx +15 -2
- package/src/components/primitives/Layout.tsx +100 -0
- package/src/components/primitives/Menu.tsx +105 -0
- package/src/components/primitives/NumberInput.tsx +8 -35
- package/src/components/primitives/Panel.tsx +49 -44
- package/src/components/primitives/SectionHeader.tsx +10 -28
- package/src/components/primitives/Text.tsx +46 -0
- package/src/components/primitives/Tooltip.tsx +20 -12
- package/src/components/primitives/Vec3Input.tsx +3 -0
- package/src/components/primitives/banner-tones.ts +33 -0
- package/src/components/primitives/editor-icons.ts +102 -0
- package/src/components/primitives/panel-header-styles.ts +30 -0
- package/src/components/project-operation-documents.tsx +226 -0
- package/src/components/status-contributions.tsx +175 -0
- package/src/components/story-documents-register.ts +15 -0
- package/src/components/story-documents.tsx +1185 -0
- package/src/components/tool-documents.tsx +110 -0
- package/src/components/workspace-dock.css +293 -0
- package/src/components/workspace-history.ts +33 -0
- package/src/components/world-documents.tsx +145 -0
- package/src/components/xstate-documents.tsx +209 -0
- package/src/composite-screenshot.ts +248 -0
- package/src/design-system/index.ts +70 -0
- package/src/design-system-stories/Foundation.stories.tsx +183 -0
- package/src/design-system-stories/Patterns.stories.tsx +251 -0
- package/src/design-system-stories/Workspace.stories.tsx +538 -0
- package/src/doctor/report.ts +57 -13
- package/src/editor-api.ts +295 -31
- package/src/editor-compatibility.ts +200 -0
- package/src/editor-console.ts +35 -17
- package/src/editor-hotkeys.ts +85 -33
- package/src/editor-store.ts +538 -233
- package/src/editor-viewport.ts +160 -34
- package/src/entity-factory.ts +14 -3
- package/src/gizmo-registry.ts +21 -5
- package/src/harness-chat-editor-context.ts +37 -0
- package/src/harness-chat-indicator.ts +68 -0
- package/src/harness-chat-layout.ts +53 -0
- package/src/harness-chat-lifecycle.ts +170 -0
- package/src/harness-chat-reconnect.ts +85 -0
- package/src/harness-chat-types.ts +409 -0
- package/src/hierarchy-drop.ts +50 -0
- package/src/hierarchy-expansion-state.ts +100 -0
- package/src/hierarchy-menu-registry.ts +1 -1
- package/src/hierarchy-projection.ts +76 -0
- package/src/history/editor-session.ts +21 -0
- package/src/history/history-commands.ts +147 -0
- package/src/history/history-service.ts +904 -0
- package/src/history/json-history-resource.ts +165 -0
- package/src/history/persistence-coordinator.ts +35 -0
- package/src/history/project-file-history.ts +266 -0
- package/src/history/project-root-history-backends.ts +53 -0
- package/src/history/resource-registry.ts +209 -0
- package/src/history/snapshot-store.ts +108 -0
- package/src/history/source-history-backend.ts +374 -0
- package/src/history/types.ts +100 -0
- package/src/hmr-registration-group.ts +67 -0
- package/src/hmr-stable-react-context.ts +23 -0
- package/src/hosted-example.ts +12 -2
- package/src/hotkeys.ts +89 -18
- package/src/ingest/discovery-react.ts +9 -10
- package/src/ingest/discovery.ts +4 -4
- package/src/ingest/discovery2d.ts +5 -5
- package/src/ingest/games/bloom-composer/vgai.game.json +3 -3
- package/src/ingest/games/embed-bundle/vgai.game.json +3 -3
- package/src/ingest/games/games-fps/vgai.game.json +3 -3
- package/src/ingest/games/iframe-reachable/vgai.game.json +3 -3
- package/src/ingest/games/interactive-cubes/vgai.game.json +3 -3
- package/src/ingest/games/mismatched-three/vgai.game.json +3 -3
- package/src/ingest/games/own-three-bundled/vgai.game.json +3 -3
- package/src/ingest/games/pointerlock-maze/vgai.game.json +7 -4
- package/src/ingest/games-2d/bubbo-bubbo/vgai.game.json +3 -3
- package/src/ingest/games-2d/bunnymark/vgai.game.json +3 -3
- package/src/ingest/games-2d/flappy/vgai.game.json +3 -3
- package/src/ingest/games-2d/flappy-pixi/vgai.game.json +3 -3
- package/src/ingest/games-2d/puzzling-potions/vgai.game.json +3 -3
- package/src/ingest/games-2d/tilemap-camera/vgai.game.json +3 -3
- package/src/ingest/games-react/react-rpg/vgai.game.json +3 -3
- package/src/ingest/types.ts +1 -1
- package/src/ingest-mode.ts +47 -52
- package/src/ingest-siblings.ts +23 -33
- package/src/initial-scene.ts +1 -2
- package/src/main.tsx +16 -8
- package/src/manifest-project.ts +10 -12
- package/src/model-thumbnail.ts +261 -26
- package/src/module-mode.ts +44 -50
- package/src/play-log-events.ts +26 -0
- package/src/play-mode.ts +495 -401
- package/src/play-scene-adoption.ts +23 -0
- package/src/playground/main.ts +68 -24
- package/src/playwright-shim.ts +473 -0
- package/src/project-manager.ts +90 -10
- package/src/project-operations.ts +112 -0
- package/src/project-story-discovery.ts +25 -0
- package/src/project-tool-discovery.ts +43 -0
- package/src/scene-serializer.ts +0 -6
- package/src/scene-sync.ts +7 -2
- package/src/script-loader.ts +3 -3
- package/src/skeleton-helper.ts +57 -0
- package/src/storage/seed.ts +68 -8
- package/src/stories/StoryPreviewMount.tsx +265 -0
- package/src/stories/action-stories.fixture.tsx +44 -0
- package/src/stories/button-stories.fixture.tsx +50 -0
- package/src/stories/compose-project-stories.ts +182 -0
- package/src/stories/loader-stories.fixture.tsx +53 -0
- package/src/stories/story-discovery.ts +130 -0
- package/src/stories/story-registry.ts +146 -0
- package/src/theme-css.ts +1049 -0
- package/src/theme-library.ts +238 -0
- package/src/theme-preference.ts +130 -0
- package/src/theme.ts +527 -0
- package/src/tool-loader.ts +80 -24
- package/src/ui-source/inspect.ts +27 -4
- package/src/ui-source/source-write-backend.ts +83 -22
- package/src/widgets/index.ts +9 -18
- package/src/workspace-aux-commands.ts +11 -0
- package/src/workspace-dock-controller.ts +64 -0
- package/src/workspace-document-ids.ts +5 -0
- package/src/workspace-document-registry.ts +367 -0
- package/src/workspace-state-persistence.ts +455 -0
- package/src/workspace-status-registry.ts +110 -0
- package/src/workspace-utility-commands.ts +49 -0
- package/src/workspace-utility-registry.ts +161 -0
- package/src/world-document-routing.ts +16 -0
- package/src/xstate-inspect/XStateMachineInspector.tsx +544 -0
- package/src/xstate-inspect/character-animation-machine.fixture.ts +74 -0
- package/src/xstate-inspect/use-live-actor-state.ts +39 -0
- package/src/xstate-inspect/xstate-graph.ts +265 -0
- package/src/xstate-inspect/xstate-layout.ts +76 -0
- package/src/z-index.ts +26 -0
- package/template/.claude/skills/editor/SKILL.md +129 -90
- package/template/AGENTS.md +1110 -0
- package/template/CLAUDE.md +1 -512
- package/template/IDIOMS.md +50 -0
- package/template/check-idioms.ts +706 -0
- package/template/manifest-entry-modules-plugin.ts +53 -0
- package/template/package.json +13 -4
- package/template/public/scenes/default.scn2d.json +6 -0
- package/template/public/scenes/default.vscn.json +7 -1
- package/template/public/scenes/untitled.vscn.json +10 -0
- package/template/scripts/playtest.ts +40 -0
- package/template/server/loopback.ts +75 -0
- package/template/server/rooms/arena-room.ts +69 -1
- package/template/server/rooms/game-room.ts +68 -1
- package/template/src/data/README.md +9 -4
- package/template/src/data/data-ref.ts +35 -0
- package/template/src/data/tuning.schema.ts +1 -1
- package/template/src/main.ts +41 -62
- package/template/src/manifest-entry-modules.d.ts +3 -0
- package/template/src/operations/README.md +88 -0
- package/template/src/runtime/pixi-adapter.ts +24 -0
- package/template/src/scripts/components/data-spinner.ts +43 -2
- package/template/src/scripts/components/spin-lap-math.ts +27 -0
- package/template/src/tools/README.md +5 -3
- package/template/src/tools/example.tool.tsx +13 -7
- package/template/src/ui/Button.stories.tsx +66 -0
- package/template/src/ui/RebindPanel.tsx +2 -6
- package/template/src/ui/game.tsx +17 -0
- package/template/tests/acceptance/example.spec.ts +64 -0
- package/template/tests/logic/example.test.ts +31 -0
- package/template/tests/logic/room.test.ts +100 -0
- package/template/tests/tsconfig.json +30 -0
- package/template/validate-manifest.ts +5 -5
- package/template/validate-scenes.ts +50 -18
- package/template/vgai.game.json +20 -13
- package/template/vite.config.ts +77 -16
- package/vite-plugin-ui-oid.ts +261 -17
- package/dist/assets/CCDIKSolver-Dz-ysxZF.js +0 -1
- package/dist/assets/SelectionBox-BXsv_6aH.js +0 -1
- package/dist/assets/browser-project-scripts-DOru4L1x.js +0 -2
- package/dist/assets/browserAll-CEkWYlFk.js +0 -1
- package/dist/assets/create-runtime-DYIWr3va.js +0 -1
- package/dist/assets/data-asset-Bb9sIM7i.js +0 -5
- package/dist/assets/game-8SDZPPgH.js +0 -1
- package/dist/assets/game-CevorxyV.js +0 -1
- package/dist/assets/game-Di7XlywD.js +0 -1
- package/dist/assets/game-DjnCaw8R.js +0 -1
- package/dist/assets/game-Q9A0aXSV.js +0 -1
- package/dist/assets/index-CPVV6pjw.js +0 -1
- package/dist/assets/index-CpOnYyeD.js +0 -213
- package/dist/assets/index-Dr8H_6Rl.js +0 -1
- package/dist/assets/index-_bCaipUk.js +0 -13650
- package/dist/assets/ingest-siblings-Dz8eEqrV.js +0 -1
- package/dist/assets/mount-manifest-Bhk0ohsl.js +0 -1
- package/dist/assets/scene-query-BivAHhJu.js +0 -1
- package/dist/assets/webworkerAll-BLrH0F6F.js +0 -1
- package/dist/assets/xstate-animation-binding-1NGLmw6y.js +0 -1
- package/dist/assets/xstate.esm-BgUD33nl.js +0 -3
- package/src/authoring/scene-ui-layer.ts +0 -485
- package/src/authoring/ui-authoring-adapter.ts +0 -325
- package/src/authoring/ui-hierarchy-menu.ts +0 -40
- package/src/authoring/ui-inspector-sections.tsx +0 -156
- package/src/authoring/undo-timeline.ts +0 -145
- package/src/components/BottomPanel.tsx +0 -326
- package/src/components/BuildPanel.tsx +0 -455
- package/src/components/FolderTree.tsx +0 -179
- package/src/components/RightPanel.tsx +0 -166
- package/src/components/WorldContextMenu.tsx +0 -257
- package/src/components/asset-viewers/AssetViewer.tsx +0 -38
- package/src/ingest/games-react/react-rpg/src/ui/game-state.tsx +0 -129
- package/src/scene-ui-mode/active-scene-ui.ts +0 -58
- package/src/scene-ui-mode/scene-ui-palette-panel.tsx +0 -152
- package/src/scene-ui-mode/scene-ui-timeline-tab.tsx +0 -29
- package/src/scene-ui-mode/ui-prefab-dialog-host.tsx +0 -74
- package/src/scene-ui-mode/ui-prefab-hierarchy-menu.ts +0 -49
- package/src/scene-ui-mode/ui-prefab-io.ts +0 -76
- package/src/ui-editor/inspector.tsx +0 -563
- package/src/ui-editor/node-ops.ts +0 -266
- package/src/ui-editor/overlay.tsx +0 -380
- package/src/ui-editor/palette.tsx +0 -114
- package/src/ui-editor/react-store.ts +0 -78
- package/src/ui-editor/timeline-panel.tsx +0 -443
- package/src/ui-editor/ui-edit-store.ts +0 -552
- package/src/ui-editor/ui-prefab.ts +0 -95
- package/template/project.json +0 -9
- package/template/src/scripts/main.ts +0 -94
- package/template/src/ui/DefaultHud.tsx +0 -40
- package/template/src/ui/game-state.tsx +0 -37
- package/template/src/ui/mount-default-hud.tsx +0 -19
- package/template/src/ui/mount-ui.ts +0 -15
- 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
|
+
}
|