@thinkwise/testwise 0.2.67 → 0.2.103
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/Testwise.ts +35 -0
- package/artifact-builder/ArtifactManager.ts +25 -2
- package/artifact-builder/ComponentClassModelBuilder.ts +148 -0
- package/artifact-builder/InterfaceGenerator.ts +31 -33
- package/artifact-builder/ModelDataBuilder.ts +18 -20
- package/artifact-builder/ModelDataRefiner.ts +23 -3
- package/artifact-builder/SchemaGenerator.ts +45 -35
- package/artifact-builder/ScreenInterfaceRefiner.ts +25 -10
- package/artifact-builder/SelectorBuilder.ts +30 -24
- package/artifact-builder/SubjectClassModelBuilder.ts +209 -0
- package/artifact-builder/SubjectComponentGenerator.ts +67 -246
- package/artifact-builder/SubjectGenerator.ts +117 -230
- package/artifact-builder/SubjectRegistration.ts +41 -32
- package/artifact-builder/TsFileModel.ts +94 -0
- package/artifact-builder/TsFileSerializer.ts +247 -0
- package/artifact-builder/helpers/DataRetriever.ts +64 -27
- package/artifact-builder/helpers/NamingHandler.ts +23 -78
- package/artifact-builder/helpers/PipelinePaths.ts +123 -0
- package/artifact-builder/helpers/Stopwatch.ts +10 -0
- package/artifact-builder/helpers/index.ts +1 -0
- package/artifact-builder/index.ts +4 -0
- package/components/BaseComponentObjects.ts +17 -0
- package/components/action-bar/ActionBar.ts +35 -47
- package/components/action-bar/ActionBarObjects.ts +2 -0
- package/components/action-bar/CustomActionBar.ts +6 -0
- package/components/cardlist/Cardlist.ts +6 -0
- package/components/chart/Chart.ts +6 -0
- package/components/combined-filter/CombinedFilter.ts +9 -0
- package/components/cube-view-bar/CubeViewBar.ts +6 -0
- package/components/export/ExportComponent.ts +12 -0
- package/components/filter/ExcelStyleFilterPopup.ts +35 -2
- package/components/filter/FilterForm.ts +10 -0
- package/components/filter/FilterPopup.ts +30 -9
- package/components/filter/FilterPopupObjects.ts +10 -3
- package/components/filter/FindForm.ts +7 -0
- package/components/filter/ManagePrefilterPopup.ts +11 -0
- package/components/form/Form.ts +16 -0
- package/components/grid/Grid.ts +18 -1
- package/components/maps/Maps.ts +6 -0
- package/components/menu/ListBarMenuComponent.ts +10 -0
- package/components/pivot-grid/PivotGrid.ts +6 -0
- package/components/pivot-grid/PivotGridFieldList.ts +7 -0
- package/components/pop-up/AllRowsPopUp.ts +12 -0
- package/components/pop-up/PopUpComponent.ts +11 -0
- package/components/prefilter-list/PrefilterList.ts +9 -0
- package/components/preview/Preview.ts +6 -0
- package/components/report-bar/ReportBar.ts +6 -0
- package/components/report-tiles/ReportTiles.ts +6 -0
- package/components/scheduler/Scheduler.ts +6 -0
- package/components/snackbar/SnackBar.ts +12 -0
- package/components/splitter/Splitter.ts +6 -0
- package/components/tab/BaseTab.ts +5 -0
- package/components/tab/DetailTabPage.ts +11 -0
- package/components/tab/Tab.ts +10 -0
- package/components/task/TaskBar.ts +13 -3
- package/components/task/TaskTiles.ts +12 -3
- package/components/topbar/TopBar.ts +10 -0
- package/components/tree-view/TreeView.ts +9 -0
- package/controls/DateControl.ts +40 -1
- package/controls/FormComboBox.ts +30 -0
- package/controls/GridColumnControl.ts +34 -3
- package/controls/InputFieldControl.ts +49 -0
- package/controls/LookupDropdown.ts +29 -0
- package/dist/Testwise.js +35 -0
- package/dist/Testwise.js.map +1 -1
- package/dist/artifact-builder/ArtifactManager.d.ts +18 -1
- package/dist/artifact-builder/ArtifactManager.js +24 -2
- package/dist/artifact-builder/ArtifactManager.js.map +1 -1
- package/dist/artifact-builder/ComponentClassModelBuilder.d.ts +22 -0
- package/dist/artifact-builder/ComponentClassModelBuilder.js +115 -0
- package/dist/artifact-builder/ComponentClassModelBuilder.js.map +1 -0
- package/dist/artifact-builder/InterfaceGenerator.d.ts +15 -6
- package/dist/artifact-builder/InterfaceGenerator.js +27 -26
- package/dist/artifact-builder/InterfaceGenerator.js.map +1 -1
- package/dist/artifact-builder/ModelDataBuilder.d.ts +7 -0
- package/dist/artifact-builder/ModelDataBuilder.js +13 -5
- package/dist/artifact-builder/ModelDataBuilder.js.map +1 -1
- package/dist/artifact-builder/ModelDataRefiner.d.ts +18 -1
- package/dist/artifact-builder/ModelDataRefiner.js +18 -3
- package/dist/artifact-builder/ModelDataRefiner.js.map +1 -1
- package/dist/artifact-builder/SchemaGenerator.d.ts +21 -5
- package/dist/artifact-builder/SchemaGenerator.js +38 -26
- package/dist/artifact-builder/SchemaGenerator.js.map +1 -1
- package/dist/artifact-builder/ScreenInterfaceRefiner.d.ts +18 -1
- package/dist/artifact-builder/ScreenInterfaceRefiner.js +23 -9
- package/dist/artifact-builder/ScreenInterfaceRefiner.js.map +1 -1
- package/dist/artifact-builder/SelectorBuilder.d.ts +18 -5
- package/dist/artifact-builder/SelectorBuilder.js +29 -14
- package/dist/artifact-builder/SelectorBuilder.js.map +1 -1
- package/dist/artifact-builder/SubjectClassModelBuilder.d.ts +38 -0
- package/dist/artifact-builder/SubjectClassModelBuilder.js +158 -0
- package/dist/artifact-builder/SubjectClassModelBuilder.js.map +1 -0
- package/dist/artifact-builder/SubjectComponentGenerator.d.ts +25 -14
- package/dist/artifact-builder/SubjectComponentGenerator.js +65 -189
- package/dist/artifact-builder/SubjectComponentGenerator.js.map +1 -1
- package/dist/artifact-builder/SubjectGenerator.d.ts +32 -13
- package/dist/artifact-builder/SubjectGenerator.js +81 -165
- package/dist/artifact-builder/SubjectGenerator.js.map +1 -1
- package/dist/artifact-builder/SubjectRegistration.d.ts +22 -8
- package/dist/artifact-builder/SubjectRegistration.js +29 -14
- package/dist/artifact-builder/SubjectRegistration.js.map +1 -1
- package/dist/artifact-builder/TsFileModel.d.ts +86 -0
- package/dist/artifact-builder/TsFileModel.js +2 -0
- package/dist/artifact-builder/TsFileModel.js.map +1 -0
- package/dist/artifact-builder/TsFileSerializer.d.ts +19 -0
- package/dist/artifact-builder/TsFileSerializer.js +225 -0
- package/dist/artifact-builder/TsFileSerializer.js.map +1 -0
- package/dist/artifact-builder/helpers/DataRetriever.d.ts +22 -3
- package/dist/artifact-builder/helpers/DataRetriever.js +56 -26
- package/dist/artifact-builder/helpers/DataRetriever.js.map +1 -1
- package/dist/artifact-builder/helpers/NamingHandler.d.ts +18 -6
- package/dist/artifact-builder/helpers/NamingHandler.js +20 -61
- package/dist/artifact-builder/helpers/NamingHandler.js.map +1 -1
- package/dist/artifact-builder/helpers/PipelinePaths.d.ts +52 -0
- package/dist/artifact-builder/helpers/PipelinePaths.js +99 -0
- package/dist/artifact-builder/helpers/PipelinePaths.js.map +1 -0
- package/dist/artifact-builder/helpers/Stopwatch.d.ts +10 -0
- package/dist/artifact-builder/helpers/Stopwatch.js +10 -0
- package/dist/artifact-builder/helpers/Stopwatch.js.map +1 -1
- package/dist/artifact-builder/helpers/index.d.ts +1 -0
- package/dist/artifact-builder/helpers/index.js +1 -0
- package/dist/artifact-builder/helpers/index.js.map +1 -1
- package/dist/artifact-builder/index.d.ts +4 -0
- package/dist/artifact-builder/index.js +4 -0
- package/dist/artifact-builder/index.js.map +1 -1
- package/dist/components/BaseComponentObjects.d.ts +17 -0
- package/dist/components/BaseComponentObjects.js +17 -0
- package/dist/components/BaseComponentObjects.js.map +1 -1
- package/dist/components/action-bar/ActionBar.d.ts +21 -24
- package/dist/components/action-bar/ActionBar.js +31 -24
- package/dist/components/action-bar/ActionBar.js.map +1 -1
- package/dist/components/action-bar/ActionBarObjects.d.ts +1 -0
- package/dist/components/action-bar/ActionBarObjects.js +1 -0
- package/dist/components/action-bar/ActionBarObjects.js.map +1 -1
- package/dist/components/action-bar/CustomActionBar.d.ts +6 -0
- package/dist/components/action-bar/CustomActionBar.js +6 -0
- package/dist/components/action-bar/CustomActionBar.js.map +1 -1
- package/dist/components/cardlist/Cardlist.d.ts +6 -0
- package/dist/components/cardlist/Cardlist.js +6 -0
- package/dist/components/cardlist/Cardlist.js.map +1 -1
- package/dist/components/chart/Chart.d.ts +6 -0
- package/dist/components/chart/Chart.js +6 -0
- package/dist/components/chart/Chart.js.map +1 -1
- package/dist/components/combined-filter/CombinedFilter.d.ts +9 -0
- package/dist/components/combined-filter/CombinedFilter.js +9 -0
- package/dist/components/combined-filter/CombinedFilter.js.map +1 -1
- package/dist/components/cube-view-bar/CubeViewBar.d.ts +6 -0
- package/dist/components/cube-view-bar/CubeViewBar.js +6 -0
- package/dist/components/cube-view-bar/CubeViewBar.js.map +1 -1
- package/dist/components/export/ExportComponent.d.ts +12 -0
- package/dist/components/export/ExportComponent.js +12 -0
- package/dist/components/export/ExportComponent.js.map +1 -1
- package/dist/components/filter/ExcelStyleFilterPopup.d.ts +17 -4
- package/dist/components/filter/ExcelStyleFilterPopup.js +35 -23
- package/dist/components/filter/ExcelStyleFilterPopup.js.map +1 -1
- package/dist/components/filter/ExcelStyleFilterPopupObjects.d.ts +1 -1
- package/dist/components/filter/ExcelStyleFilterPopupObjects.js +2 -2
- package/dist/components/filter/ExcelStyleFilterPopupObjects.js.map +1 -1
- package/dist/components/filter/FilterForm.d.ts +10 -0
- package/dist/components/filter/FilterForm.js +10 -0
- package/dist/components/filter/FilterForm.js.map +1 -1
- package/dist/components/filter/FilterPopup.d.ts +22 -4
- package/dist/components/filter/FilterPopup.js +17 -9
- package/dist/components/filter/FilterPopup.js.map +1 -1
- package/dist/components/filter/FilterPopupObjects.d.ts +5 -2
- package/dist/components/filter/FilterPopupObjects.js +5 -2
- package/dist/components/filter/FilterPopupObjects.js.map +1 -1
- package/dist/components/filter/FindForm.d.ts +7 -0
- package/dist/components/filter/FindForm.js +7 -0
- package/dist/components/filter/FindForm.js.map +1 -1
- package/dist/components/filter/ManagePrefilterPopup.d.ts +11 -0
- package/dist/components/filter/ManagePrefilterPopup.js +11 -0
- package/dist/components/filter/ManagePrefilterPopup.js.map +1 -1
- package/dist/components/form/Form.d.ts +16 -0
- package/dist/components/form/Form.js +16 -0
- package/dist/components/form/Form.js.map +1 -1
- package/dist/components/grid/Grid.d.ts +17 -0
- package/dist/components/grid/Grid.js +18 -1
- package/dist/components/grid/Grid.js.map +1 -1
- package/dist/components/maps/Maps.d.ts +6 -0
- package/dist/components/maps/Maps.js +6 -0
- package/dist/components/maps/Maps.js.map +1 -1
- package/dist/components/menu/ListBarMenuComponent.d.ts +10 -0
- package/dist/components/menu/ListBarMenuComponent.js +10 -0
- package/dist/components/menu/ListBarMenuComponent.js.map +1 -1
- package/dist/components/pivot-grid/PivotGrid.d.ts +6 -0
- package/dist/components/pivot-grid/PivotGrid.js +6 -0
- package/dist/components/pivot-grid/PivotGrid.js.map +1 -1
- package/dist/components/pivot-grid/PivotGridFieldList.d.ts +7 -0
- package/dist/components/pivot-grid/PivotGridFieldList.js +7 -0
- package/dist/components/pivot-grid/PivotGridFieldList.js.map +1 -1
- package/dist/components/pop-up/AllRowsPopUp.d.ts +12 -0
- package/dist/components/pop-up/AllRowsPopUp.js +12 -0
- package/dist/components/pop-up/AllRowsPopUp.js.map +1 -1
- package/dist/components/pop-up/PopUpComponent.d.ts +11 -0
- package/dist/components/pop-up/PopUpComponent.js +11 -0
- package/dist/components/pop-up/PopUpComponent.js.map +1 -1
- package/dist/components/prefilter-list/PrefilterList.d.ts +9 -0
- package/dist/components/prefilter-list/PrefilterList.js +9 -0
- package/dist/components/prefilter-list/PrefilterList.js.map +1 -1
- package/dist/components/preview/Preview.d.ts +6 -0
- package/dist/components/preview/Preview.js +6 -0
- package/dist/components/preview/Preview.js.map +1 -1
- package/dist/components/report-bar/ReportBar.d.ts +6 -0
- package/dist/components/report-bar/ReportBar.js +6 -0
- package/dist/components/report-bar/ReportBar.js.map +1 -1
- package/dist/components/report-tiles/ReportTiles.d.ts +6 -0
- package/dist/components/report-tiles/ReportTiles.js +6 -0
- package/dist/components/report-tiles/ReportTiles.js.map +1 -1
- package/dist/components/scheduler/Scheduler.d.ts +6 -0
- package/dist/components/scheduler/Scheduler.js +6 -0
- package/dist/components/scheduler/Scheduler.js.map +1 -1
- package/dist/components/snackbar/SnackBar.d.ts +12 -0
- package/dist/components/snackbar/SnackBar.js +12 -0
- package/dist/components/snackbar/SnackBar.js.map +1 -1
- package/dist/components/splitter/Splitter.d.ts +6 -0
- package/dist/components/splitter/Splitter.js +6 -0
- package/dist/components/splitter/Splitter.js.map +1 -1
- package/dist/components/tab/BaseTab.d.ts +5 -0
- package/dist/components/tab/BaseTab.js +5 -0
- package/dist/components/tab/BaseTab.js.map +1 -1
- package/dist/components/tab/DetailTabPage.d.ts +11 -0
- package/dist/components/tab/DetailTabPage.js +11 -0
- package/dist/components/tab/DetailTabPage.js.map +1 -1
- package/dist/components/tab/Tab.d.ts +10 -0
- package/dist/components/tab/Tab.js +10 -0
- package/dist/components/tab/Tab.js.map +1 -1
- package/dist/components/task/TaskBar.d.ts +12 -2
- package/dist/components/task/TaskBar.js +13 -3
- package/dist/components/task/TaskBar.js.map +1 -1
- package/dist/components/task/TaskTiles.d.ts +11 -2
- package/dist/components/task/TaskTiles.js +12 -3
- package/dist/components/task/TaskTiles.js.map +1 -1
- package/dist/components/topbar/TopBar.d.ts +10 -0
- package/dist/components/topbar/TopBar.js +10 -0
- package/dist/components/topbar/TopBar.js.map +1 -1
- package/dist/components/tree-view/TreeView.d.ts +9 -0
- package/dist/components/tree-view/TreeView.js +9 -0
- package/dist/components/tree-view/TreeView.js.map +1 -1
- package/dist/controls/DateControl.d.ts +39 -0
- package/dist/controls/DateControl.js +18 -1
- package/dist/controls/DateControl.js.map +1 -1
- package/dist/controls/FormComboBox.d.ts +30 -0
- package/dist/controls/FormComboBox.js +8 -0
- package/dist/controls/FormComboBox.js.map +1 -1
- package/dist/controls/GridColumnControl.d.ts +30 -0
- package/dist/controls/GridColumnControl.js +12 -3
- package/dist/controls/GridColumnControl.js.map +1 -1
- package/dist/controls/InputFieldControl.d.ts +49 -0
- package/dist/controls/InputFieldControl.js +30 -0
- package/dist/controls/InputFieldControl.js.map +1 -1
- package/dist/controls/LookupDropdown.d.ts +29 -0
- package/dist/controls/LookupDropdown.js +8 -0
- package/dist/controls/LookupDropdown.js.map +1 -1
- package/dist/enums/ElementTypes.d.ts +18 -0
- package/dist/enums/ElementTypes.js +18 -0
- package/dist/enums/ElementTypes.js.map +1 -1
- package/dist/enums/ExportFormat.d.ts +8 -0
- package/dist/enums/ExportFormat.js +8 -0
- package/dist/enums/ExportFormat.js.map +1 -1
- package/dist/enums/LogLevel.d.ts +17 -0
- package/dist/enums/LogLevel.js +17 -1
- package/dist/enums/LogLevel.js.map +1 -1
- package/dist/enums/index.d.ts +1 -0
- package/dist/enums/index.js +1 -0
- package/dist/enums/index.js.map +1 -1
- package/dist/helpers/ConfigChecker.d.ts +13 -0
- package/dist/helpers/ConfigChecker.js +13 -0
- package/dist/helpers/ConfigChecker.js.map +1 -1
- package/dist/helpers/Ensure.d.ts +13 -0
- package/dist/helpers/Ensure.js +13 -0
- package/dist/helpers/Ensure.js.map +1 -1
- package/dist/helpers/FlightTracker.d.ts +13 -3
- package/dist/helpers/FlightTracker.js +103 -17
- package/dist/helpers/FlightTracker.js.map +1 -1
- package/dist/helpers/GlobalWaitEventHandler.d.ts +13 -0
- package/dist/helpers/GlobalWaitEventHandler.js +13 -0
- package/dist/helpers/GlobalWaitEventHandler.js.map +1 -1
- package/dist/helpers/LoginHelper.d.ts +35 -1
- package/dist/helpers/LoginHelper.js +46 -11
- package/dist/helpers/LoginHelper.js.map +1 -1
- package/dist/helpers/PathResolver.d.ts +22 -0
- package/dist/helpers/PathResolver.js +22 -0
- package/dist/helpers/PathResolver.js.map +1 -1
- package/dist/helpers/Poll.d.ts +13 -1
- package/dist/helpers/Poll.js +12 -0
- package/dist/helpers/Poll.js.map +1 -1
- package/dist/helpers/RegexHelper.d.ts +15 -0
- package/dist/helpers/RegexHelper.js +15 -3
- package/dist/helpers/RegexHelper.js.map +1 -1
- package/dist/helpers/SubjectNavigator.d.ts +26 -0
- package/dist/helpers/SubjectNavigator.js +51 -0
- package/dist/helpers/SubjectNavigator.js.map +1 -0
- package/dist/helpers/UserSimulationHelper.d.ts +14 -0
- package/dist/helpers/UserSimulationHelper.js +14 -0
- package/dist/helpers/UserSimulationHelper.js.map +1 -1
- package/dist/helpers/index.d.ts +1 -0
- package/dist/helpers/index.js +1 -0
- package/dist/helpers/index.js.map +1 -1
- package/dist/index.d.ts +6 -20
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/interfaces/IComponentObjects.d.ts +12 -0
- package/dist/interfaces/IComponentSchemaMeta.d.ts +15 -0
- package/dist/interfaces/IComponentSchemaMeta.js +2 -0
- package/dist/interfaces/IComponentSchemaMeta.js.map +1 -0
- package/dist/interfaces/IProperty.d.ts +13 -0
- package/dist/interfaces/IRegisteredSubjects.d.ts +12 -0
- package/dist/interfaces/ISeedSubjectDraft.d.ts +16 -0
- package/dist/interfaces/ISeedSubjectDraft.js +2 -0
- package/dist/interfaces/ISeedSubjectDraft.js.map +1 -0
- package/dist/interfaces/ISubject.d.ts +13 -0
- package/dist/interfaces/ISubjectDetail.d.ts +11 -0
- package/dist/interfaces/ISubjectDetail.js +2 -0
- package/dist/interfaces/ISubjectDetail.js.map +1 -0
- package/dist/mcp/ArtifactResolver.d.ts +152 -0
- package/dist/mcp/ArtifactResolver.js +232 -0
- package/dist/mcp/ArtifactResolver.js.map +1 -0
- package/dist/mcp/index.d.ts +2 -0
- package/dist/mcp/index.js +2 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/page-extensions/GlobalComponents.d.ts +30 -0
- package/dist/page-extensions/GlobalComponents.js +30 -0
- package/dist/page-extensions/GlobalComponents.js.map +1 -1
- package/dist/page-extensions/GoToDeepLink.d.ts +9 -0
- package/dist/page-extensions/GoToDeepLink.js +9 -0
- package/dist/page-extensions/GoToDeepLink.js.map +1 -1
- package/dist/page-extensions/LoginFeatures.d.ts +8 -0
- package/dist/page-extensions/LoginFeatures.js +8 -0
- package/dist/page-extensions/LoginFeatures.js.map +1 -1
- package/dist/page-extensions/SubjectRegistry.d.ts +31 -0
- package/dist/page-extensions/SubjectRegistry.js +31 -0
- package/dist/page-extensions/SubjectRegistry.js.map +1 -1
- package/dist/page-extensions/UserSimulation.d.ts +21 -0
- package/dist/page-extensions/UserSimulation.js +21 -0
- package/dist/page-extensions/UserSimulation.js.map +1 -1
- package/dist/page-extensions/WaitEventHandler.d.ts +20 -0
- package/dist/page-extensions/WaitEventHandler.js +20 -0
- package/dist/page-extensions/WaitEventHandler.js.map +1 -1
- package/dist/page-overrides/ClickOverride.d.ts +23 -2
- package/dist/page-overrides/ClickOverride.js +34 -50
- package/dist/page-overrides/ClickOverride.js.map +1 -1
- package/dist/page-overrides/FillOverride.d.ts +23 -0
- package/dist/page-overrides/FillOverride.js +56 -61
- package/dist/page-overrides/FillOverride.js.map +1 -1
- package/dist/services/ConfigBuilder.d.ts +24 -0
- package/dist/services/ConfigBuilder.js +24 -0
- package/dist/services/ConfigBuilder.js.map +1 -1
- package/dist/services/IndiciumApi.service.d.ts +26 -0
- package/dist/services/IndiciumApi.service.js +40 -5
- package/dist/services/IndiciumApi.service.js.map +1 -1
- package/dist/services/Logger.d.ts +13 -0
- package/dist/services/Logger.js +39 -0
- package/dist/services/Logger.js.map +1 -1
- package/dist/types/Components.d.ts +18 -1
- package/dist/types/Components.js +0 -1
- package/dist/types/Components.js.map +1 -1
- package/dist/types/LocatorType.d.ts +5 -0
- package/dist/types/LocatorType.js +2 -0
- package/dist/types/LocatorType.js.map +1 -0
- package/dist/types/PollTypes.d.ts +8 -0
- package/dist/types/Test.d.ts +18 -0
- package/dist/types/Universal.d.ts +12 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -1
- package/enums/ElementTypes.ts +18 -0
- package/enums/ExportFormat.ts +8 -0
- package/enums/LogLevel.ts +17 -1
- package/enums/index.ts +1 -0
- package/helpers/ConfigChecker.ts +13 -0
- package/helpers/Ensure.ts +13 -0
- package/helpers/FlightTracker.ts +109 -20
- package/helpers/GlobalWaitEventHandler.ts +13 -0
- package/helpers/LoginHelper.ts +49 -12
- package/helpers/PathResolver.ts +22 -0
- package/helpers/Poll.ts +13 -1
- package/helpers/RegexHelper.ts +15 -3
- package/helpers/SubjectNavigator.ts +57 -0
- package/helpers/UserSimulationHelper.ts +14 -0
- package/helpers/index.ts +1 -0
- package/index.ts +1 -0
- package/interfaces/IComponentObjects.ts +12 -0
- package/interfaces/IComponentSchemaMeta.ts +17 -0
- package/interfaces/IProperty.ts +13 -0
- package/interfaces/IRegisteredSubjects.ts +12 -0
- package/interfaces/ISeedSubjectDraft.ts +17 -0
- package/interfaces/ISubject.ts +13 -0
- package/interfaces/ISubjectDetail.ts +12 -0
- package/mcp/ArtifactResolver.ts +296 -0
- package/mcp/index.ts +2 -0
- package/package.json +20 -8
- package/page-extensions/GlobalComponents.ts +30 -0
- package/page-extensions/GoToDeepLink.ts +9 -0
- package/page-extensions/LoginFeatures.ts +8 -0
- package/page-extensions/SubjectRegistry.ts +32 -0
- package/page-extensions/UserSimulation.ts +21 -0
- package/page-extensions/WaitEventHandler.ts +20 -0
- package/page-overrides/ClickOverride.ts +36 -55
- package/page-overrides/FillOverride.ts +59 -73
- package/promptCredentials.js +1 -1
- package/scripts/Testwise.template.json +1 -1
- package/scripts/diagram.js +2267 -0
- package/scripts/main.js +84 -63
- package/scripts/postinstall.js +55 -42
- package/scripts/setup.js +91 -37
- package/scripts/sync.js +767 -748
- package/services/ConfigBuilder.ts +24 -0
- package/services/IndiciumApi.service.ts +44 -6
- package/services/Logger.ts +40 -0
- package/templates/copilot-instructions.md +130 -0
- package/templates/mcp.json +9 -0
- package/templates/testwise-test-expert.agent.md +453 -0
- package/tests/tsconfig.json +12 -0
- package/tsconfig.json +6 -2
- package/types/Components.ts +17 -2
- package/types/LocatorType.ts +5 -0
- package/types/PollTypes.ts +8 -0
- package/types/Test.ts +18 -0
- package/types/Universal.ts +12 -0
- package/types/index.ts +2 -0
- package/dist/interfaces/IStoredTestResults.d.ts +0 -5
- package/dist/interfaces/IStoredTestResults.js +0 -2
- package/dist/interfaces/IStoredTestResults.js.map +0 -1
- package/dist/templates/test-artifacts/SubjectPageBase.d.ts +0 -5
- package/dist/templates/test-artifacts/SubjectPageBase.js +0 -6
- package/dist/templates/test-artifacts/SubjectPageBase.js.map +0 -1
- package/dist/templates/test-artifacts/screens/index.d.ts +0 -1
- package/dist/templates/test-artifacts/screens/index.js +0 -2
- package/dist/templates/test-artifacts/screens/index.js.map +0 -1
- package/dist/templates/test-artifacts/subjects/index.d.ts +0 -1
- package/dist/templates/test-artifacts/subjects/index.js +0 -2
- package/dist/templates/test-artifacts/subjects/index.js.map +0 -1
- package/interfaces/IStoredTestResults.ts +0 -6
|
@@ -0,0 +1,2267 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* eslint-disable @typescript-eslint/naming-convention */
|
|
3
|
+
/** biome-ignore-all lint/suspicious/noAssignInExpressions: reason42 */
|
|
4
|
+
/**
|
|
5
|
+
* Testwise Architecture Diagram
|
|
6
|
+
* Serves a living structured SVG diagram that rescans source files on every refresh.
|
|
7
|
+
* Run via: testwise diagram OR npm run diagram
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import http from 'node:http';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import process from 'node:process';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
17
|
+
|
|
18
|
+
// ─── Filesystem helpers ───────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/** Scan a flat folder for .ts source files; skip index.ts, *.d.ts, *Objects.ts */
|
|
21
|
+
function scanTs(relDir) {
|
|
22
|
+
const dir = path.join(ROOT, relDir);
|
|
23
|
+
if (!fs.existsSync(dir)) return [];
|
|
24
|
+
return fs
|
|
25
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
26
|
+
.filter(
|
|
27
|
+
(e) =>
|
|
28
|
+
e.isFile() &&
|
|
29
|
+
e.name.endsWith('.ts') &&
|
|
30
|
+
!e.name.endsWith('.d.ts') &&
|
|
31
|
+
e.name !== 'index.ts' &&
|
|
32
|
+
!e.name.endsWith('Objects.ts')
|
|
33
|
+
)
|
|
34
|
+
.map((e) => e.name.replace(/\.ts$/, ''));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Scan components/ subfolders and categorise into subject / screen / overlay */
|
|
38
|
+
function scanComponentFolders() {
|
|
39
|
+
const dir = path.join(ROOT, 'components');
|
|
40
|
+
if (!fs.existsSync(dir)) return { subject: [], screen: [], overlay: [] };
|
|
41
|
+
|
|
42
|
+
const SUBJECT = new Set(['form', 'grid']);
|
|
43
|
+
const OVERLAY = new Set(['pop-up', 'export', 'snackbar', 'topbar', 'menu', 'task']);
|
|
44
|
+
|
|
45
|
+
const subject = [],
|
|
46
|
+
screen = [],
|
|
47
|
+
overlay = [];
|
|
48
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
49
|
+
if (!e.isDirectory()) continue;
|
|
50
|
+
const label = e.name
|
|
51
|
+
.split('-')
|
|
52
|
+
.map((w) => w[0].toUpperCase() + w.slice(1))
|
|
53
|
+
.join('');
|
|
54
|
+
if (SUBJECT.has(e.name)) subject.push(label);
|
|
55
|
+
else if (OVERLAY.has(e.name)) overlay.push(label);
|
|
56
|
+
else screen.push(label);
|
|
57
|
+
}
|
|
58
|
+
return { subject, screen, overlay };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── JSDoc parser ─────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Parse JSDoc comments from a TypeScript source file.
|
|
65
|
+
* Returns { summary, members } where members is an array of { name, kind, doc }.
|
|
66
|
+
* - summary: the first top-level file/class/function JSDoc block
|
|
67
|
+
* - members: all exported functions, methods, classes, and constants with their JSDoc
|
|
68
|
+
*/
|
|
69
|
+
function parseJsDoc(absPath) {
|
|
70
|
+
if (!fs.existsSync(absPath)) return { summary: '', members: [] };
|
|
71
|
+
const src = fs.readFileSync(absPath, 'utf8');
|
|
72
|
+
|
|
73
|
+
// Strip single-line // comments to avoid false-positive matches
|
|
74
|
+
const stripped = src.replace(/\/\/[^\n]*/g, '');
|
|
75
|
+
|
|
76
|
+
// Extract all /** … */ blocks with the code line that follows them
|
|
77
|
+
const blockRe =
|
|
78
|
+
/\/\*\*([\s\S]*?)\*\/([ \t]*\n[ \t]*(?:export\s+)?((?:async\s+)?(?:class|function|const|interface|type|enum|abstract\s+class)\s+(\w+)|(?:(?:public|protected|private|static|async|readonly|override)\s+)*(\w+)\s*[(<]))/g;
|
|
79
|
+
|
|
80
|
+
const members = [];
|
|
81
|
+
let summary = '';
|
|
82
|
+
let summaryFromExport = false;
|
|
83
|
+
let m;
|
|
84
|
+
|
|
85
|
+
while ((m = blockRe.exec(stripped)) !== null) {
|
|
86
|
+
const rawDoc = m[1];
|
|
87
|
+
const followingLine = m[2].trim();
|
|
88
|
+
const isExported = /^\s*export\s/.test(m[2]);
|
|
89
|
+
|
|
90
|
+
// Clean the JSDoc text: remove leading * on each line, collapse whitespace
|
|
91
|
+
// Strip @example sections (and the code lines that follow) before filtering
|
|
92
|
+
const docLines = rawDoc.split('\n').map((l) => l.replace(/^\s*\*\s?/, '').trim());
|
|
93
|
+
let inExample = false;
|
|
94
|
+
const filteredLines = docLines.filter((l) => {
|
|
95
|
+
if (l.startsWith('@example')) {
|
|
96
|
+
inExample = true;
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
if (inExample && l.startsWith('@')) inExample = false;
|
|
100
|
+
if (inExample) return false;
|
|
101
|
+
return l && !l.startsWith('@param') && !l.startsWith('@returns') && !l.startsWith('@template');
|
|
102
|
+
});
|
|
103
|
+
const doc = filteredLines.join(' ').replace(/\s+/g, ' ').trim();
|
|
104
|
+
|
|
105
|
+
if (!doc) continue;
|
|
106
|
+
|
|
107
|
+
// Determine the exported name
|
|
108
|
+
const nameMatch =
|
|
109
|
+
followingLine.match(/(?:class|function|const|interface|type|enum|abstract\s+class)\s+(\w+)/) ||
|
|
110
|
+
followingLine.match(/^(?:(?:public|protected|private|static|async|readonly|override)\s+)*(\w+)\s*[(<]/);
|
|
111
|
+
const name = nameMatch ? nameMatch[1] : null;
|
|
112
|
+
|
|
113
|
+
// Determine kind for display
|
|
114
|
+
let kind = 'member';
|
|
115
|
+
if (/\bclass\b/.test(followingLine)) kind = 'class';
|
|
116
|
+
else if (/\bfunction\b/.test(followingLine)) kind = 'function';
|
|
117
|
+
else if (/\bconst\b/.test(followingLine)) kind = 'const';
|
|
118
|
+
else if (/\binterface\b/.test(followingLine)) kind = 'interface';
|
|
119
|
+
else if (/\benum\b/.test(followingLine)) kind = 'enum';
|
|
120
|
+
else if (/\btype\b/.test(followingLine)) kind = 'type';
|
|
121
|
+
else if (/\basync\b/.test(followingLine)) kind = 'method';
|
|
122
|
+
else kind = 'method';
|
|
123
|
+
|
|
124
|
+
// Prefer exported symbols as summary; accept non-exported only if nothing found yet
|
|
125
|
+
if (!summary || (!summaryFromExport && isExported)) {
|
|
126
|
+
if (
|
|
127
|
+
doc.length <= 600 &&
|
|
128
|
+
!doc.startsWith('biome-') &&
|
|
129
|
+
!doc.startsWith('eslint-') &&
|
|
130
|
+
!doc.includes('=>') &&
|
|
131
|
+
!doc.includes('await ') &&
|
|
132
|
+
!doc.includes('Promise<') &&
|
|
133
|
+
!doc.includes('this.')
|
|
134
|
+
) {
|
|
135
|
+
summary = doc;
|
|
136
|
+
if (isExported) summaryFromExport = true;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (name && name !== 'constructor') {
|
|
141
|
+
// Skip members whose doc looks like code
|
|
142
|
+
if (!doc.includes('=>') && !doc.includes('await ') && !doc.includes('Promise<') && !doc.includes('this.')) {
|
|
143
|
+
members.push({ name, kind, doc });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// If no JSDoc found at all, try to get the first block regardless of what follows
|
|
149
|
+
if (!summary) {
|
|
150
|
+
const firstBlock = stripped.match(/\/\*\*([\s\S]*?)\*\//);
|
|
151
|
+
if (firstBlock) {
|
|
152
|
+
const candidate = firstBlock[1]
|
|
153
|
+
.split('\n')
|
|
154
|
+
.map((l) => l.replace(/^\s*\*\s?/, '').trim())
|
|
155
|
+
.filter(Boolean)
|
|
156
|
+
.join(' ')
|
|
157
|
+
.replace(/\s+/g, ' ')
|
|
158
|
+
.trim();
|
|
159
|
+
// Only use it if it looks like a real summary (not a code dump or lint directive)
|
|
160
|
+
if (
|
|
161
|
+
candidate.length <= 600 &&
|
|
162
|
+
!candidate.includes('=>') &&
|
|
163
|
+
!candidate.includes('await ') &&
|
|
164
|
+
!candidate.startsWith('biome-') &&
|
|
165
|
+
!candidate.startsWith('eslint-')
|
|
166
|
+
) {
|
|
167
|
+
summary = candidate;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { summary, members };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Build a tips map for all scanned source files.
|
|
177
|
+
* Keys are bare file names (no extension), values are tooltip HTML strings.
|
|
178
|
+
* Reads JSDoc live from each .ts file on every call (living docs).
|
|
179
|
+
*/
|
|
180
|
+
function buildTipsMap(fileMap) {
|
|
181
|
+
// fileMap: { 'relDir/fileName': absPath, ... }
|
|
182
|
+
const tips = {};
|
|
183
|
+
for (const [key, absPath] of Object.entries(fileMap)) {
|
|
184
|
+
const name = key.split('/').pop();
|
|
185
|
+
const { summary, members } = parseJsDoc(absPath);
|
|
186
|
+
if (!summary && members.length === 0) continue;
|
|
187
|
+
|
|
188
|
+
let text = summary || '';
|
|
189
|
+
if (members.length > 0) {
|
|
190
|
+
const lines = members.map((mem) => {
|
|
191
|
+
const kindLabel = mem.kind === 'method' || mem.kind === 'function' ? `${mem.name}()` : mem.name;
|
|
192
|
+
const doc = mem.doc.length > 120 ? `${mem.doc.slice(0, 117)}…` : mem.doc;
|
|
193
|
+
return `• ${kindLabel} — ${doc}`;
|
|
194
|
+
});
|
|
195
|
+
text += (text ? '\n\n' : '') + lines.join('\n');
|
|
196
|
+
}
|
|
197
|
+
tips[name] = text;
|
|
198
|
+
}
|
|
199
|
+
return tips;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Scan a directory recursively for all .ts files (not .d.ts, not node_modules/dist) */
|
|
203
|
+
function scanAllTsFiles() {
|
|
204
|
+
const SKIP_DIRS = new Set(['node_modules', 'dist', 'coverage', '.git', 'test-artifacts', 'seed-data', 'logs']);
|
|
205
|
+
const results = [];
|
|
206
|
+
function walk(dir) {
|
|
207
|
+
if (!fs.existsSync(dir)) return;
|
|
208
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
209
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
210
|
+
const full = path.join(dir, e.name);
|
|
211
|
+
if (e.isDirectory()) walk(full);
|
|
212
|
+
else if (e.isFile() && e.name.endsWith('.ts') && !e.name.endsWith('.d.ts')) {
|
|
213
|
+
results.push(full);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
walk(ROOT);
|
|
218
|
+
return results;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Parse the import statements from a TypeScript file.
|
|
223
|
+
* Returns an array of relative paths that this file imports.
|
|
224
|
+
*/
|
|
225
|
+
function parseImports(absPath) {
|
|
226
|
+
try {
|
|
227
|
+
const src = fs.readFileSync(absPath, 'utf8');
|
|
228
|
+
const importRe = /from\s+['"]([^'"]+)['"]/g;
|
|
229
|
+
const imports = [];
|
|
230
|
+
let m;
|
|
231
|
+
while ((m = importRe.exec(src)) !== null) {
|
|
232
|
+
const spec = m[1];
|
|
233
|
+
if (spec.startsWith('.')) imports.push(spec);
|
|
234
|
+
}
|
|
235
|
+
return imports;
|
|
236
|
+
} catch {
|
|
237
|
+
return [];
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Build the full living dependency graph from all .ts files in the project.
|
|
243
|
+
* Returns { nodes, links } where links use actual parsed imports.
|
|
244
|
+
*/
|
|
245
|
+
function buildDepGraph() {
|
|
246
|
+
const MOD_COLOR = {
|
|
247
|
+
services: '#9b59b6',
|
|
248
|
+
interfaces: '#5a8abf',
|
|
249
|
+
types: '#9a8a50',
|
|
250
|
+
enums: '#9a8a50',
|
|
251
|
+
'artifact-builder/helpers': '#c9a558',
|
|
252
|
+
'artifact-builder': '#d4910a',
|
|
253
|
+
controls: '#c084fc',
|
|
254
|
+
helpers: '#27ae60',
|
|
255
|
+
'page-overrides': '#c0392b',
|
|
256
|
+
'page-extensions': '#16a085',
|
|
257
|
+
components: '#4a90d9',
|
|
258
|
+
mcp: '#f97316',
|
|
259
|
+
tests: '#64748b',
|
|
260
|
+
scripts: '#94a3b8',
|
|
261
|
+
root: '#58a6ff'
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const allFiles = scanAllTsFiles();
|
|
265
|
+
// Map from absolute path → { id, name, group, relPath }
|
|
266
|
+
const byPath = new Map();
|
|
267
|
+
const nodes = [];
|
|
268
|
+
|
|
269
|
+
for (const absFile of allFiles) {
|
|
270
|
+
const rel = path.relative(ROOT, absFile).replace(/\\/g, '/');
|
|
271
|
+
const parts = rel.split('/');
|
|
272
|
+
// Determine group: first folder, or 'root' for top-level files
|
|
273
|
+
let group = parts.length > 1 ? parts[0] : 'root';
|
|
274
|
+
// Sub-group artifact-builder/helpers separately
|
|
275
|
+
if (parts[0] === 'artifact-builder' && parts[1] === 'helpers') group = 'artifact-builder/helpers';
|
|
276
|
+
const name = parts[parts.length - 1].replace(/\.ts$/, '');
|
|
277
|
+
const id = rel.replace(/\.ts$/, '');
|
|
278
|
+
const node = {
|
|
279
|
+
id,
|
|
280
|
+
name,
|
|
281
|
+
group,
|
|
282
|
+
rel,
|
|
283
|
+
absPath: absFile,
|
|
284
|
+
tip: '',
|
|
285
|
+
color: MOD_COLOR[group] || '#8b949e'
|
|
286
|
+
};
|
|
287
|
+
nodes.push(node);
|
|
288
|
+
byPath.set(absFile, node);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const links = [];
|
|
292
|
+
const seen = new Set();
|
|
293
|
+
|
|
294
|
+
for (const node of nodes) {
|
|
295
|
+
const imports = parseImports(node.absPath);
|
|
296
|
+
for (const imp of imports) {
|
|
297
|
+
// Resolve the import relative to the importing file's directory
|
|
298
|
+
const impDir = path.dirname(node.absPath);
|
|
299
|
+
// Strip .js extension — TS ESM imports use .js but the actual files are .ts
|
|
300
|
+
const impNoExt = imp.replace(/\.js$/, '');
|
|
301
|
+
const resolved = path.resolve(impDir, impNoExt).replace(/\\/g, '/');
|
|
302
|
+
// Try .ts, then /index.ts
|
|
303
|
+
const candidates = [`${resolved}.ts`, `${resolved}/index.ts`];
|
|
304
|
+
for (const cand of candidates) {
|
|
305
|
+
const target = byPath.get(cand.replace(/\//g, path.sep));
|
|
306
|
+
if (target && target.id !== node.id) {
|
|
307
|
+
const key = `${node.id}->${target.id}`;
|
|
308
|
+
if (!seen.has(key)) {
|
|
309
|
+
seen.add(key);
|
|
310
|
+
links.push({ source: node.id, target: target.id });
|
|
311
|
+
}
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return { nodes, links };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ─── HTML page generation ────────────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
/** Dep graph computed once at server start (not per request). */
|
|
324
|
+
const _cachedDepGraph = buildDepGraph();
|
|
325
|
+
|
|
326
|
+
function esc(s) {
|
|
327
|
+
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ─── HTML page generation ────────────────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
function generateHtml() {
|
|
333
|
+
const components = scanComponentFolders();
|
|
334
|
+
const controls = scanTs('controls');
|
|
335
|
+
const helpers = scanTs('helpers');
|
|
336
|
+
const pageExts = scanTs('page-extensions').filter((f) => f !== 'SubjectRegistry');
|
|
337
|
+
const pageOvr = scanTs('page-overrides');
|
|
338
|
+
const interfaces = scanTs('interfaces');
|
|
339
|
+
const types = [...scanTs('types'), ...scanTs('enums')];
|
|
340
|
+
const services = scanTs('services');
|
|
341
|
+
const pipeline = scanTs('artifact-builder');
|
|
342
|
+
const pipeHelpers = scanTs('artifact-builder/helpers');
|
|
343
|
+
const scripts =
|
|
344
|
+
scanTs('scripts').length > 0 ? scanTs('scripts') : ['main', 'postinstall', 'setup', 'sync', 'diagram'];
|
|
345
|
+
const mcpFiles = scanTs('mcp').length > 0 ? scanTs('mcp') : ['ArtifactResolver'];
|
|
346
|
+
const generatedAt = new Date().toLocaleString();
|
|
347
|
+
const ROOT_ESC = ROOT.replace(/\\/g, '/');
|
|
348
|
+
|
|
349
|
+
// TIPS are used only for data-tip attributes on arch card items.
|
|
350
|
+
// They are also served via /api/data for client-side use.
|
|
351
|
+
// Build them here so card HTML can include them.
|
|
352
|
+
function absPath(relDir, name) {
|
|
353
|
+
return path.join(ROOT, relDir, `${name}.ts`);
|
|
354
|
+
}
|
|
355
|
+
const fileMap = {};
|
|
356
|
+
const addGroup = (relDir, list) => {
|
|
357
|
+
for (const name of list) fileMap[`${relDir}/${name}`] = absPath(relDir, name);
|
|
358
|
+
};
|
|
359
|
+
addGroup('services', services);
|
|
360
|
+
addGroup('interfaces', interfaces);
|
|
361
|
+
addGroup('types', scanTs('types'));
|
|
362
|
+
addGroup('enums', scanTs('enums'));
|
|
363
|
+
addGroup('artifact-builder', pipeline);
|
|
364
|
+
addGroup('artifact-builder/helpers', pipeHelpers);
|
|
365
|
+
addGroup('controls', controls);
|
|
366
|
+
addGroup('helpers', helpers);
|
|
367
|
+
addGroup('page-overrides', pageOvr);
|
|
368
|
+
addGroup('page-extensions', [...pageExts, 'SubjectRegistry']);
|
|
369
|
+
addGroup('mcp', mcpFiles);
|
|
370
|
+
// Scripts are .js not .ts — add them separately with correct extension
|
|
371
|
+
for (const name of scripts) {
|
|
372
|
+
const jsPath = path.join(ROOT, 'scripts', `${name}.js`);
|
|
373
|
+
if (fs.existsSync(jsPath)) fileMap[`scripts/${name}`] = jsPath;
|
|
374
|
+
}
|
|
375
|
+
const compDir = path.join(ROOT, 'components');
|
|
376
|
+
if (fs.existsSync(compDir)) {
|
|
377
|
+
for (const e of fs.readdirSync(compDir, { withFileTypes: true })) {
|
|
378
|
+
if (!e.isDirectory()) continue;
|
|
379
|
+
// Add ALL non-Objects, non-index .ts files in each component folder
|
|
380
|
+
const folderDir = path.join(compDir, e.name);
|
|
381
|
+
for (const f of fs.readdirSync(folderDir)) {
|
|
382
|
+
if (!f.endsWith('.ts') || f.endsWith('.d.ts') || f.endsWith('Objects.ts') || f === 'index.ts') continue;
|
|
383
|
+
const name = f.replace(/\.ts$/, '');
|
|
384
|
+
fileMap[`components/${name}`] = path.join(folderDir, f);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
const TIPS = buildTipsMap(fileMap);
|
|
389
|
+
|
|
390
|
+
const items = (list, color = '#8b949e') =>
|
|
391
|
+
list
|
|
392
|
+
.map((x) => {
|
|
393
|
+
const tip = TIPS[String(x)] || '';
|
|
394
|
+
const tipAttr = tip ? ` data-tip="${esc(tip)}"` : '';
|
|
395
|
+
return `<div class="item" style="color:${color}"${tipAttr}>${esc(String(x))}</div>`;
|
|
396
|
+
})
|
|
397
|
+
.join('');
|
|
398
|
+
|
|
399
|
+
// fileItems variant that resolves across multiple candidate dirs (first match wins)
|
|
400
|
+
const fileItemsAny = (list, dirs, color = '#8b949e') =>
|
|
401
|
+
list
|
|
402
|
+
.map((name) => {
|
|
403
|
+
const tip = TIPS[name] || '';
|
|
404
|
+
const tipAttr = tip ? ` data-tip="${esc(tip)}"` : '';
|
|
405
|
+
// Find first dir where the file actually exists
|
|
406
|
+
const relDir = dirs.find((d) => fs.existsSync(path.join(ROOT, d, `${name}.ts`))) || dirs[0];
|
|
407
|
+
const p = `${ROOT_ESC}/${relDir}/${name}.ts`;
|
|
408
|
+
return `<div class="item file-item" style="color:${color}" data-path="${p}"${tipAttr}>${esc(name)}</div>`;
|
|
409
|
+
})
|
|
410
|
+
.join('');
|
|
411
|
+
|
|
412
|
+
// Component items — files live in components/<kebab-name>/<PascalName>.ts
|
|
413
|
+
const toKebab = (s) => s.replace(/([A-Z])/g, (_m, c, i) => (i > 0 ? `-${c.toLowerCase()}` : c.toLowerCase()));
|
|
414
|
+
const compItems = (list, color = '#8b949e') =>
|
|
415
|
+
list
|
|
416
|
+
.flatMap((folderLabel) => {
|
|
417
|
+
const folderName = toKebab(folderLabel);
|
|
418
|
+
const folderDir = path.join(ROOT, 'components', folderName);
|
|
419
|
+
let files = [];
|
|
420
|
+
if (fs.existsSync(folderDir)) {
|
|
421
|
+
files = fs
|
|
422
|
+
.readdirSync(folderDir)
|
|
423
|
+
.filter((f) => f.endsWith('.ts') && !f.endsWith('.d.ts') && !f.endsWith('Objects.ts') && f !== 'index.ts')
|
|
424
|
+
.map((f) => f.replace(/\.ts$/, ''));
|
|
425
|
+
}
|
|
426
|
+
if (files.length === 0) files = [folderLabel];
|
|
427
|
+
return files.map((name) => {
|
|
428
|
+
const tip = TIPS[name] || '';
|
|
429
|
+
const tipAttr = tip ? ` data-tip="${esc(tip)}"` : '';
|
|
430
|
+
const p = `${ROOT_ESC}/components/${folderName}/${name}.ts`;
|
|
431
|
+
return `<div class="item file-item" style="color:${color}" data-path="${p}"${tipAttr}>${esc(name)}</div>`;
|
|
432
|
+
});
|
|
433
|
+
})
|
|
434
|
+
.join('');
|
|
435
|
+
|
|
436
|
+
// Root-level file links (no extension in list, but path includes extension)
|
|
437
|
+
const rootItems = (list, color = '#8b949e') =>
|
|
438
|
+
list
|
|
439
|
+
.map((name) => {
|
|
440
|
+
const tip = TIPS[name] || '';
|
|
441
|
+
const tipAttr = tip ? ` data-tip="${esc(tip)}"` : '';
|
|
442
|
+
const absPath = path.join(ROOT, name);
|
|
443
|
+
if (!fs.existsSync(absPath)) return `<div class="item" style="color:${color}">${esc(name)}</div>`;
|
|
444
|
+
const p = `${ROOT_ESC}/${name}`;
|
|
445
|
+
return `<div class="item file-item" style="color:${color}" data-path="${p}"${tipAttr}>${esc(name)}</div>`;
|
|
446
|
+
})
|
|
447
|
+
.join('');
|
|
448
|
+
|
|
449
|
+
// .js file items — links into the scripts/ folder
|
|
450
|
+
const jsFileItems = (list, relDir, color = '#8b949e') =>
|
|
451
|
+
list
|
|
452
|
+
.map((name) => {
|
|
453
|
+
const tip = TIPS[name] || '';
|
|
454
|
+
const tipAttr = tip ? ` data-tip="${esc(tip)}"` : '';
|
|
455
|
+
const p = `${ROOT_ESC}/${relDir}/${name}.js`;
|
|
456
|
+
return `<div class="item file-item" style="color:${color}" data-path="${p}"${tipAttr}>${esc(`${name}.js`)}</div>`;
|
|
457
|
+
})
|
|
458
|
+
.join('');
|
|
459
|
+
|
|
460
|
+
// Render file items — always clickable, tooltip from JSDoc if available
|
|
461
|
+
const fileItems = (list, relDir, color = '#8b949e') =>
|
|
462
|
+
list
|
|
463
|
+
.map((name) => {
|
|
464
|
+
const tip = TIPS[name] || '';
|
|
465
|
+
const tipAttr = tip ? ` data-tip="${esc(tip)}"` : '';
|
|
466
|
+
const p = `${ROOT_ESC}/${relDir}/${name}.ts`;
|
|
467
|
+
return `<div class="item file-item" style="color:${color}" data-path="${p}"${tipAttr}>${esc(name)}</div>`;
|
|
468
|
+
})
|
|
469
|
+
.join('');
|
|
470
|
+
|
|
471
|
+
// Dashed inner sub-box
|
|
472
|
+
const subbox = (title, accent, content, foot = '') =>
|
|
473
|
+
`<div class="subbox" style="--sa:${accent}">` +
|
|
474
|
+
`<div class="subbox-head">${esc(title)}</div>` +
|
|
475
|
+
`<div class="subbox-body">${content}</div>` +
|
|
476
|
+
(foot ? `<div class="subbox-foot">${esc(foot)}</div>` : '') +
|
|
477
|
+
`</div>`;
|
|
478
|
+
|
|
479
|
+
// Main card (tip = optional tooltip on the card header)
|
|
480
|
+
const card = (id, accent, titleBg, bodyBg, title, body, foot = '', tip = '') =>
|
|
481
|
+
`<div class="card" id="${id}" style="--ca:${accent};--ct:${titleBg};--cb:${bodyBg}">` +
|
|
482
|
+
`<div class="card-head"${tip ? ` data-tip="${esc(tip)}"` : ''}>${esc(title)}</div>` +
|
|
483
|
+
`<div class="card-body">${body}</div>` +
|
|
484
|
+
(foot ? `<div class="card-foot">${esc(foot)}</div>` : '') +
|
|
485
|
+
`</div>`;
|
|
486
|
+
|
|
487
|
+
// ── Data ─────────────────────────────────────────────────────────────────
|
|
488
|
+
const svcList = services.length > 0 ? services : ['ConfigBuilder', 'IndiciumApi.service', 'Logger'];
|
|
489
|
+
|
|
490
|
+
const ifaceList =
|
|
491
|
+
interfaces.length > 0
|
|
492
|
+
? interfaces
|
|
493
|
+
: [
|
|
494
|
+
'ISubject',
|
|
495
|
+
'IProperty',
|
|
496
|
+
'IRegisteredSubjects',
|
|
497
|
+
'IStoredTestResults',
|
|
498
|
+
'IComponentObjects',
|
|
499
|
+
'IComponentSchemaMeta',
|
|
500
|
+
'ISeedSubjectDraft'
|
|
501
|
+
];
|
|
502
|
+
|
|
503
|
+
const typeList = types.length > 0 ? types : ['Components', 'Test', 'ElementTypes', 'ExportFormat', 'LogLevel'];
|
|
504
|
+
|
|
505
|
+
const stepList =
|
|
506
|
+
pipeline.length > 0
|
|
507
|
+
? pipeline
|
|
508
|
+
: [
|
|
509
|
+
'ArtifactManager',
|
|
510
|
+
'ModelDataBuilder',
|
|
511
|
+
'ModelDataRefiner',
|
|
512
|
+
'SchemaGenerator',
|
|
513
|
+
'InterfaceGenerator',
|
|
514
|
+
'ScreenInterfaceRefiner',
|
|
515
|
+
'SelectorBuilder',
|
|
516
|
+
'SubjectClassModelBuilder',
|
|
517
|
+
'ComponentClassModelBuilder',
|
|
518
|
+
'SubjectGenerator',
|
|
519
|
+
'SubjectComponentGenerator',
|
|
520
|
+
'SubjectRegistration',
|
|
521
|
+
'TsFileModel',
|
|
522
|
+
'TsFileSerializer'
|
|
523
|
+
];
|
|
524
|
+
|
|
525
|
+
const pipeHList =
|
|
526
|
+
pipeHelpers.length > 0 ? pipeHelpers : ['DataRetriever', 'NamingHandler', 'Stopwatch', 'PipelinePaths'];
|
|
527
|
+
|
|
528
|
+
const ctrlList =
|
|
529
|
+
controls.length > 0
|
|
530
|
+
? controls
|
|
531
|
+
: ['InputFieldControl', 'FormComboBox', 'DateControl', 'LookupDropdown', 'GridColumnControl'];
|
|
532
|
+
|
|
533
|
+
const helpList =
|
|
534
|
+
helpers.length > 0
|
|
535
|
+
? helpers
|
|
536
|
+
: [
|
|
537
|
+
'FlightTracker',
|
|
538
|
+
'GlobalWaitEventHandler',
|
|
539
|
+
'LoginHelper',
|
|
540
|
+
'SubjectNavigator',
|
|
541
|
+
'Ensure',
|
|
542
|
+
'Poll',
|
|
543
|
+
'RegexHelper',
|
|
544
|
+
'PathResolver',
|
|
545
|
+
'UserSimulationHelper',
|
|
546
|
+
'ConfigChecker'
|
|
547
|
+
];
|
|
548
|
+
|
|
549
|
+
const ovrList = pageOvr.length > 0 ? pageOvr : ['ClickOverride', 'FillOverride'];
|
|
550
|
+
const extList =
|
|
551
|
+
pageExts.length > 0
|
|
552
|
+
? pageExts
|
|
553
|
+
: ['GlobalComponents', 'GoToDeepLink', 'LoginFeatures', 'WaitEventHandler', 'UserSimulation'];
|
|
554
|
+
|
|
555
|
+
// ── Section cards ─────────────────────────────────────────────────────────
|
|
556
|
+
const extCard = card(
|
|
557
|
+
'sec-ext',
|
|
558
|
+
'#3fb950',
|
|
559
|
+
'#163320',
|
|
560
|
+
'#0d2018',
|
|
561
|
+
'📦 External Dependencies',
|
|
562
|
+
items(
|
|
563
|
+
['@playwright/test', 'playwright-bdd', 'quicktype-core', 'prettier', 'axios', 'ts-morph', 'chalk'],
|
|
564
|
+
'#7cc97e'
|
|
565
|
+
),
|
|
566
|
+
'',
|
|
567
|
+
'Third-party npm packages that Testwise depends on. @playwright/test is the core test runner; quicktype-core generates TypeScript interfaces from JSON schemas.'
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
const cfgCard = card(
|
|
571
|
+
'sec-cfg',
|
|
572
|
+
'#4a90d9',
|
|
573
|
+
'#152238',
|
|
574
|
+
'#0d1829',
|
|
575
|
+
'⚙ Configuration',
|
|
576
|
+
rootItems(['Testwise.json', 'config.json', '.env'], '#7db3d9'),
|
|
577
|
+
'provided by consumer project',
|
|
578
|
+
'Configuration files supplied by the consuming project. Testwise.json holds base-URL and credentials; config.json holds environment-specific overrides; .env provides secrets.'
|
|
579
|
+
);
|
|
580
|
+
|
|
581
|
+
const svcCard = card(
|
|
582
|
+
'sec-svc',
|
|
583
|
+
'#9b59b6',
|
|
584
|
+
'#261040',
|
|
585
|
+
'#16082a',
|
|
586
|
+
'🔌 Services',
|
|
587
|
+
fileItems(svcList, 'services', '#c39cdf'),
|
|
588
|
+
'',
|
|
589
|
+
'Service classes used internally by the library: configuration access, API calls, and logging.'
|
|
590
|
+
);
|
|
591
|
+
|
|
592
|
+
const ifaceCard = card(
|
|
593
|
+
'sec-iface',
|
|
594
|
+
'#5a8abf',
|
|
595
|
+
'#152238',
|
|
596
|
+
'#0d1829',
|
|
597
|
+
'🔷 Interfaces',
|
|
598
|
+
fileItems(ifaceList, 'interfaces', '#7db3d9'),
|
|
599
|
+
'',
|
|
600
|
+
'TypeScript contracts (interfaces) that define the shape of subjects, properties, and components across the library.'
|
|
601
|
+
);
|
|
602
|
+
|
|
603
|
+
const typeCard = card(
|
|
604
|
+
'sec-types',
|
|
605
|
+
'#9a8a50',
|
|
606
|
+
'#2e2810',
|
|
607
|
+
'#1c1808',
|
|
608
|
+
'🏷 Types & Enums',
|
|
609
|
+
fileItemsAny(typeList, ['types', 'enums'], '#c9b96e'),
|
|
610
|
+
'',
|
|
611
|
+
'Shared TypeScript type aliases and enumerations used across components, controls, and the artifact pipeline.'
|
|
612
|
+
);
|
|
613
|
+
|
|
614
|
+
const pipeCard = card(
|
|
615
|
+
'sec-pipe',
|
|
616
|
+
'#d4910a',
|
|
617
|
+
'#2c1c0a',
|
|
618
|
+
'#1a1108',
|
|
619
|
+
'⚙ Build-Time: Artifact Generation Pipeline',
|
|
620
|
+
`<div class="pipe-layout">` +
|
|
621
|
+
`<div class="pipe-steps">${fileItems(stepList, 'artifact-builder', '#c9a558')}</div>` +
|
|
622
|
+
`<div class="stack" style="gap:10px">` +
|
|
623
|
+
subbox('Builder Helpers', '#d4910a', fileItems(pipeHList, 'artifact-builder/helpers', '#c9a558')) +
|
|
624
|
+
subbox(
|
|
625
|
+
'Templates',
|
|
626
|
+
'#a07830',
|
|
627
|
+
items(['SubjectRegistry.template.ts'], '#c9a558'),
|
|
628
|
+
'source-of-truth for generated files'
|
|
629
|
+
) +
|
|
630
|
+
`</div>` +
|
|
631
|
+
`</div>`,
|
|
632
|
+
'Input: seed-data/ → Output: test-artifacts/',
|
|
633
|
+
'Reads the Thinkwise application model via API and generates typed TypeScript subject classes and screen interfaces. Run via the testwise sync CLI command.'
|
|
634
|
+
);
|
|
635
|
+
|
|
636
|
+
const genCard = card(
|
|
637
|
+
'sec-gen',
|
|
638
|
+
'#c9c258',
|
|
639
|
+
'#28280e',
|
|
640
|
+
'#1a1a08',
|
|
641
|
+
'✨ Generated Artifacts',
|
|
642
|
+
items(['SubjectPageBase', 'screens/*.ts', 'subjects/<Name>/*.ts', 'SubjectRegistry.ts'], '#c9c258'),
|
|
643
|
+
'test-artifacts/ — never edit directly',
|
|
644
|
+
'Output of the artifact pipeline. Contains typed subject classes (one per Thinkwise subject), screen interfaces, and the SubjectRegistry. Never edit these files — they are regenerated on every sync.'
|
|
645
|
+
);
|
|
646
|
+
|
|
647
|
+
const scriptsCard = card(
|
|
648
|
+
'sec-scripts',
|
|
649
|
+
'#94a3b8',
|
|
650
|
+
'#1a1f2e',
|
|
651
|
+
'#10141e',
|
|
652
|
+
'📜 Scripts (scripts/)',
|
|
653
|
+
jsFileItems(scripts, 'scripts', '#94a3b8'),
|
|
654
|
+
'CLI entry points',
|
|
655
|
+
'Plain JavaScript CLI scripts. main.js is the testwise CLI entry; sync.js runs the full artifact pipeline; postinstall.js runs after npm install; diagram.js serves this living architecture diagram.'
|
|
656
|
+
);
|
|
657
|
+
|
|
658
|
+
const mcpCard = card(
|
|
659
|
+
'sec-mcp',
|
|
660
|
+
'#f97316',
|
|
661
|
+
'#2a1400',
|
|
662
|
+
'#180d00',
|
|
663
|
+
'🤖 AI / MCP',
|
|
664
|
+
fileItems(mcpFiles, 'mcp', '#fb923c') +
|
|
665
|
+
items(['@thinkwise/testwise-mcp', 'GitHub Copilot Agents', 'VS Code MCP integration'], '#fb923c'),
|
|
666
|
+
'',
|
|
667
|
+
'ArtifactResolver exposes generated subjects and components to AI tooling via the Model Context Protocol. The MCP server is consumed by @thinkwise/testwise-mcp and powers GitHub Copilot agent workflows.'
|
|
668
|
+
);
|
|
669
|
+
|
|
670
|
+
const devCard = card(
|
|
671
|
+
'sec-dev',
|
|
672
|
+
'#64748b',
|
|
673
|
+
'#1a1f28',
|
|
674
|
+
'#10141a',
|
|
675
|
+
'🛠 Dev Tooling',
|
|
676
|
+
items(['ESLint', 'Biome (format/lint)', 'vitest (unit tests)', 'ts-morph', 'prettier'], '#94a3b8') +
|
|
677
|
+
`<div style="margin-top:6px">${items(['tests/ (mirrors src structure)', 'coverage/'], '#64748b')}</div>`,
|
|
678
|
+
'npm test · npm run format:fix',
|
|
679
|
+
'Development quality tools. ESLint and Biome handle linting and formatting. Vitest runs unit tests (no browser required). Tests live in tests/ and mirror the source structure.'
|
|
680
|
+
);
|
|
681
|
+
|
|
682
|
+
const fixCard = card(
|
|
683
|
+
'sec-fix',
|
|
684
|
+
'#4a90d9',
|
|
685
|
+
'#152238',
|
|
686
|
+
'#0d1829',
|
|
687
|
+
'⛓ Fixture Chain (Testwise.ts)',
|
|
688
|
+
items(
|
|
689
|
+
[
|
|
690
|
+
'1. @playwright/test base',
|
|
691
|
+
'2. playwright-bdd',
|
|
692
|
+
'3. ClickOverride',
|
|
693
|
+
'4. FillOverride',
|
|
694
|
+
'5. GlobalComponents',
|
|
695
|
+
'6. GoToDeepLink',
|
|
696
|
+
'7. LoginFeatures',
|
|
697
|
+
'8. UserSimulation',
|
|
698
|
+
'9. WaitEventHandler',
|
|
699
|
+
'10. SubjectRegistry',
|
|
700
|
+
'↓ export: test'
|
|
701
|
+
],
|
|
702
|
+
'#7db3d9'
|
|
703
|
+
),
|
|
704
|
+
'',
|
|
705
|
+
'The ordered chain of Playwright test.extend() calls that compose the final test fixture exported by this library. Each layer adds capabilities to the page object. playwright-bdd adds BDD step support.'
|
|
706
|
+
);
|
|
707
|
+
|
|
708
|
+
const consCard = card(
|
|
709
|
+
'sec-cons',
|
|
710
|
+
'#58a6ff',
|
|
711
|
+
'#0d1828',
|
|
712
|
+
'#08101a',
|
|
713
|
+
'🧪 Consumer Test Project',
|
|
714
|
+
`<pre class="code-block">${esc("import { test } from '@thinkwise/testwise'\n\ntest('scenario', async ({ page }) => {\n const emp = await\n page.subjects\n .Employee.goTo();\n // use components ...\n});")}</pre>`,
|
|
715
|
+
'',
|
|
716
|
+
'How a consuming project uses Testwise. Import the test fixture, access generated subjects via page.subjects, and interact with components using typed methods.'
|
|
717
|
+
);
|
|
718
|
+
|
|
719
|
+
const compCard = card(
|
|
720
|
+
'sec-comp',
|
|
721
|
+
'#4a90d9',
|
|
722
|
+
'#152238',
|
|
723
|
+
'#0d1829',
|
|
724
|
+
'🧩 Components (components/)',
|
|
725
|
+
`<div class="comp-row">` +
|
|
726
|
+
subbox('Subject Components', '#4a90d9', compItems(components.subject, '#7db3d9'), 'typed field/col access') +
|
|
727
|
+
subbox('Screen Components', '#4a90d9', `<div class="two-col">${compItems(components.screen, '#8b949e')}</div>`) +
|
|
728
|
+
subbox('Overlay / Global', '#5a8abf', `<div class="two-col">${compItems(components.overlay, '#8b949e')}</div>`) +
|
|
729
|
+
`</div>`,
|
|
730
|
+
'Each: <Name>.ts (public API) + <Name>Objects.ts (locators) · extends BaseComponentObjects',
|
|
731
|
+
'Typed wrappers around every Thinkwise UI control. Subject Components (Form, Grid) expose typed field and column accessors. Screen Components handle navigation and layout elements. Overlay/Global components handle pop-ups, menus, and toasts.'
|
|
732
|
+
);
|
|
733
|
+
|
|
734
|
+
const ctrlCard = card(
|
|
735
|
+
'sec-ctrl',
|
|
736
|
+
'#9b59b6',
|
|
737
|
+
'#221042',
|
|
738
|
+
'#14082a',
|
|
739
|
+
'🎛 Controls (controls/)',
|
|
740
|
+
fileItems(ctrlList, 'controls', '#c39cdf'),
|
|
741
|
+
'Proxy: Locator + typed methods',
|
|
742
|
+
'Fine-grained input helpers. Each wraps a specific Thinkwise form field type and exposes typed methods. Acts as both a Playwright Locator and a typed control via Proxy.'
|
|
743
|
+
);
|
|
744
|
+
|
|
745
|
+
const helpCard = card(
|
|
746
|
+
'sec-help',
|
|
747
|
+
'#27ae60',
|
|
748
|
+
'#102810',
|
|
749
|
+
'#081808',
|
|
750
|
+
'🛠 Helpers (helpers/)',
|
|
751
|
+
fileItems(helpList, 'helpers', '#7cc98a'),
|
|
752
|
+
'',
|
|
753
|
+
'Standalone utility classes used by test code and page extensions. Handle navigation, waiting, assertions, and user simulation.'
|
|
754
|
+
);
|
|
755
|
+
|
|
756
|
+
const ovrCard = card(
|
|
757
|
+
'sec-ovr',
|
|
758
|
+
'#c0392b',
|
|
759
|
+
'#2e1010',
|
|
760
|
+
'#1a0808',
|
|
761
|
+
'🔧 Page Overrides',
|
|
762
|
+
fileItems(ovrList, 'page-overrides', '#e07070'),
|
|
763
|
+
'patch Playwright timing for Thinkwise',
|
|
764
|
+
"Low-level patches that override Playwright's native click() and fill() to inject the extra waits that Thinkwise's event-driven UI requires."
|
|
765
|
+
);
|
|
766
|
+
|
|
767
|
+
const extPageCard = card(
|
|
768
|
+
'sec-pext',
|
|
769
|
+
'#16a085',
|
|
770
|
+
'#102828',
|
|
771
|
+
'#081818',
|
|
772
|
+
'🔌 Page Extensions',
|
|
773
|
+
fileItems(extList, 'page-extensions', '#60c0b0'),
|
|
774
|
+
'extend page fixture with typed methods',
|
|
775
|
+
"Each class calls test.extend() to bolt a new property or method onto Playwright's page fixture — e.g. page.login(), page.subjects, page.topbar."
|
|
776
|
+
);
|
|
777
|
+
|
|
778
|
+
// ── Full HTML ─────────────────────────────────────────────────────────────
|
|
779
|
+
return `<!DOCTYPE html>
|
|
780
|
+
<html lang="en">
|
|
781
|
+
<head>
|
|
782
|
+
<meta charset="UTF-8"/>
|
|
783
|
+
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
784
|
+
<title>Testwise Architecture</title>
|
|
785
|
+
<script src="https://html2canvas.hertzen.com/dist/html2canvas.min.js"></script>
|
|
786
|
+
<style>
|
|
787
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
788
|
+
|
|
789
|
+
body {
|
|
790
|
+
background: #0d1117;
|
|
791
|
+
color: #e6edf3;
|
|
792
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
793
|
+
overflow: hidden;
|
|
794
|
+
height: 100vh;
|
|
795
|
+
display: flex;
|
|
796
|
+
flex-direction: column;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/* ── Header ── */
|
|
800
|
+
header {
|
|
801
|
+
padding: 8px 16px;
|
|
802
|
+
background: #161b22;
|
|
803
|
+
border-bottom: 1px solid #30363d;
|
|
804
|
+
display: flex;
|
|
805
|
+
align-items: center;
|
|
806
|
+
gap: 12px;
|
|
807
|
+
flex-shrink: 0;
|
|
808
|
+
}
|
|
809
|
+
header h1 { font-size: 0.88rem; font-weight: 700; color: #58a6ff; }
|
|
810
|
+
.meta { font-size: 0.68rem; color: #8b949e; }
|
|
811
|
+
.hbtn {
|
|
812
|
+
padding: 4px 12px;
|
|
813
|
+
border: none;
|
|
814
|
+
border-radius: 5px;
|
|
815
|
+
color: #fff;
|
|
816
|
+
font-size: 0.75rem;
|
|
817
|
+
cursor: pointer;
|
|
818
|
+
transition: filter 0.15s;
|
|
819
|
+
}
|
|
820
|
+
.hbtn:hover { filter: brightness(1.15); }
|
|
821
|
+
.hbtn:disabled { opacity: 0.5; cursor: wait; }
|
|
822
|
+
#export-btn { background: #1f6feb; }
|
|
823
|
+
#reload-btn { background: #238636; }
|
|
824
|
+
#recenter-btn { background: #6e40c9; display: none; }
|
|
825
|
+
#clear-sel-btn { background: #6e40c9; display: none; }
|
|
826
|
+
|
|
827
|
+
/* ── Viewport / zoom ── */
|
|
828
|
+
#viewport {
|
|
829
|
+
flex: 1;
|
|
830
|
+
overflow: hidden;
|
|
831
|
+
position: relative;
|
|
832
|
+
cursor: grab;
|
|
833
|
+
}
|
|
834
|
+
#viewport.dragging { cursor: grabbing; }
|
|
835
|
+
|
|
836
|
+
#diagram {
|
|
837
|
+
position: absolute;
|
|
838
|
+
top: 0; left: 0;
|
|
839
|
+
transform-origin: 0 0;
|
|
840
|
+
padding: 40px 36px;
|
|
841
|
+
display: flex;
|
|
842
|
+
flex-direction: column;
|
|
843
|
+
gap: 32px;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
.top-row, .bot-row {
|
|
847
|
+
display: flex;
|
|
848
|
+
flex-direction: row;
|
|
849
|
+
gap: 28px;
|
|
850
|
+
align-items: flex-start;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
.stack {
|
|
854
|
+
display: flex;
|
|
855
|
+
flex-direction: column;
|
|
856
|
+
gap: 24px;
|
|
857
|
+
flex-shrink: 0;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/* ── Cards ── */
|
|
861
|
+
.card {
|
|
862
|
+
background: var(--cb, #161b22);
|
|
863
|
+
border: 1.5px solid var(--ca, #30363d);
|
|
864
|
+
border-radius: 7px;
|
|
865
|
+
display: flex;
|
|
866
|
+
flex-direction: column;
|
|
867
|
+
flex-shrink: 0;
|
|
868
|
+
min-width: 150px;
|
|
869
|
+
position: relative;
|
|
870
|
+
z-index: 1;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
.card-head {
|
|
874
|
+
background: var(--ct, #1e2a3a);
|
|
875
|
+
color: var(--ca, #e6edf3);
|
|
876
|
+
font-size: 10.5px;
|
|
877
|
+
font-weight: 700;
|
|
878
|
+
padding: 7px 11px;
|
|
879
|
+
border-radius: 5px 5px 0 0;
|
|
880
|
+
border-bottom: 1px solid var(--ca);
|
|
881
|
+
white-space: nowrap;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
.card-body {
|
|
885
|
+
padding: 9px 11px;
|
|
886
|
+
flex: 1;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
.card-foot {
|
|
890
|
+
font-size: 9px;
|
|
891
|
+
color: #6e8098;
|
|
892
|
+
font-style: italic;
|
|
893
|
+
padding: 5px 11px 8px;
|
|
894
|
+
border-top: 1px solid rgba(255,255,255,0.06);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/* ── Items ── */
|
|
898
|
+
.item {
|
|
899
|
+
font-family: ui-monospace, 'Cascadia Code', Consolas, monospace;
|
|
900
|
+
font-size: 10.5px;
|
|
901
|
+
padding: 2px 0;
|
|
902
|
+
white-space: nowrap;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/* ── Sub-boxes ── */
|
|
906
|
+
.subbox {
|
|
907
|
+
border: 1px dashed var(--sa, #555);
|
|
908
|
+
border-radius: 5px;
|
|
909
|
+
margin: 4px 0;
|
|
910
|
+
}
|
|
911
|
+
.subbox-head {
|
|
912
|
+
font-size: 9.5px;
|
|
913
|
+
font-weight: 600;
|
|
914
|
+
color: var(--sa);
|
|
915
|
+
padding: 4px 8px;
|
|
916
|
+
border-bottom: 1px dashed var(--sa);
|
|
917
|
+
}
|
|
918
|
+
.subbox-body { padding: 6px 8px; }
|
|
919
|
+
.subbox-foot {
|
|
920
|
+
font-size: 9px;
|
|
921
|
+
color: #6e8098;
|
|
922
|
+
font-style: italic;
|
|
923
|
+
padding: 3px 8px 6px;
|
|
924
|
+
border-top: 1px dashed rgba(255,255,255,0.07);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/* ── Pipeline ── */
|
|
928
|
+
.pipe-layout {
|
|
929
|
+
display: flex;
|
|
930
|
+
gap: 16px;
|
|
931
|
+
align-items: flex-start;
|
|
932
|
+
}
|
|
933
|
+
.pipe-steps {
|
|
934
|
+
display: grid;
|
|
935
|
+
grid-template-columns: 1fr 1fr;
|
|
936
|
+
column-gap: 20px;
|
|
937
|
+
flex-shrink: 0;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/* ── Components ── */
|
|
941
|
+
.comp-row {
|
|
942
|
+
display: flex;
|
|
943
|
+
gap: 14px;
|
|
944
|
+
align-items: flex-start;
|
|
945
|
+
}
|
|
946
|
+
.comp-row .subbox { flex-shrink: 0; }
|
|
947
|
+
.two-col {
|
|
948
|
+
display: grid;
|
|
949
|
+
grid-template-columns: 1fr 1fr;
|
|
950
|
+
column-gap: 12px;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/* ── Code block ── */
|
|
954
|
+
.code-block {
|
|
955
|
+
font-family: ui-monospace, 'Cascadia Code', Consolas, monospace;
|
|
956
|
+
font-size: 10px;
|
|
957
|
+
color: #79c0ff;
|
|
958
|
+
line-height: 1.6;
|
|
959
|
+
white-space: pre;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/* ── Tabs ── */
|
|
963
|
+
#tab-bar {
|
|
964
|
+
display: flex;
|
|
965
|
+
gap: 2px;
|
|
966
|
+
background: #161b22;
|
|
967
|
+
border-bottom: 1px solid #30363d;
|
|
968
|
+
padding: 0 12px;
|
|
969
|
+
flex-shrink: 0;
|
|
970
|
+
}
|
|
971
|
+
.tab-btn {
|
|
972
|
+
padding: 6px 16px;
|
|
973
|
+
border: none;
|
|
974
|
+
background: transparent;
|
|
975
|
+
color: #8b949e;
|
|
976
|
+
font-size: 0.78rem;
|
|
977
|
+
cursor: pointer;
|
|
978
|
+
border-bottom: 2px solid transparent;
|
|
979
|
+
margin-bottom: -1px;
|
|
980
|
+
transition: color 0.15s, border-color 0.15s;
|
|
981
|
+
}
|
|
982
|
+
.tab-btn:hover { color: #e6edf3; }
|
|
983
|
+
.tab-btn.active { color: #58a6ff; border-bottom-color: #58a6ff; }
|
|
984
|
+
|
|
985
|
+
.tab-panel { display: none; flex: 1; overflow: hidden; position: relative; }
|
|
986
|
+
.tab-panel.active { display: flex; flex-direction: column; }
|
|
987
|
+
|
|
988
|
+
/* ── Tooltip ── */
|
|
989
|
+
#tw-tooltip {
|
|
990
|
+
position: fixed;
|
|
991
|
+
z-index: 9999;
|
|
992
|
+
pointer-events: none;
|
|
993
|
+
background: #1c2333;
|
|
994
|
+
border: 1px solid #388bfd;
|
|
995
|
+
border-radius: 6px;
|
|
996
|
+
padding: 8px 12px;
|
|
997
|
+
max-width: 520px;
|
|
998
|
+
font-size: 11px;
|
|
999
|
+
line-height: 1.55;
|
|
1000
|
+
color: #e6edf3;
|
|
1001
|
+
box-shadow: 0 4px 18px rgba(0,0,0,0.5);
|
|
1002
|
+
display: none;
|
|
1003
|
+
white-space: pre-wrap;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/* ── File item hover ── */
|
|
1007
|
+
.file-item { cursor: pointer; border-radius: 3px; padding: 2px 3px; margin: 0 -3px; text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 3px; }
|
|
1008
|
+
.file-item:hover { background: rgba(88,166,255,0.12); text-decoration-style: solid; }
|
|
1009
|
+
.card-head[data-tip] { cursor: help; }
|
|
1010
|
+
.card-head[data-tip]:hover { filter: brightness(1.12); }
|
|
1011
|
+
|
|
1012
|
+
/* ── D3 graph ── */
|
|
1013
|
+
#deps-panel {
|
|
1014
|
+
width: 0; flex-shrink: 0; overflow: hidden;
|
|
1015
|
+
background: #161b22; border-left: 1px solid #30363d;
|
|
1016
|
+
padding: 0; display: flex; flex-direction: column; gap: 6px;
|
|
1017
|
+
font-size: 11px; line-height: 1.55; color: #e6edf3;
|
|
1018
|
+
opacity: 0; pointer-events: none;
|
|
1019
|
+
transition: opacity 0.2s ease, width 0.2s ease, padding 0.2s ease;
|
|
1020
|
+
}
|
|
1021
|
+
#deps-panel.visible { width: 240px; padding: 14px 12px; padding-top: 18px; opacity: 1; pointer-events: auto; overflow-y: auto; }
|
|
1022
|
+
#deps-panel .dp-name { font-weight: 700; font-size: 13px; color: #58a6ff; margin-bottom: 4px; }
|
|
1023
|
+
#deps-panel .dp-meta { color: #8b949e; font-size: 10px; margin-bottom: 8px; }
|
|
1024
|
+
#deps-panel .dp-jsdoc { color: #c9d1d9; white-space: pre-wrap; margin-bottom: 8px; }
|
|
1025
|
+
#deps-panel .dp-connected { color: #8b949e; white-space: pre-wrap; font-size: 10px; }
|
|
1026
|
+
#deps-layout { display: flex; flex: 1; overflow: hidden; }
|
|
1027
|
+
#deps-legend {
|
|
1028
|
+
width: 180px; flex-shrink: 0;
|
|
1029
|
+
background: #161b22; border-right: 1px solid #30363d;
|
|
1030
|
+
padding: 0; overflow: hidden;
|
|
1031
|
+
display: flex; flex-direction: column;
|
|
1032
|
+
}
|
|
1033
|
+
#deps-legend .leg-title {
|
|
1034
|
+
font-size: 10px; font-weight: 600; color: #8b949e;
|
|
1035
|
+
text-transform: uppercase; letter-spacing: 0.06em;
|
|
1036
|
+
padding: 8px 10px 6px; flex-shrink: 0;
|
|
1037
|
+
border-bottom: 1px solid #30363d;
|
|
1038
|
+
}
|
|
1039
|
+
#deps-legend-rows { padding: 6px 10px; display: flex; flex-direction: column; gap: 2px; overflow-y: auto; flex: 1; min-height: 0; }
|
|
1040
|
+
#deps-legend .leg-row {
|
|
1041
|
+
display: flex; align-items: center; gap: 8px;
|
|
1042
|
+
padding: 5px 6px; border-radius: 5px;
|
|
1043
|
+
cursor: pointer; user-select: none;
|
|
1044
|
+
}
|
|
1045
|
+
#deps-legend .leg-row:hover { background: rgba(255,255,255,0.07); }
|
|
1046
|
+
#deps-legend .leg-row:hover .leg-label { color: #ffffff; }
|
|
1047
|
+
#deps-legend .leg-row.leg-selected { background: rgba(255,255,255,0.12); }
|
|
1048
|
+
#deps-legend .leg-row.leg-selected .leg-label { color: #ffffff; font-weight: 700; }
|
|
1049
|
+
#deps-panel .dp-open-btn { margin-top: 12px; padding: 6px 10px; border: 1px solid #388bfd; background: transparent; color: #58a6ff; border-radius: 5px; font-size: 11px; cursor: pointer; width: 100%; }
|
|
1050
|
+
#deps-panel .dp-open-btn:hover { background: rgba(56,139,253,0.15); }
|
|
1051
|
+
#deps-legend .leg-dot {
|
|
1052
|
+
width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0;
|
|
1053
|
+
border: 1.5px solid transparent;
|
|
1054
|
+
}
|
|
1055
|
+
#deps-legend .leg-label { font-size: 13px; color: #c9d1d9; font-family: system-ui; }
|
|
1056
|
+
#deps-search-wrap {
|
|
1057
|
+
padding: 10px 12px; flex-shrink: 0;
|
|
1058
|
+
border-bottom: 1px solid #30363d;
|
|
1059
|
+
}
|
|
1060
|
+
#deps-search {
|
|
1061
|
+
width: 100%; padding: 6px 8px; border-radius: 5px;
|
|
1062
|
+
border: 1px solid #444c56; background: #2d333b; color: #e6edf3;
|
|
1063
|
+
font-size: 11px; outline: none; display: block; box-sizing: border-box;
|
|
1064
|
+
}
|
|
1065
|
+
#deps-search::placeholder { color: #768390; }
|
|
1066
|
+
#deps-search:focus { border-color: #388bfd; background: #353d47; }
|
|
1067
|
+
#deps-search:focus { border-color: #388bfd; }
|
|
1068
|
+
#deps-search-list {
|
|
1069
|
+
display: none; position: absolute; left: 0; right: 0; top: 100%;
|
|
1070
|
+
z-index: 200;
|
|
1071
|
+
background: #161b22; border: 1px solid #388bfd; border-radius: 5px;
|
|
1072
|
+
max-height: 220px; overflow-y: auto;
|
|
1073
|
+
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
|
|
1074
|
+
}
|
|
1075
|
+
#deps-search-list .sl-item {
|
|
1076
|
+
padding: 6px 10px; font-size: 11px; font-family: ui-monospace, monospace;
|
|
1077
|
+
cursor: pointer; color: #e6edf3; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
1078
|
+
}
|
|
1079
|
+
#deps-search-list .sl-item:hover, #deps-search-list .sl-item.sl-active { background: rgba(56,139,253,0.2); }
|
|
1080
|
+
#deps-search-list .sl-item .sl-group { font-size: 9px; color: #8b949e; margin-left: 6px; }
|
|
1081
|
+
#deps-viewport { flex: 1; min-width: 0; position: relative; overflow: hidden; }
|
|
1082
|
+
#deps-viewport.dragging { cursor: grabbing; }
|
|
1083
|
+
#deps-svg { width: 100%; height: 100%; opacity: 0; transition: opacity 0.4s ease; }
|
|
1084
|
+
#deps-svg.settled { opacity: 1; }
|
|
1085
|
+
#deps-loading {
|
|
1086
|
+
position: absolute; inset: 0; display: flex; flex-direction: column;
|
|
1087
|
+
align-items: center; justify-content: center; gap: 12px;
|
|
1088
|
+
color: #8b949e; font-size: 0.8rem; pointer-events: none;
|
|
1089
|
+
}
|
|
1090
|
+
#deps-loading.hidden { display: none; }
|
|
1091
|
+
.deps-spinner {
|
|
1092
|
+
width: 32px; height: 32px; border-radius: 50%;
|
|
1093
|
+
border: 3px solid #30363d; border-top-color: #58a6ff;
|
|
1094
|
+
animation: spin 0.7s linear infinite;
|
|
1095
|
+
}
|
|
1096
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
1097
|
+
.d3-node circle { stroke-width: 1.5; cursor: pointer; transition: fill 0.25s, stroke-width 0.25s, opacity 0.25s; }
|
|
1098
|
+
.d3-node circle:hover { stroke-width: 3; filter: brightness(1.3); }
|
|
1099
|
+
.d3-node text { font-size: 9.5px; fill: #e6edf3; pointer-events: none; font-family: ui-monospace, monospace; transition: opacity 0.25s; }
|
|
1100
|
+
.d3-link { stroke-opacity: 0.45; fill: none; }
|
|
1101
|
+
</style>
|
|
1102
|
+
</head>
|
|
1103
|
+
<body>
|
|
1104
|
+
<header>
|
|
1105
|
+
<h1>⬡ Testwise Architecture</h1>
|
|
1106
|
+
<div class="meta">Generated ${esc(generatedAt)} · Refresh to update from source</div>
|
|
1107
|
+
</header>
|
|
1108
|
+
|
|
1109
|
+
<div id="tab-bar">
|
|
1110
|
+
<button class="tab-btn active" onclick="switchTab('arch')">Architecture</button>
|
|
1111
|
+
<button class="tab-btn" onclick="switchTab('deps')">Dependencies</button>
|
|
1112
|
+
<div style="margin-left:auto;display:flex;gap:8px;align-items:center;padding:10px 8px 10px 0">
|
|
1113
|
+
<button id="recenter-btn" class="hbtn" onclick="recenter()">⊙ Recenter</button>
|
|
1114
|
+
<button id="clear-sel-btn" class="hbtn" onclick="clearSelection()">✕ Clear Selection</button>
|
|
1115
|
+
<button id="export-btn" class="hbtn" onclick="exportPng()">⬇ Export PNG</button>
|
|
1116
|
+
<button id="reload-btn" class="hbtn" onclick="location.reload()">↺ Refresh</button>
|
|
1117
|
+
</div>
|
|
1118
|
+
</div>
|
|
1119
|
+
|
|
1120
|
+
<!-- Architecture tab -->
|
|
1121
|
+
<div id="tab-arch" class="tab-panel active">
|
|
1122
|
+
<div id="viewport">
|
|
1123
|
+
<div id="diagram">
|
|
1124
|
+
<div class="top-row">
|
|
1125
|
+
<div class="stack">
|
|
1126
|
+
${extCard}
|
|
1127
|
+
${cfgCard}
|
|
1128
|
+
${svcCard}
|
|
1129
|
+
</div>
|
|
1130
|
+
<div class="stack">
|
|
1131
|
+
${ifaceCard}
|
|
1132
|
+
${typeCard}
|
|
1133
|
+
</div>
|
|
1134
|
+
${pipeCard}
|
|
1135
|
+
${genCard}
|
|
1136
|
+
${fixCard}
|
|
1137
|
+
${consCard}
|
|
1138
|
+
<div class="stack">
|
|
1139
|
+
${scriptsCard}
|
|
1140
|
+
${mcpCard}
|
|
1141
|
+
</div>
|
|
1142
|
+
</div>
|
|
1143
|
+
<div class="bot-row">
|
|
1144
|
+
${compCard}
|
|
1145
|
+
${ctrlCard}
|
|
1146
|
+
${helpCard}
|
|
1147
|
+
<div class="stack">
|
|
1148
|
+
${ovrCard}
|
|
1149
|
+
${extPageCard}
|
|
1150
|
+
</div>
|
|
1151
|
+
${devCard}
|
|
1152
|
+
</div>
|
|
1153
|
+
</div>
|
|
1154
|
+
</div>
|
|
1155
|
+
</div>
|
|
1156
|
+
|
|
1157
|
+
<!-- Dependencies tab -->
|
|
1158
|
+
<div id="tab-deps" class="tab-panel">
|
|
1159
|
+
<div id="deps-layout">
|
|
1160
|
+
<div id="deps-legend">
|
|
1161
|
+
<div id="deps-search-wrap">
|
|
1162
|
+
<div style="position:relative">
|
|
1163
|
+
<input id="deps-search" type="text" placeholder="Search nodes..." autocomplete="new-password" spellcheck="false" autocorrect="off" autocapitalize="off" data-form-type="other"/>
|
|
1164
|
+
<div id="deps-search-list"></div>
|
|
1165
|
+
</div>
|
|
1166
|
+
</div>
|
|
1167
|
+
<div class="leg-title">Groups</div>
|
|
1168
|
+
<div id="deps-legend-rows"></div>
|
|
1169
|
+
</div>
|
|
1170
|
+
<div id="deps-viewport">
|
|
1171
|
+
<svg id="deps-svg"></svg>
|
|
1172
|
+
<div id="deps-loading"><div class="deps-spinner"></div><span>Settling layout\u2026</span></div>
|
|
1173
|
+
</div>
|
|
1174
|
+
<div id="deps-panel"></div>
|
|
1175
|
+
</div>
|
|
1176
|
+
</div>
|
|
1177
|
+
|
|
1178
|
+
<!-- Shared tooltip -->
|
|
1179
|
+
<div id="tw-tooltip"></div>
|
|
1180
|
+
|
|
1181
|
+
<script src="https://d3js.org/d3.v7.min.js"></script>
|
|
1182
|
+
<script>
|
|
1183
|
+
// ── Shared data (loaded async from /api/data to avoid any escaping issues) ──
|
|
1184
|
+
let ROOT_PATH = '';
|
|
1185
|
+
let TIPS = {};
|
|
1186
|
+
let GRAPH_NODES = [];
|
|
1187
|
+
let GRAPH_LINKS = [];
|
|
1188
|
+
|
|
1189
|
+
let selectedNode = null;
|
|
1190
|
+
let selectedLegendGroup = null;
|
|
1191
|
+
let selectedLegendRow = null;
|
|
1192
|
+
let archIsCentered = true;
|
|
1193
|
+
let depsIsCentered = false;
|
|
1194
|
+
let _applyNodeHighlight = null;
|
|
1195
|
+
let _legColors = {};
|
|
1196
|
+
|
|
1197
|
+
function updateButtons() {
|
|
1198
|
+
const archActive = document.getElementById('tab-arch').classList.contains('active');
|
|
1199
|
+
const recenterBtn = document.getElementById('recenter-btn');
|
|
1200
|
+
const clearSelBtn = document.getElementById('clear-sel-btn');
|
|
1201
|
+
if (archActive) {
|
|
1202
|
+
recenterBtn.style.display = archIsCentered ? 'none' : 'inline-block';
|
|
1203
|
+
clearSelBtn.style.display = 'none';
|
|
1204
|
+
} else {
|
|
1205
|
+
if (selectedNode || selectedLegendGroup) {
|
|
1206
|
+
recenterBtn.style.display = 'none';
|
|
1207
|
+
clearSelBtn.style.display = 'inline-block';
|
|
1208
|
+
} else {
|
|
1209
|
+
recenterBtn.style.display = depsIsCentered ? 'none' : 'inline-block';
|
|
1210
|
+
clearSelBtn.style.display = 'none';
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
function openInVscode(filePath) {
|
|
1216
|
+
window.location.href = 'vscode://file/' + filePath.replace(/\\\\/g, '/');
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
function clearSelection() {
|
|
1220
|
+
selectedNode = null;
|
|
1221
|
+
if (selectedLegendRow) {
|
|
1222
|
+
selectedLegendRow.classList.remove('leg-selected');
|
|
1223
|
+
selectedLegendRow.querySelector('.leg-dot').style.background = _legColors[selectedLegendGroup] + '55';
|
|
1224
|
+
selectedLegendGroup = null;
|
|
1225
|
+
selectedLegendRow = null;
|
|
1226
|
+
}
|
|
1227
|
+
if (_applyNodeHighlight) _applyNodeHighlight(null);
|
|
1228
|
+
document.getElementById('deps-panel').classList.remove('visible');
|
|
1229
|
+
recenter();
|
|
1230
|
+
updateButtons();
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
fetch('/api/data')
|
|
1234
|
+
.then(r => r.json())
|
|
1235
|
+
.then(data => {
|
|
1236
|
+
ROOT_PATH = data.rootPath;
|
|
1237
|
+
TIPS = data.tips;
|
|
1238
|
+
GRAPH_NODES = data.graphNodes;
|
|
1239
|
+
GRAPH_LINKS = data.graphLinks;
|
|
1240
|
+
})
|
|
1241
|
+
.catch(err => console.error('Failed to load diagram data:', err));
|
|
1242
|
+
|
|
1243
|
+
// ── Tab switching ─────────────────────────────────────────────────────
|
|
1244
|
+
function switchTab(name) {
|
|
1245
|
+
document.querySelectorAll('.tab-btn').forEach((b, i) => {
|
|
1246
|
+
const id = ['arch','deps'][i];
|
|
1247
|
+
b.classList.toggle('active', id === name);
|
|
1248
|
+
});
|
|
1249
|
+
document.querySelectorAll('.tab-panel').forEach(p => {
|
|
1250
|
+
p.classList.toggle('active', p.id === 'tab-' + name);
|
|
1251
|
+
});
|
|
1252
|
+
localStorage.setItem('tw-active-tab', name);
|
|
1253
|
+
updateButtons();
|
|
1254
|
+
if (name === 'arch') {
|
|
1255
|
+
// fitToScreen recalculates scale and calls drawArrows — more reliable than rAF alone
|
|
1256
|
+
requestAnimationFrame(() => requestAnimationFrame(fitToScreen));
|
|
1257
|
+
} else if (name === 'deps') {
|
|
1258
|
+
// Data may still be loading; wait for it then init
|
|
1259
|
+
if (GRAPH_NODES.length > 0) {
|
|
1260
|
+
initDepsGraph();
|
|
1261
|
+
} else {
|
|
1262
|
+
fetch('/api/data').then(r => r.json()).then(data => {
|
|
1263
|
+
ROOT_PATH = data.rootPath; TIPS = data.tips;
|
|
1264
|
+
GRAPH_NODES = data.graphNodes; GRAPH_LINKS = data.graphLinks;
|
|
1265
|
+
initDepsGraph();
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
// Restore last active tab on load
|
|
1272
|
+
const _savedTab = localStorage.getItem('tw-active-tab');
|
|
1273
|
+
if (_savedTab && _savedTab !== 'arch') {
|
|
1274
|
+
switchTab(_savedTab);
|
|
1275
|
+
} else {
|
|
1276
|
+
// Arch tab is active — fit and draw arrows after layout is fully painted
|
|
1277
|
+
requestAnimationFrame(() => requestAnimationFrame(fitToScreen));
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// ── Tooltip ───────────────────────────────────────────────────────────
|
|
1281
|
+
const tooltip = document.getElementById('tw-tooltip');
|
|
1282
|
+
function showTip(text, e) {
|
|
1283
|
+
tooltip.textContent = text;
|
|
1284
|
+
tooltip.style.display = 'block';
|
|
1285
|
+
moveTip(e);
|
|
1286
|
+
}
|
|
1287
|
+
function moveTip(e) {
|
|
1288
|
+
const pad = 14, margin = 8;
|
|
1289
|
+
const tw = tooltip.offsetWidth, th = tooltip.offsetHeight;
|
|
1290
|
+
const vw = window.innerWidth, vh = window.innerHeight;
|
|
1291
|
+
// Prefer below-right; flip left if not enough room on right; flip above if not enough room below
|
|
1292
|
+
let x = e.clientX + pad;
|
|
1293
|
+
let y = e.clientY + pad;
|
|
1294
|
+
if (x + tw > vw - margin) x = e.clientX - tw - pad;
|
|
1295
|
+
if (y + th > vh - margin) y = e.clientY - th - pad;
|
|
1296
|
+
// Clamp so tooltip never goes off the left or top edge
|
|
1297
|
+
if (x < margin) x = margin;
|
|
1298
|
+
if (y < margin) y = margin;
|
|
1299
|
+
tooltip.style.left = x + 'px';
|
|
1300
|
+
tooltip.style.top = y + 'px';
|
|
1301
|
+
}
|
|
1302
|
+
function hideTip() { tooltip.style.display = 'none'; }
|
|
1303
|
+
|
|
1304
|
+
document.body.addEventListener('mouseover', e => {
|
|
1305
|
+
// Don't interfere with D3 node tooltips in the deps graph
|
|
1306
|
+
if (e.target.closest('#deps-svg')) return;
|
|
1307
|
+
const el = e.target.closest('[data-tip]');
|
|
1308
|
+
if (el) showTip(el.dataset.tip, e);
|
|
1309
|
+
else hideTip();
|
|
1310
|
+
});
|
|
1311
|
+
document.body.addEventListener('mousemove', e => {
|
|
1312
|
+
if (tooltip.style.display !== 'none') moveTip(e);
|
|
1313
|
+
});
|
|
1314
|
+
document.body.addEventListener('mouseout', e => {
|
|
1315
|
+
if (e.target.closest('#deps-svg')) return;
|
|
1316
|
+
if (!e.relatedTarget || !e.relatedTarget.closest('[data-tip]')) hideTip();
|
|
1317
|
+
});
|
|
1318
|
+
|
|
1319
|
+
// ── Click file to open in VS Code ─────────────────────────────────────
|
|
1320
|
+
document.body.addEventListener('click', e => {
|
|
1321
|
+
const el = e.target.closest('.file-item[data-path]');
|
|
1322
|
+
if (!el) return;
|
|
1323
|
+
// normalise: forward-slashes → OS-native via URL encoding
|
|
1324
|
+
const raw = el.dataset.path;
|
|
1325
|
+
const uri = 'vscode://file/' + raw.replace(/\\\\/g, '/');
|
|
1326
|
+
window.location.href = uri;
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
// ── Architecture viewport ─────────────────────────────────────────────
|
|
1330
|
+
const viewport = document.getElementById('viewport');
|
|
1331
|
+
const diagram = document.getElementById('diagram');
|
|
1332
|
+
let scale = 1, tx = 0, ty = 0;
|
|
1333
|
+
let dragging = false, dragStartX = 0, dragStartY = 0, startTx = 0, startTy = 0;
|
|
1334
|
+
|
|
1335
|
+
function applyTransform() {
|
|
1336
|
+
diagram.style.transform = \`translate(\${tx}px,\${ty}px) scale(\${scale})\`;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
function fitToScreen() {
|
|
1340
|
+
const vw = viewport.clientWidth, vh = viewport.clientHeight;
|
|
1341
|
+
const dw = diagram.offsetWidth, dh = diagram.offsetHeight;
|
|
1342
|
+
scale = Math.min(vw / dw, vh / dh) * 0.92;
|
|
1343
|
+
tx = (vw - dw * scale) / 2;
|
|
1344
|
+
ty = Math.max((vh - dh * scale) / 2, 16);
|
|
1345
|
+
applyTransform();
|
|
1346
|
+
archIsCentered = true;
|
|
1347
|
+
updateButtons();
|
|
1348
|
+
requestAnimationFrame(drawArrows);
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
function recenter() {
|
|
1352
|
+
const archActive = document.getElementById('tab-arch').classList.contains('active');
|
|
1353
|
+
if (archActive) {
|
|
1354
|
+
fitToScreen();
|
|
1355
|
+
} else if (GRAPH_NODES.length > 0 && zoomBehaviour) {
|
|
1356
|
+
// Fit deps graph to view
|
|
1357
|
+
const svgEl = document.getElementById('deps-svg');
|
|
1358
|
+
const W = svgEl.clientWidth || 1200, H = svgEl.clientHeight || 700;
|
|
1359
|
+
const pad = 40;
|
|
1360
|
+
const xs = GRAPH_NODES.map(d => d.x), ys = GRAPH_NODES.map(d => d.y);
|
|
1361
|
+
const minX = Math.min(...xs), maxX = Math.max(...xs);
|
|
1362
|
+
const minY = Math.min(...ys), maxY = Math.max(...ys);
|
|
1363
|
+
const bw = maxX - minX || 1, bh = maxY - minY || 1;
|
|
1364
|
+
const k = Math.min((W - pad * 2) / bw, (H - pad * 2) / bh, 2);
|
|
1365
|
+
const tx2 = W / 2 - k * (minX + bw / 2);
|
|
1366
|
+
const ty2 = H / 2 - k * (minY + bh / 2);
|
|
1367
|
+
d3.select('#deps-svg').transition().duration(500)
|
|
1368
|
+
.call(zoomBehaviour.transform, d3.zoomIdentity.translate(tx2, ty2).scale(k));
|
|
1369
|
+
depsIsCentered = true;
|
|
1370
|
+
updateButtons();
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
window.addEventListener('load', fitToScreen);
|
|
1375
|
+
window.addEventListener('resize', () => {
|
|
1376
|
+
if (document.getElementById('tab-arch').classList.contains('active')) fitToScreen();
|
|
1377
|
+
});
|
|
1378
|
+
|
|
1379
|
+
viewport.addEventListener('wheel', (e) => {
|
|
1380
|
+
e.preventDefault();
|
|
1381
|
+
const rect = viewport.getBoundingClientRect();
|
|
1382
|
+
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
|
|
1383
|
+
const delta = e.deltaY > 0 ? 0.92 : 1.08;
|
|
1384
|
+
const ns = Math.min(Math.max(scale * delta, 0.05), 5);
|
|
1385
|
+
tx = mx - (mx - tx) * (ns / scale);
|
|
1386
|
+
ty = my - (my - ty) * (ns / scale);
|
|
1387
|
+
scale = ns;
|
|
1388
|
+
if (archIsCentered) { archIsCentered = false; updateButtons(); }
|
|
1389
|
+
applyTransform();
|
|
1390
|
+
}, { passive: false });
|
|
1391
|
+
|
|
1392
|
+
viewport.addEventListener('mousedown', (e) => {
|
|
1393
|
+
dragging = true;
|
|
1394
|
+
dragStartX = e.clientX; dragStartY = e.clientY;
|
|
1395
|
+
startTx = tx; startTy = ty;
|
|
1396
|
+
viewport.classList.add('dragging');
|
|
1397
|
+
});
|
|
1398
|
+
window.addEventListener('mousemove', (e) => {
|
|
1399
|
+
if (!dragging) return;
|
|
1400
|
+
tx = startTx + (e.clientX - dragStartX);
|
|
1401
|
+
ty = startTy + (e.clientY - dragStartY);
|
|
1402
|
+
if (archIsCentered) { archIsCentered = false; updateButtons(); }
|
|
1403
|
+
applyTransform();
|
|
1404
|
+
});
|
|
1405
|
+
window.addEventListener('mouseup', () => {
|
|
1406
|
+
dragging = false;
|
|
1407
|
+
viewport.classList.remove('dragging');
|
|
1408
|
+
});
|
|
1409
|
+
|
|
1410
|
+
// ── Arrow overlay ──────────────────────────────────────────────────────
|
|
1411
|
+
function drawArrows() {
|
|
1412
|
+
const old = document.getElementById('arrow-overlay');
|
|
1413
|
+
if (old) old.remove();
|
|
1414
|
+
|
|
1415
|
+
const diagramEl = document.getElementById('diagram');
|
|
1416
|
+
const dr = diagramEl.getBoundingClientRect();
|
|
1417
|
+
|
|
1418
|
+
// Convert a card's screen rect → diagram's own coordinate space (pre-transform)
|
|
1419
|
+
function rel(id) {
|
|
1420
|
+
const el = document.getElementById(id);
|
|
1421
|
+
if (!el) return null;
|
|
1422
|
+
const r = el.getBoundingClientRect();
|
|
1423
|
+
const l = (r.left - dr.left) / scale;
|
|
1424
|
+
const t = (r.top - dr.top) / scale;
|
|
1425
|
+
const w = r.width / scale;
|
|
1426
|
+
const h = r.height / scale;
|
|
1427
|
+
return { l, t, r: l + w, b: t + h, cx: l + w / 2, cy: t + h / 2 };
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
// ── Routing corridors ───────────────────────────────────────────────
|
|
1431
|
+
const topRowEl = document.querySelector('.top-row');
|
|
1432
|
+
const botRowEl = document.querySelector('.bot-row');
|
|
1433
|
+
const aboveY = topRowEl ? (topRowEl.getBoundingClientRect().top - dr.top) / scale - 14 : 10;
|
|
1434
|
+
const aboveYHi = aboveY - 14;
|
|
1435
|
+
const belowY = botRowEl ? (botRowEl.getBoundingClientRect().bottom - dr.top) / scale + 18 : 9999;
|
|
1436
|
+
const belowYLo = belowY + 14;
|
|
1437
|
+
// midY: horizontal corridor in the gap between top and bottom rows
|
|
1438
|
+
const midY = (topRowEl && botRowEl)
|
|
1439
|
+
? ((topRowEl.getBoundingClientRect().bottom + botRowEl.getBoundingClientRect().top) / 2 - dr.top) / scale
|
|
1440
|
+
: 500;
|
|
1441
|
+
const midYLo = midY + 14;
|
|
1442
|
+
|
|
1443
|
+
// gapX12: vertical corridor between left-stack and iface/types-stack
|
|
1444
|
+
// (guaranteed clear because nothing occupies this x between the two stacks)
|
|
1445
|
+
const rSvcEl = rel('sec-svc');
|
|
1446
|
+
const rIfaceEl = rel('sec-iface');
|
|
1447
|
+
const gapX12 = rSvcEl && rIfaceEl ? (rSvcEl.r + rIfaceEl.l) / 2 : 200;
|
|
1448
|
+
|
|
1449
|
+
// rightMarginX: x corridor to the RIGHT of ALL cards (both rows)
|
|
1450
|
+
const everyId = ['sec-ext','sec-cfg','sec-svc','sec-iface','sec-types',
|
|
1451
|
+
'sec-pipe','sec-gen','sec-fix','sec-cons','sec-scripts','sec-mcp',
|
|
1452
|
+
'sec-comp','sec-ctrl','sec-help','sec-ovr','sec-pext','sec-dev'];
|
|
1453
|
+
let rmx = 0;
|
|
1454
|
+
for (const id of everyId) { const rr = rel(id); if (rr) rmx = Math.max(rmx, rr.r); }
|
|
1455
|
+
const rightMarginX = rmx + 20; // first right-margin corridor
|
|
1456
|
+
const rightMarginX2 = rmx + 36; // second corridor (offset so two arrows don't overlap)
|
|
1457
|
+
|
|
1458
|
+
// ── Connection table ─────────────────────────────────────────────────
|
|
1459
|
+
// Strategies:
|
|
1460
|
+
// 'lr' – adjacent cards, Z-bend in horizontal flex gap
|
|
1461
|
+
// 'gap12' – exit source right → gapX12 → up to aboveY → right to target cx → down to target top
|
|
1462
|
+
// 'gap12-hi' – same but uses aboveYHi
|
|
1463
|
+
// 'rm' – exit source right → rightMarginX → up to aboveY → left to (target.cx+8) → down to target top
|
|
1464
|
+
// 'rm2' – exit source right → rightMarginX2 → up to aboveYHi → left to target.cx → down to target top
|
|
1465
|
+
//
|
|
1466
|
+
// Safety proofs:
|
|
1467
|
+
// 'lr' – vertical segment sits inside the flex gap; no card is there ✓
|
|
1468
|
+
// 'gap12' – gapX12 < iface.l and > svc.r; nothing occupies that x between y=f.cy ✓
|
|
1469
|
+
// – aboveY is 14px above top-row top; horizontal at aboveY clears all boxes ✓
|
|
1470
|
+
// 'rm'/'rm2' – rightMarginX > ALL card right edges (both rows); vertical is always clear ✓
|
|
1471
|
+
// – aboveY / aboveYHi are above top-row top ✓
|
|
1472
|
+
const CONNS = [
|
|
1473
|
+
['sec-pipe', 'sec-gen', '#d4910a', 'lr', false],
|
|
1474
|
+
['sec-gen', 'sec-fix', '#c9c258', 'lr', false],
|
|
1475
|
+
['sec-fix', 'sec-cons', '#4a90d9', 'lr', false],
|
|
1476
|
+
['sec-iface', 'sec-pipe', '#5a8abf', 'lr', true ],
|
|
1477
|
+
['sec-svc', 'sec-pipe', '#9b59b6', 'gap12',true ],
|
|
1478
|
+
['sec-ext', 'sec-fix', '#3fb950', 'gap12-hi', true ],
|
|
1479
|
+
['sec-ovr', 'sec-fix', '#c0392b', 'rm', false],
|
|
1480
|
+
['sec-pext', 'sec-fix', '#16a085', 'rm2', false],
|
|
1481
|
+
['sec-comp', 'sec-ctrl', '#4a90d9', 'lr', true ],
|
|
1482
|
+
['sec-help', 'sec-pext', '#27ae60', 'lr', true ],
|
|
1483
|
+
['sec-scripts', 'sec-mcp', '#f97316', 'tb', false],
|
|
1484
|
+
];
|
|
1485
|
+
|
|
1486
|
+
const paths = [];
|
|
1487
|
+
for (const [fid, tid, color, strat, dashed] of CONNS) {
|
|
1488
|
+
const f = rel(fid), t = rel(tid);
|
|
1489
|
+
if (!f || !t) continue;
|
|
1490
|
+
let pts;
|
|
1491
|
+
if (strat === 'lr') {
|
|
1492
|
+
const midX = (f.r + t.l) / 2;
|
|
1493
|
+
pts = [[f.r, f.cy], [midX, f.cy], [midX, t.cy], [t.l, t.cy]];
|
|
1494
|
+
} else if (strat === 'tb') {
|
|
1495
|
+
// straight top→bottom between vertically stacked cards
|
|
1496
|
+
pts = [[f.cx, f.b], [f.cx, t.t]];
|
|
1497
|
+
} else if (strat === 'above-tb') {
|
|
1498
|
+
// exit source top, go above top-row, travel right to target cx, drop down
|
|
1499
|
+
pts = [[f.cx, f.t], [f.cx, aboveYHi], [t.cx + 8, aboveYHi], [t.cx + 8, t.t]];
|
|
1500
|
+
} else if (strat === 'bm') {
|
|
1501
|
+
// exit source bottom → below all bottom-row cards → right margin → above top-row → target
|
|
1502
|
+
pts = [[f.cx, f.b], [f.cx, belowY], [rightMarginX, belowY], [rightMarginX, aboveY], [t.cx + 8, aboveY], [t.cx + 8, t.t]];
|
|
1503
|
+
} else if (strat === 'bm2') {
|
|
1504
|
+
pts = [[f.cx, f.b], [f.cx, belowYLo], [rightMarginX2, belowYLo], [rightMarginX2, aboveYHi], [t.cx, aboveYHi], [t.cx, t.t]];
|
|
1505
|
+
} else if (strat === 'gap-row') {
|
|
1506
|
+
// exit source top → inter-row gap → left toward target cx → up into target
|
|
1507
|
+
pts = [[f.cx, f.t], [f.cx, midY], [t.cx + 8, midY], [t.cx + 8, t.b]];
|
|
1508
|
+
} else if (strat === 'gap-row2') {
|
|
1509
|
+
pts = [[f.cx, f.t], [f.cx, midYLo], [t.cx, midYLo], [t.cx, t.b]];
|
|
1510
|
+
} else if (strat === 'gap12') {
|
|
1511
|
+
pts = [[f.r, f.cy], [gapX12, f.cy], [gapX12, aboveY], [t.cx, aboveY], [t.cx, t.t]];
|
|
1512
|
+
} else if (strat === 'gap12-hi') {
|
|
1513
|
+
pts = [[f.r, f.cy], [gapX12, f.cy], [gapX12, aboveYHi], [t.cx, aboveYHi], [t.cx, t.t]];
|
|
1514
|
+
} else if (strat === 'rm') {
|
|
1515
|
+
// Exit bottom → belowY (clear) → rightMarginX (clear) → aboveY (clear) → target
|
|
1516
|
+
pts = [[f.cx, f.b], [f.cx, belowY], [rightMarginX, belowY], [rightMarginX, aboveY], [t.cx + 8, aboveY], [t.cx + 8, t.t]];
|
|
1517
|
+
} else if (strat === 'rm2') {
|
|
1518
|
+
pts = [[f.cx, f.b], [f.cx, belowYLo], [rightMarginX2, belowYLo], [rightMarginX2, aboveYHi], [t.cx, aboveYHi], [t.cx, t.t]];
|
|
1519
|
+
}
|
|
1520
|
+
if (pts) paths.push({ pts, color, label: '', dashed });
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
1524
|
+
const svg = document.createElementNS(NS, 'svg');
|
|
1525
|
+
svg.id = 'arrow-overlay';
|
|
1526
|
+
svg.setAttribute('style', 'position:absolute;top:0;left:0;overflow:visible;pointer-events:none;z-index:0;');
|
|
1527
|
+
svg.setAttribute('width', diagramEl.scrollWidth);
|
|
1528
|
+
svg.setAttribute('height', diagramEl.scrollHeight);
|
|
1529
|
+
|
|
1530
|
+
// Define arrowhead markers per colour
|
|
1531
|
+
const defs = document.createElementNS(NS, 'defs');
|
|
1532
|
+
for (const c of [...new Set(paths.map(p => p.color))]) {
|
|
1533
|
+
const mk = document.createElementNS(NS, 'marker');
|
|
1534
|
+
const mid = 'ah' + c.replace(/#/g, '');
|
|
1535
|
+
mk.setAttribute('id', mid);
|
|
1536
|
+
mk.setAttribute('markerWidth', '7');
|
|
1537
|
+
mk.setAttribute('markerHeight', '7');
|
|
1538
|
+
mk.setAttribute('refX', '6');
|
|
1539
|
+
mk.setAttribute('refY', '3.5');
|
|
1540
|
+
mk.setAttribute('orient', 'auto');
|
|
1541
|
+
const poly = document.createElementNS(NS, 'polygon');
|
|
1542
|
+
poly.setAttribute('points', '0 0, 7 3.5, 0 7');
|
|
1543
|
+
poly.setAttribute('fill', c);
|
|
1544
|
+
mk.appendChild(poly);
|
|
1545
|
+
defs.appendChild(mk);
|
|
1546
|
+
}
|
|
1547
|
+
svg.appendChild(defs);
|
|
1548
|
+
|
|
1549
|
+
for (const { pts, color, label, dashed } of paths) {
|
|
1550
|
+
const markId = 'url(#ah' + color.replace(/#/g, '') + ')';
|
|
1551
|
+
|
|
1552
|
+
const path = document.createElementNS(NS, 'path');
|
|
1553
|
+
const d = 'M ' + pts.map(([x, y]) => x.toFixed(1) + ' ' + y.toFixed(1)).join(' L ');
|
|
1554
|
+
path.setAttribute('d', d);
|
|
1555
|
+
path.setAttribute('fill', 'none');
|
|
1556
|
+
path.setAttribute('stroke', color);
|
|
1557
|
+
path.setAttribute('stroke-width', '1.5');
|
|
1558
|
+
path.setAttribute('stroke-opacity', '0.75');
|
|
1559
|
+
if (dashed) path.setAttribute('stroke-dasharray', '5,3');
|
|
1560
|
+
path.setAttribute('marker-end', markId);
|
|
1561
|
+
svg.appendChild(path);
|
|
1562
|
+
|
|
1563
|
+
if (label && pts.length >= 2) {
|
|
1564
|
+
// Place label at the midpoint of the longest segment
|
|
1565
|
+
let bestIdx = 0, bestLen = 0;
|
|
1566
|
+
for (let i = 0; i < pts.length - 1; i++) {
|
|
1567
|
+
const dx = pts[i+1][0] - pts[i][0], dy = pts[i+1][1] - pts[i][1];
|
|
1568
|
+
const len = Math.sqrt(dx*dx + dy*dy);
|
|
1569
|
+
if (len > bestLen) { bestLen = len; bestIdx = i; }
|
|
1570
|
+
}
|
|
1571
|
+
const lx = ((pts[bestIdx][0] + pts[bestIdx+1][0]) / 2).toFixed(1);
|
|
1572
|
+
const ly = ((pts[bestIdx][1] + pts[bestIdx+1][1]) / 2 - 4).toFixed(1);
|
|
1573
|
+
const txt = document.createElementNS(NS, 'text');
|
|
1574
|
+
txt.setAttribute('x', lx);
|
|
1575
|
+
txt.setAttribute('y', ly);
|
|
1576
|
+
txt.setAttribute('fill', color);
|
|
1577
|
+
txt.setAttribute('font-size', '9');
|
|
1578
|
+
txt.setAttribute('text-anchor', 'middle');
|
|
1579
|
+
txt.setAttribute('font-family', 'system-ui, sans-serif');
|
|
1580
|
+
txt.setAttribute('opacity', '0.85');
|
|
1581
|
+
txt.textContent = label;
|
|
1582
|
+
svg.appendChild(txt);
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
diagramEl.appendChild(svg);
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
async function exportPng() {
|
|
1590
|
+
const btn = document.getElementById('export-btn');
|
|
1591
|
+
btn.disabled = true; btn.textContent = 'Exporting…';
|
|
1592
|
+
try {
|
|
1593
|
+
const archActive = document.getElementById('tab-arch').classList.contains('active');
|
|
1594
|
+
if (archActive) {
|
|
1595
|
+
// Architecture tab — use html2canvas on the diagram element
|
|
1596
|
+
const saved = diagram.style.transform;
|
|
1597
|
+
diagram.style.transform = 'none';
|
|
1598
|
+
await new Promise(r => requestAnimationFrame(r));
|
|
1599
|
+
const canvas = await html2canvas(diagram, {
|
|
1600
|
+
backgroundColor: '#0d1117',
|
|
1601
|
+
scale: 2,
|
|
1602
|
+
useCORS: true,
|
|
1603
|
+
logging: false,
|
|
1604
|
+
});
|
|
1605
|
+
diagram.style.transform = saved;
|
|
1606
|
+
canvas.toBlob((blob) => {
|
|
1607
|
+
const a = document.createElement('a');
|
|
1608
|
+
a.href = URL.createObjectURL(blob);
|
|
1609
|
+
a.download = 'testwise-architecture.png';
|
|
1610
|
+
a.click();
|
|
1611
|
+
URL.revokeObjectURL(a.href);
|
|
1612
|
+
btn.disabled = false; btn.textContent = '⬇ Export PNG';
|
|
1613
|
+
}, 'image/png');
|
|
1614
|
+
} else {
|
|
1615
|
+
// Dependencies tab — serialize the D3 SVG to a canvas
|
|
1616
|
+
const svgEl = document.getElementById('deps-svg');
|
|
1617
|
+
const { width, height } = svgEl.getBoundingClientRect();
|
|
1618
|
+
// Clone and stamp explicit dimensions so the serialized SVG renders at viewport size
|
|
1619
|
+
const clone = svgEl.cloneNode(true);
|
|
1620
|
+
clone.setAttribute('width', width);
|
|
1621
|
+
clone.setAttribute('height', height);
|
|
1622
|
+
clone.setAttribute('overflow', 'hidden');
|
|
1623
|
+
// Embed the font so text renders correctly
|
|
1624
|
+
const style = document.createElement('style');
|
|
1625
|
+
style.textContent = 'text { font-family: system-ui, sans-serif; font-size: 11px; fill: #c9d1d9; }';
|
|
1626
|
+
clone.insertBefore(style, clone.firstChild);
|
|
1627
|
+
const serializer = new XMLSerializer();
|
|
1628
|
+
const svgStr = serializer.serializeToString(clone);
|
|
1629
|
+
const svgBlob = new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' });
|
|
1630
|
+
const url = URL.createObjectURL(svgBlob);
|
|
1631
|
+
const img = new Image();
|
|
1632
|
+
img.onload = () => {
|
|
1633
|
+
const canvas = document.createElement('canvas');
|
|
1634
|
+
const scale = 2;
|
|
1635
|
+
canvas.width = width * scale;
|
|
1636
|
+
canvas.height = height * scale;
|
|
1637
|
+
const ctx = canvas.getContext('2d');
|
|
1638
|
+
ctx.fillStyle = '#0d1117';
|
|
1639
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
1640
|
+
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
1641
|
+
URL.revokeObjectURL(url);
|
|
1642
|
+
canvas.toBlob((blob) => {
|
|
1643
|
+
const a = document.createElement('a');
|
|
1644
|
+
a.href = URL.createObjectURL(blob);
|
|
1645
|
+
a.download = 'testwise-dependencies.png';
|
|
1646
|
+
a.click();
|
|
1647
|
+
URL.revokeObjectURL(a.href);
|
|
1648
|
+
btn.disabled = false; btn.textContent = '⬇ Export PNG';
|
|
1649
|
+
}, 'image/png');
|
|
1650
|
+
};
|
|
1651
|
+
img.onerror = (e) => {
|
|
1652
|
+
URL.revokeObjectURL(url);
|
|
1653
|
+
throw new Error('SVG render failed');
|
|
1654
|
+
};
|
|
1655
|
+
img.src = url;
|
|
1656
|
+
}
|
|
1657
|
+
} catch (e) {
|
|
1658
|
+
alert('Export error: ' + e.message);
|
|
1659
|
+
btn.disabled = false; btn.textContent = '⬇ Export PNG';
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
// ── D3 Dependency Graph ────────────────────────────────────────────────
|
|
1664
|
+
// Data loaded via fetch('/api/data') above — GRAPH_NODES / GRAPH_LINKS
|
|
1665
|
+
// are populated before initDepsGraph() is called.
|
|
1666
|
+
|
|
1667
|
+
let depsInited = false;
|
|
1668
|
+
let zoomBehaviour = null;
|
|
1669
|
+
function initDepsGraph() {
|
|
1670
|
+
if (depsInited) return;
|
|
1671
|
+
depsInited = true;
|
|
1672
|
+
if (!GRAPH_NODES || GRAPH_NODES.length === 0) { depsInited = false; return; }
|
|
1673
|
+
|
|
1674
|
+
const svgEl = document.getElementById('deps-svg');
|
|
1675
|
+
const W = svgEl.clientWidth || 1200;
|
|
1676
|
+
const H = svgEl.clientHeight || 700;
|
|
1677
|
+
|
|
1678
|
+
// Count in/out degree for sizing nodes
|
|
1679
|
+
const inDeg = new Map(GRAPH_NODES.map(n => [n.id, 0]));
|
|
1680
|
+
const outDeg = new Map(GRAPH_NODES.map(n => [n.id, 0]));
|
|
1681
|
+
for (const l of GRAPH_LINKS) {
|
|
1682
|
+
outDeg.set(l.source, (outDeg.get(l.source) || 0) + 1);
|
|
1683
|
+
inDeg.set(l.target, (inDeg.get(l.target) || 0) + 1);
|
|
1684
|
+
}
|
|
1685
|
+
// Total degree drives node radius
|
|
1686
|
+
const totalDeg = id => (inDeg.get(id) || 0) + (outDeg.get(id) || 0);
|
|
1687
|
+
const nodeR = d => 4 + Math.min(totalDeg(d.id) * 0.9, 18);
|
|
1688
|
+
|
|
1689
|
+
// Build index so d3 can use string ids
|
|
1690
|
+
const nodeById = new Map(GRAPH_NODES.map(n => [n.id, n]));
|
|
1691
|
+
|
|
1692
|
+
const svg = d3.select('#deps-svg');
|
|
1693
|
+
svg.selectAll('*').remove();
|
|
1694
|
+
const g = svg.append('g');
|
|
1695
|
+
|
|
1696
|
+
zoomBehaviour = d3.zoom().scaleExtent([0.02, 6]).on('zoom', e => {
|
|
1697
|
+
g.attr('transform', e.transform);
|
|
1698
|
+
if (e.sourceEvent) { depsIsCentered = false; updateButtons(); }
|
|
1699
|
+
});
|
|
1700
|
+
svg.call(zoomBehaviour);
|
|
1701
|
+
|
|
1702
|
+
// Legend sidebar (HTML)
|
|
1703
|
+
const groups = [...new Set(GRAPH_NODES.map(n => n.group))].sort();
|
|
1704
|
+
const legendEl = document.getElementById('deps-legend-rows');
|
|
1705
|
+
legendEl.innerHTML = '';
|
|
1706
|
+
function applyLegendHighlight(grp) {
|
|
1707
|
+
if (grp) {
|
|
1708
|
+
node.select('circle').transition('hl').duration(150)
|
|
1709
|
+
.attr('fill', n => n.group === grp ? n.color + 'ff' : n.color + '0d')
|
|
1710
|
+
.attr('stroke-width', n => n.group === grp ? 2.5 : 0.5);
|
|
1711
|
+
node.select('text').transition('hl').duration(150)
|
|
1712
|
+
.attr('opacity', n => n.group === grp ? 1 : 0.05);
|
|
1713
|
+
link.transition('hl').duration(150).attr('stroke-opacity', 0).attr('marker-end', 'none');
|
|
1714
|
+
} else {
|
|
1715
|
+
node.select('circle').transition('hl').duration(150)
|
|
1716
|
+
.attr('fill', n => n.color + 'bb').attr('stroke-width', 1.8);
|
|
1717
|
+
node.select('text').transition('hl').duration(150).attr('opacity', 1);
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
function applyNodeHighlight(d) {
|
|
1722
|
+
if (d) {
|
|
1723
|
+
const neighbours = new Set([d.id]);
|
|
1724
|
+
GRAPH_LINKS.forEach(l => {
|
|
1725
|
+
const sid = typeof l.source === 'object' ? l.source.id : l.source;
|
|
1726
|
+
const tid = typeof l.target === 'object' ? l.target.id : l.target;
|
|
1727
|
+
if (sid === d.id) neighbours.add(tid);
|
|
1728
|
+
if (tid === d.id) neighbours.add(sid);
|
|
1729
|
+
});
|
|
1730
|
+
node.select('circle').transition('hl').duration(200)
|
|
1731
|
+
.attr('fill', n => neighbours.has(n.id) ? n.color + 'ee' : n.color + '22')
|
|
1732
|
+
.attr('stroke-width', n => n.id === d.id ? 2.5 : neighbours.has(n.id) ? 2 : 1);
|
|
1733
|
+
node.select('text').transition('hl').duration(200)
|
|
1734
|
+
.attr('opacity', n => neighbours.has(n.id) ? 1 : 0.2);
|
|
1735
|
+
link.transition('hl').duration(200)
|
|
1736
|
+
.attr('stroke-opacity', l => {
|
|
1737
|
+
const sid = typeof l.source === 'object' ? l.source.id : l.source;
|
|
1738
|
+
const tid = typeof l.target === 'object' ? l.target.id : l.target;
|
|
1739
|
+
return (sid === d.id || tid === d.id) ? 1 : 0;
|
|
1740
|
+
})
|
|
1741
|
+
.attr('stroke-dasharray', 'none')
|
|
1742
|
+
.attr('stroke-width', l => {
|
|
1743
|
+
const sid = typeof l.source === 'object' ? l.source.id : l.source;
|
|
1744
|
+
const tid = typeof l.target === 'object' ? l.target.id : l.target;
|
|
1745
|
+
return (sid === d.id || tid === d.id) ? 2 : 1.5;
|
|
1746
|
+
})
|
|
1747
|
+
.attr('marker-end', l => {
|
|
1748
|
+
const sid = typeof l.source === 'object' ? l.source.id : l.source;
|
|
1749
|
+
const tid = typeof l.target === 'object' ? l.target.id : l.target;
|
|
1750
|
+
return (sid === d.id || tid === d.id) ? 'url(#deps-arrow)' : 'none';
|
|
1751
|
+
});
|
|
1752
|
+
} else {
|
|
1753
|
+
node.select('circle').transition('hl').duration(200)
|
|
1754
|
+
.attr('fill', n => n.color + 'bb').attr('stroke-width', 1.8);
|
|
1755
|
+
node.select('text').transition('hl').duration(200).attr('opacity', 1).attr('fill', '#e6edf3').attr('font-weight', '500');
|
|
1756
|
+
link.transition('hl').duration(200).attr('stroke-opacity', 0).attr('stroke-dasharray', 'none').attr('stroke-width', 1.5).attr('marker-end', 'none');
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
_applyNodeHighlight = applyNodeHighlight;
|
|
1760
|
+
|
|
1761
|
+
function showNodePanel(d, showOpenBtn = false) {
|
|
1762
|
+
const neighbours = new Set([d.id]);
|
|
1763
|
+
GRAPH_LINKS.forEach(l => {
|
|
1764
|
+
const sid = typeof l.source === 'object' ? l.source.id : l.source;
|
|
1765
|
+
const tid = typeof l.target === 'object' ? l.target.id : l.target;
|
|
1766
|
+
if (sid === d.id) neighbours.add(tid);
|
|
1767
|
+
if (tid === d.id) neighbours.add(sid);
|
|
1768
|
+
});
|
|
1769
|
+
const connectedNames = GRAPH_NODES
|
|
1770
|
+
.filter(n => neighbours.has(n.id) && n.id !== d.id)
|
|
1771
|
+
.map(n => n.name).sort();
|
|
1772
|
+
const jsdoc = TIPS[d.name] || '';
|
|
1773
|
+
const safePath = d.path.replace(/\\\\/g, '/');
|
|
1774
|
+
const panel = document.getElementById('deps-panel');
|
|
1775
|
+
panel.innerHTML =
|
|
1776
|
+
\`<div class="dp-name">\${d.name}</div>\` +
|
|
1777
|
+
\`<div class="dp-meta">Imports: \${outDeg.get(d.id)||0} · Imported by: \${inDeg.get(d.id)||0}</div>\` +
|
|
1778
|
+
(jsdoc ? \`<div class="dp-jsdoc">\${jsdoc}</div>\` : '') +
|
|
1779
|
+
\`<div class="dp-connected"><strong>Connected (\${connectedNames.length}):</strong>\\n\${connectedNames.join('\\n') || '\u2014'}</div>\` +
|
|
1780
|
+
(showOpenBtn ? \`<button class="dp-open-btn" onclick="openInVscode('\${safePath}')">↗ Open File</button>\` : '');
|
|
1781
|
+
panel.classList.add('visible');
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
function zoomToNodes(nodeList) {
|
|
1785
|
+
const pad = 80;
|
|
1786
|
+
const xs = nodeList.map(d => d.x), ys = nodeList.map(d => d.y);
|
|
1787
|
+
const minX = Math.min(...xs), maxX = Math.max(...xs);
|
|
1788
|
+
const minY = Math.min(...ys), maxY = Math.max(...ys);
|
|
1789
|
+
const bw = maxX - minX || 1, bh = maxY - minY || 1;
|
|
1790
|
+
const k = Math.min((W - pad * 2) / bw, (H - pad * 2) / bh, 4);
|
|
1791
|
+
const tx2 = W / 2 - k * (minX + bw / 2);
|
|
1792
|
+
const ty2 = H / 2 - k * (minY + bh / 2);
|
|
1793
|
+
svg.transition().duration(500)
|
|
1794
|
+
.call(zoomBehaviour.transform, d3.zoomIdentity.translate(tx2, ty2).scale(k));
|
|
1795
|
+
depsIsCentered = false;
|
|
1796
|
+
updateButtons();
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
const legColors = _legColors;
|
|
1800
|
+
groups.forEach(grp => {
|
|
1801
|
+
const color = (nodeById.get(GRAPH_NODES.find(n => n.group === grp)?.id) || {}).color || '#888';
|
|
1802
|
+
legColors[grp] = color;
|
|
1803
|
+
_legColors[grp] = color;
|
|
1804
|
+
const row = document.createElement('div');
|
|
1805
|
+
row.className = 'leg-row';
|
|
1806
|
+
row.innerHTML = \`<span class="leg-dot" style="background:\${color}55;border-color:\${color}"></span><span class="leg-label">\${grp}</span>\`;
|
|
1807
|
+
row.addEventListener('click', () => {
|
|
1808
|
+
if (!document.getElementById('deps-svg').classList.contains('settled')) return;
|
|
1809
|
+
if (selectedLegendGroup === grp) {
|
|
1810
|
+
row.classList.remove('leg-selected');
|
|
1811
|
+
row.querySelector('.leg-dot').style.background = color + '55';
|
|
1812
|
+
selectedLegendGroup = null;
|
|
1813
|
+
selectedLegendRow = null;
|
|
1814
|
+
if (selectedNode) applyNodeHighlight(selectedNode); else applyLegendHighlight(null);
|
|
1815
|
+
} else {
|
|
1816
|
+
if (selectedLegendRow) {
|
|
1817
|
+
selectedLegendRow.classList.remove('leg-selected');
|
|
1818
|
+
selectedLegendRow.querySelector('.leg-dot').style.background = legColors[selectedLegendGroup] + '55';
|
|
1819
|
+
}
|
|
1820
|
+
selectedNode = null;
|
|
1821
|
+
selectedLegendGroup = grp;
|
|
1822
|
+
selectedLegendRow = row;
|
|
1823
|
+
row.classList.add('leg-selected');
|
|
1824
|
+
row.querySelector('.leg-dot').style.background = color + 'cc';
|
|
1825
|
+
applyLegendHighlight(grp);
|
|
1826
|
+
const groupNodes = GRAPH_NODES.filter(n => n.group === grp);
|
|
1827
|
+
if (groupNodes.length) zoomToNodes(groupNodes);
|
|
1828
|
+
}
|
|
1829
|
+
// Close side panel when switching legend selection
|
|
1830
|
+
document.getElementById('deps-panel').classList.remove('visible');
|
|
1831
|
+
updateButtons();
|
|
1832
|
+
});
|
|
1833
|
+
row.addEventListener('mouseover', () => {
|
|
1834
|
+
if (!document.getElementById('deps-svg').classList.contains('settled')) return;
|
|
1835
|
+
if (selectedLegendGroup || selectedNode) return;
|
|
1836
|
+
row.querySelector('.leg-dot').style.background = color + 'cc';
|
|
1837
|
+
applyLegendHighlight(grp);
|
|
1838
|
+
});
|
|
1839
|
+
row.addEventListener('mouseout', () => {
|
|
1840
|
+
if (selectedLegendGroup || selectedNode) return;
|
|
1841
|
+
row.querySelector('.leg-dot').style.background = color + '55';
|
|
1842
|
+
applyLegendHighlight(null);
|
|
1843
|
+
});
|
|
1844
|
+
legendEl.appendChild(row);
|
|
1845
|
+
});
|
|
1846
|
+
|
|
1847
|
+
// ── Search box ────────────────────────────────────────────────────
|
|
1848
|
+
const searchInput = document.getElementById('deps-search');
|
|
1849
|
+
const searchList = document.getElementById('deps-search-list');
|
|
1850
|
+
const sortedNodes = [...GRAPH_NODES].sort((a, b) => a.name.localeCompare(b.name));
|
|
1851
|
+
|
|
1852
|
+
function selectNodeById(id) {
|
|
1853
|
+
const d = GRAPH_NODES.find(n => n.id === id);
|
|
1854
|
+
if (!d) return;
|
|
1855
|
+
// Clear legend selection
|
|
1856
|
+
if (selectedLegendRow) {
|
|
1857
|
+
selectedLegendRow.classList.remove('leg-selected');
|
|
1858
|
+
selectedLegendRow.querySelector('.leg-dot').style.background = _legColors[selectedLegendGroup] + '55';
|
|
1859
|
+
selectedLegendGroup = null;
|
|
1860
|
+
selectedLegendRow = null;
|
|
1861
|
+
}
|
|
1862
|
+
selectedNode = d;
|
|
1863
|
+
applyNodeHighlight(d);
|
|
1864
|
+
showNodePanel(d, true);
|
|
1865
|
+
const neighbours = new Set([d.id]);
|
|
1866
|
+
GRAPH_LINKS.forEach(l => {
|
|
1867
|
+
const sid = typeof l.source === 'object' ? l.source.id : l.source;
|
|
1868
|
+
const tid = typeof l.target === 'object' ? l.target.id : l.target;
|
|
1869
|
+
if (sid === d.id) neighbours.add(tid);
|
|
1870
|
+
if (tid === d.id) neighbours.add(sid);
|
|
1871
|
+
});
|
|
1872
|
+
const relatedNodes = GRAPH_NODES.filter(n => neighbours.has(n.id));
|
|
1873
|
+
if (relatedNodes.length) zoomToNodes(relatedNodes);
|
|
1874
|
+
updateButtons();
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
function renderSearchList(q) {
|
|
1878
|
+
const matches = q.length === 0 ? [] :
|
|
1879
|
+
sortedNodes.filter(n => n.name.toLowerCase().includes(q.toLowerCase())).slice(0, 50);
|
|
1880
|
+
if (matches.length === 0) { searchList.style.display = 'none'; return; }
|
|
1881
|
+
searchList.innerHTML = matches.map((n, i) =>
|
|
1882
|
+
\`<div class="sl-item" data-id="\${n.id}" data-idx="\${i}">\${n.name}<span class="sl-group">\${n.group}</span></div>\`
|
|
1883
|
+
).join('');
|
|
1884
|
+
searchList.style.display = 'block';
|
|
1885
|
+
searchList.querySelectorAll('.sl-item').forEach(el => {
|
|
1886
|
+
el.addEventListener('mousedown', ev => {
|
|
1887
|
+
ev.preventDefault();
|
|
1888
|
+
searchInput.value = '';
|
|
1889
|
+
searchInput.blur();
|
|
1890
|
+
searchList.style.display = 'none';
|
|
1891
|
+
if (document.getElementById('deps-svg').classList.contains('settled')) {
|
|
1892
|
+
selectNodeById(el.dataset.id);
|
|
1893
|
+
}
|
|
1894
|
+
});
|
|
1895
|
+
});
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
searchInput.addEventListener('input', () => renderSearchList(searchInput.value));
|
|
1899
|
+
searchInput.addEventListener('blur', () => setTimeout(() => { searchList.style.display = 'none'; }, 150));
|
|
1900
|
+
searchInput.addEventListener('focus', () => { if (searchInput.value) renderSearchList(searchInput.value); });
|
|
1901
|
+
// Keyboard navigation
|
|
1902
|
+
searchInput.addEventListener('keydown', ev => {
|
|
1903
|
+
const items = searchList.querySelectorAll('.sl-item');
|
|
1904
|
+
const active = searchList.querySelector('.sl-active');
|
|
1905
|
+
if (ev.key === 'ArrowDown') {
|
|
1906
|
+
ev.preventDefault();
|
|
1907
|
+
const next = active ? active.nextElementSibling : items[0];
|
|
1908
|
+
if (active) active.classList.remove('sl-active');
|
|
1909
|
+
if (next) { next.classList.add('sl-active'); next.scrollIntoView({ block: 'nearest' }); }
|
|
1910
|
+
} else if (ev.key === 'ArrowUp') {
|
|
1911
|
+
ev.preventDefault();
|
|
1912
|
+
const prev = active ? active.previousElementSibling : items[items.length - 1];
|
|
1913
|
+
if (active) active.classList.remove('sl-active');
|
|
1914
|
+
if (prev) { prev.classList.add('sl-active'); prev.scrollIntoView({ block: 'nearest' }); }
|
|
1915
|
+
} else if (ev.key === 'Enter') {
|
|
1916
|
+
ev.preventDefault();
|
|
1917
|
+
const target = active || items[0];
|
|
1918
|
+
if (target) {
|
|
1919
|
+
searchInput.value = '';
|
|
1920
|
+
searchInput.blur();
|
|
1921
|
+
searchList.style.display = 'none';
|
|
1922
|
+
if (document.getElementById('deps-svg').classList.contains('settled')) {
|
|
1923
|
+
selectNodeById(target.dataset.id);
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
} else if (ev.key === 'Escape') {
|
|
1927
|
+
searchInput.value = '';
|
|
1928
|
+
searchInput.blur();
|
|
1929
|
+
searchList.style.display = 'none';
|
|
1930
|
+
}
|
|
1931
|
+
});
|
|
1932
|
+
|
|
1933
|
+
// Stats label
|
|
1934
|
+
svg.append('text')
|
|
1935
|
+
.attr('x', W - 8).attr('y', 18)
|
|
1936
|
+
.attr('fill', '#6e8098').attr('font-size', '10px').attr('text-anchor', 'end')
|
|
1937
|
+
.attr('font-family', 'system-ui')
|
|
1938
|
+
.text(\`\${GRAPH_NODES.length} nodes · \${GRAPH_LINKS.length} edges\`);
|
|
1939
|
+
|
|
1940
|
+
svg.append('defs').append('marker')
|
|
1941
|
+
.attr('id', 'deps-arrow')
|
|
1942
|
+
.attr('viewBox', '0 -4 8 8').attr('refX', 8).attr('refY', 0)
|
|
1943
|
+
.attr('markerWidth', 5).attr('markerHeight', 5).attr('orient', 'auto')
|
|
1944
|
+
.append('path').attr('d', 'M0,-4L8,0L0,4').attr('fill', '#5a7a9a');
|
|
1945
|
+
|
|
1946
|
+
const link = g.append('g').selectAll('line')
|
|
1947
|
+
.data(GRAPH_LINKS).join('line')
|
|
1948
|
+
.attr('stroke', '#7ca8cc').attr('stroke-width', 1.5)
|
|
1949
|
+
.attr('stroke-opacity', 0);
|
|
1950
|
+
|
|
1951
|
+
const node = g.append('g').selectAll('g')
|
|
1952
|
+
.data(GRAPH_NODES).join('g')
|
|
1953
|
+
.attr('class', 'd3-node');
|
|
1954
|
+
|
|
1955
|
+
// Radius scaled by total degree (imports + imported-by)
|
|
1956
|
+
node.append('circle')
|
|
1957
|
+
.attr('r', d => nodeR(d))
|
|
1958
|
+
.attr('fill', d => d.color + 'bb').attr('stroke', d => d.color).attr('stroke-width', 1.8);
|
|
1959
|
+
|
|
1960
|
+
node.append('text').attr('dx', d => nodeR(d) + 3)
|
|
1961
|
+
.attr('dy', 4).attr('font-weight', '500').text(d => d.name);
|
|
1962
|
+
|
|
1963
|
+
// Node hover / click / selection
|
|
1964
|
+
node.on('mouseover', (ev, d) => {
|
|
1965
|
+
if (!document.getElementById('deps-svg').classList.contains('settled')) return;
|
|
1966
|
+
if (selectedNode) {
|
|
1967
|
+
if (selectedNode.id === d.id) return;
|
|
1968
|
+
// Compute selected node neighbours (keep solid) and hovered neighbours (dotted)
|
|
1969
|
+
const selNeighbours = new Set([selectedNode.id]);
|
|
1970
|
+
const hovNeighbours = new Set([d.id]);
|
|
1971
|
+
GRAPH_LINKS.forEach(l => {
|
|
1972
|
+
const sid = typeof l.source === 'object' ? l.source.id : l.source;
|
|
1973
|
+
const tid = typeof l.target === 'object' ? l.target.id : l.target;
|
|
1974
|
+
if (sid === selectedNode.id) selNeighbours.add(tid);
|
|
1975
|
+
if (tid === selectedNode.id) selNeighbours.add(sid);
|
|
1976
|
+
if (sid === d.id) hovNeighbours.add(tid);
|
|
1977
|
+
if (tid === d.id) hovNeighbours.add(sid);
|
|
1978
|
+
});
|
|
1979
|
+
link.transition('hover').duration(100)
|
|
1980
|
+
.attr('stroke-opacity', l => {
|
|
1981
|
+
const sid = typeof l.source === 'object' ? l.source.id : l.source;
|
|
1982
|
+
const tid = typeof l.target === 'object' ? l.target.id : l.target;
|
|
1983
|
+
const isSel = (sid === selectedNode.id || tid === selectedNode.id);
|
|
1984
|
+
const isHov = (sid === d.id || tid === d.id);
|
|
1985
|
+
return (isSel || isHov) ? (isSel ? 1 : 0.7) : 0;
|
|
1986
|
+
})
|
|
1987
|
+
.attr('stroke-dasharray', l => {
|
|
1988
|
+
const sid = typeof l.source === 'object' ? l.source.id : l.source;
|
|
1989
|
+
const tid = typeof l.target === 'object' ? l.target.id : l.target;
|
|
1990
|
+
const isSel = (sid === selectedNode.id || tid === selectedNode.id);
|
|
1991
|
+
return isSel ? 'none' : '5,3';
|
|
1992
|
+
})
|
|
1993
|
+
.attr('stroke-width', l => {
|
|
1994
|
+
const sid = typeof l.source === 'object' ? l.source.id : l.source;
|
|
1995
|
+
const tid = typeof l.target === 'object' ? l.target.id : l.target;
|
|
1996
|
+
return (sid === selectedNode.id || tid === selectedNode.id) ? 2 : 1.5;
|
|
1997
|
+
})
|
|
1998
|
+
.attr('marker-end', l => {
|
|
1999
|
+
const sid = typeof l.source === 'object' ? l.source.id : l.source;
|
|
2000
|
+
const tid = typeof l.target === 'object' ? l.target.id : l.target;
|
|
2001
|
+
return (sid === selectedNode.id || tid === selectedNode.id) ? 'url(#deps-arrow)' : 'none';
|
|
2002
|
+
});
|
|
2003
|
+
// Brighten hovered node + its neighbours; keep selected node bright; dim rest
|
|
2004
|
+
const allVisible = new Set([...selNeighbours, ...hovNeighbours]);
|
|
2005
|
+
node.select('circle').transition('hover').duration(100)
|
|
2006
|
+
.attr('fill', n => {
|
|
2007
|
+
if (selNeighbours.has(n.id)) return n.color + (n.id === selectedNode.id ? 'ff' : 'ee');
|
|
2008
|
+
if (hovNeighbours.has(n.id)) return n.color + 'cc';
|
|
2009
|
+
return n.color + '15';
|
|
2010
|
+
})
|
|
2011
|
+
.attr('stroke-width', n => {
|
|
2012
|
+
if (n.id === selectedNode.id) return 2.5;
|
|
2013
|
+
if (n.id === d.id) return 2;
|
|
2014
|
+
return hovNeighbours.has(n.id) ? 1.5 : 0.5;
|
|
2015
|
+
});
|
|
2016
|
+
node.select('text').transition('hover').duration(100)
|
|
2017
|
+
.attr('opacity', n => allVisible.has(n.id) ? 1 : 0.12)
|
|
2018
|
+
.attr('fill', n => hovNeighbours.has(n.id) && !selNeighbours.has(n.id) ? '#fff' : '#e6edf3')
|
|
2019
|
+
.attr('font-weight', n => (n.id === d.id || hovNeighbours.has(n.id)) ? '700' : '500');
|
|
2020
|
+
return;
|
|
2021
|
+
}
|
|
2022
|
+
if (selectedLegendGroup) {
|
|
2023
|
+
// Legend is selected — show this node's links + brighten hovered node text
|
|
2024
|
+
const neighbours = new Set([d.id]);
|
|
2025
|
+
GRAPH_LINKS.forEach(l => {
|
|
2026
|
+
const sid = typeof l.source === 'object' ? l.source.id : l.source;
|
|
2027
|
+
const tid = typeof l.target === 'object' ? l.target.id : l.target;
|
|
2028
|
+
if (sid === d.id) neighbours.add(tid);
|
|
2029
|
+
if (tid === d.id) neighbours.add(sid);
|
|
2030
|
+
});
|
|
2031
|
+
node.select('text').transition('hover').duration(100)
|
|
2032
|
+
.attr('opacity', n => neighbours.has(n.id) ? 1 : (n.group === selectedLegendGroup ? 0.3 : 0.05))
|
|
2033
|
+
.attr('font-weight', n => n.id === d.id ? '700' : '500');
|
|
2034
|
+
link.transition('hover').duration(100)
|
|
2035
|
+
.attr('stroke-opacity', l => {
|
|
2036
|
+
const sid = typeof l.source === 'object' ? l.source.id : l.source;
|
|
2037
|
+
const tid = typeof l.target === 'object' ? l.target.id : l.target;
|
|
2038
|
+
return (sid === d.id || tid === d.id) ? 0.8 : 0;
|
|
2039
|
+
})
|
|
2040
|
+
.attr('stroke-dasharray', 'none')
|
|
2041
|
+
.attr('stroke-width', 1.8)
|
|
2042
|
+
.attr('marker-end', l => {
|
|
2043
|
+
const sid = typeof l.source === 'object' ? l.source.id : l.source;
|
|
2044
|
+
const tid = typeof l.target === 'object' ? l.target.id : l.target;
|
|
2045
|
+
return (sid === d.id || tid === d.id) ? 'url(#deps-arrow)' : 'none';
|
|
2046
|
+
});
|
|
2047
|
+
showNodePanel(d);
|
|
2048
|
+
return;
|
|
2049
|
+
}
|
|
2050
|
+
applyNodeHighlight(d);
|
|
2051
|
+
showNodePanel(d);
|
|
2052
|
+
})
|
|
2053
|
+
.on('mouseout', () => {
|
|
2054
|
+
if (selectedNode) {
|
|
2055
|
+
// Restore selected node highlight (remove hover dotted lines)
|
|
2056
|
+
applyNodeHighlight(selectedNode);
|
|
2057
|
+
return;
|
|
2058
|
+
}
|
|
2059
|
+
document.getElementById('deps-panel').classList.remove('visible');
|
|
2060
|
+
if (selectedLegendGroup) {
|
|
2061
|
+
// Restore legend highlight — reset links and text brightening
|
|
2062
|
+
node.select('text').transition('hover').duration(100)
|
|
2063
|
+
.attr('opacity', n => n.group === selectedLegendGroup ? 1 : 0.05)
|
|
2064
|
+
.attr('font-weight', '500');
|
|
2065
|
+
link.transition('hover').duration(100).attr('stroke-opacity', 0).attr('marker-end', 'none').attr('stroke-dasharray', 'none');
|
|
2066
|
+
} else {
|
|
2067
|
+
applyNodeHighlight(null);
|
|
2068
|
+
}
|
|
2069
|
+
})
|
|
2070
|
+
.on('click', (ev, d) => {
|
|
2071
|
+
ev.stopPropagation();
|
|
2072
|
+
if (!document.getElementById('deps-svg').classList.contains('settled')) return;
|
|
2073
|
+
if (selectedNode && selectedNode.id === d.id) {
|
|
2074
|
+
selectedNode = null;
|
|
2075
|
+
if (selectedLegendGroup) applyLegendHighlight(selectedLegendGroup);
|
|
2076
|
+
else applyNodeHighlight(null);
|
|
2077
|
+
document.getElementById('deps-panel').classList.remove('visible');
|
|
2078
|
+
updateButtons();
|
|
2079
|
+
} else {
|
|
2080
|
+
selectedNode = d;
|
|
2081
|
+
// Clear any legend selection
|
|
2082
|
+
if (selectedLegendRow) {
|
|
2083
|
+
selectedLegendRow.classList.remove('leg-selected');
|
|
2084
|
+
selectedLegendRow.querySelector('.leg-dot').style.background = legColors[selectedLegendGroup] + '55';
|
|
2085
|
+
selectedLegendGroup = null;
|
|
2086
|
+
selectedLegendRow = null;
|
|
2087
|
+
}
|
|
2088
|
+
applyNodeHighlight(d);
|
|
2089
|
+
showNodePanel(d, true);
|
|
2090
|
+
const neighbours = new Set([d.id]);
|
|
2091
|
+
GRAPH_LINKS.forEach(l => {
|
|
2092
|
+
const sid = typeof l.source === 'object' ? l.source.id : l.source;
|
|
2093
|
+
const tid = typeof l.target === 'object' ? l.target.id : l.target;
|
|
2094
|
+
if (sid === d.id) neighbours.add(tid);
|
|
2095
|
+
if (tid === d.id) neighbours.add(sid);
|
|
2096
|
+
});
|
|
2097
|
+
const relatedNodes = GRAPH_NODES.filter(n => neighbours.has(n.id));
|
|
2098
|
+
if (relatedNodes.length) zoomToNodes(relatedNodes);
|
|
2099
|
+
updateButtons();
|
|
2100
|
+
}
|
|
2101
|
+
});
|
|
2102
|
+
|
|
2103
|
+
// Click SVG background to deselect
|
|
2104
|
+
svg.on('click', () => {
|
|
2105
|
+
if (!selectedNode) return;
|
|
2106
|
+
selectedNode = null;
|
|
2107
|
+
if (selectedLegendGroup) applyLegendHighlight(selectedLegendGroup);
|
|
2108
|
+
else applyNodeHighlight(null);
|
|
2109
|
+
document.getElementById('deps-panel').classList.remove('visible');
|
|
2110
|
+
updateButtons();
|
|
2111
|
+
});
|
|
2112
|
+
|
|
2113
|
+
const sim = d3.forceSimulation(GRAPH_NODES)
|
|
2114
|
+
.force('link', d3.forceLink(GRAPH_LINKS).id(d => d.id).distance(70).strength(0.35))
|
|
2115
|
+
.force('charge', d3.forceManyBody().strength(-180))
|
|
2116
|
+
.force('center', d3.forceCenter(W / 2, H / 2).strength(0.08))
|
|
2117
|
+
.force('x', d3.forceX(W / 2).strength(0.04))
|
|
2118
|
+
.force('y', d3.forceY(H / 2).strength(0.04))
|
|
2119
|
+
.force('collide', d3.forceCollide(d => nodeR(d) + (d.name.length * 3.5) + 4))
|
|
2120
|
+
.on('tick', () => {
|
|
2121
|
+
link.attr('x1', d => {
|
|
2122
|
+
const dx = d.target.x - d.source.x, dy = d.target.y - d.source.y;
|
|
2123
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
2124
|
+
return dist ? d.source.x + (dx / dist) * nodeR(d.source) : d.source.x;
|
|
2125
|
+
})
|
|
2126
|
+
.attr('y1', d => {
|
|
2127
|
+
const dx = d.target.x - d.source.x, dy = d.target.y - d.source.y;
|
|
2128
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
2129
|
+
return dist ? d.source.y + (dy / dist) * nodeR(d.source) : d.source.y;
|
|
2130
|
+
})
|
|
2131
|
+
.attr('x2', d => {
|
|
2132
|
+
const dx = d.target.x - d.source.x, dy = d.target.y - d.source.y;
|
|
2133
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
2134
|
+
return dist ? d.target.x - (dx / dist) * nodeR(d.target) : d.target.x;
|
|
2135
|
+
})
|
|
2136
|
+
.attr('y2', d => {
|
|
2137
|
+
const dx = d.target.x - d.source.x, dy = d.target.y - d.source.y;
|
|
2138
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
2139
|
+
return dist ? d.target.y - (dy / dist) * nodeR(d.target) : d.target.y;
|
|
2140
|
+
});
|
|
2141
|
+
node.attr('transform', d => \`translate(\${d.x},\${d.y})\`);
|
|
2142
|
+
})
|
|
2143
|
+
.on('end', () => {
|
|
2144
|
+
// Fit all nodes into view with padding
|
|
2145
|
+
const pad = 40;
|
|
2146
|
+
const xs = GRAPH_NODES.map(d => d.x);
|
|
2147
|
+
const ys = GRAPH_NODES.map(d => d.y);
|
|
2148
|
+
const minX = Math.min(...xs), maxX = Math.max(...xs);
|
|
2149
|
+
const minY = Math.min(...ys), maxY = Math.max(...ys);
|
|
2150
|
+
const bw = maxX - minX || 1, bh = maxY - minY || 1;
|
|
2151
|
+
const scaleX = (W - pad * 2) / bw;
|
|
2152
|
+
const scaleY = (H - pad * 2) / bh;
|
|
2153
|
+
const k = Math.min(scaleX, scaleY, 2);
|
|
2154
|
+
const tx = W / 2 - k * (minX + bw / 2);
|
|
2155
|
+
const ty = H / 2 - k * (minY + bh / 2);
|
|
2156
|
+
svg.transition().duration(600)
|
|
2157
|
+
.call(zoomBehaviour.transform, d3.zoomIdentity.translate(tx, ty).scale(k));
|
|
2158
|
+
document.getElementById('deps-svg').classList.add('settled');
|
|
2159
|
+
depsIsCentered = true;
|
|
2160
|
+
updateButtons();
|
|
2161
|
+
const loader = document.getElementById('deps-loading');
|
|
2162
|
+
if (loader) loader.classList.add('hidden');
|
|
2163
|
+
});
|
|
2164
|
+
}
|
|
2165
|
+
</script>
|
|
2166
|
+
</body>
|
|
2167
|
+
</html>`;
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
// ─── HTTP server ──────────────────────────────────────────────────────────────
|
|
2171
|
+
|
|
2172
|
+
/** Build the /api/data payload — called per-request so it's always fresh. */
|
|
2173
|
+
function buildApiData() {
|
|
2174
|
+
const ROOT_ESC = ROOT.replace(/\\/g, '/');
|
|
2175
|
+
const TIPS = buildTipsMap(
|
|
2176
|
+
(() => {
|
|
2177
|
+
// Build the same fileMap as generateHtml uses, without duplicating all the scan logic
|
|
2178
|
+
const fileMap = {};
|
|
2179
|
+
const add = (dir, list) => {
|
|
2180
|
+
for (const n of list) fileMap[`${dir}/${n}`] = path.join(ROOT, dir, `${n}.ts`);
|
|
2181
|
+
};
|
|
2182
|
+
add('services', scanTs('services'));
|
|
2183
|
+
add('interfaces', scanTs('interfaces'));
|
|
2184
|
+
add('types', scanTs('types'));
|
|
2185
|
+
add('enums', scanTs('enums'));
|
|
2186
|
+
add('artifact-builder', scanTs('artifact-builder'));
|
|
2187
|
+
add('artifact-builder/helpers', scanTs('artifact-builder/helpers'));
|
|
2188
|
+
add('controls', scanTs('controls'));
|
|
2189
|
+
add('helpers', scanTs('helpers'));
|
|
2190
|
+
add('page-overrides', scanTs('page-overrides'));
|
|
2191
|
+
add('page-extensions', [...scanTs('page-extensions'), 'SubjectRegistry']);
|
|
2192
|
+
const compDir = path.join(ROOT, 'components');
|
|
2193
|
+
if (fs.existsSync(compDir)) {
|
|
2194
|
+
for (const e of fs.readdirSync(compDir, { withFileTypes: true })) {
|
|
2195
|
+
if (!e.isDirectory()) continue;
|
|
2196
|
+
const label = e.name
|
|
2197
|
+
.split('-')
|
|
2198
|
+
.map((w) => w[0].toUpperCase() + w.slice(1))
|
|
2199
|
+
.join('');
|
|
2200
|
+
const cand = path.join(compDir, e.name, `${label}.ts`);
|
|
2201
|
+
if (fs.existsSync(cand)) fileMap[`components/${label}`] = cand;
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
return fileMap;
|
|
2205
|
+
})()
|
|
2206
|
+
);
|
|
2207
|
+
|
|
2208
|
+
const graph = _cachedDepGraph;
|
|
2209
|
+
return {
|
|
2210
|
+
rootPath: ROOT_ESC,
|
|
2211
|
+
tips: TIPS,
|
|
2212
|
+
graphNodes: graph.nodes.map((n) => ({
|
|
2213
|
+
id: n.id,
|
|
2214
|
+
name: n.name,
|
|
2215
|
+
group: n.group,
|
|
2216
|
+
path: `${ROOT_ESC}/${n.rel}`,
|
|
2217
|
+
tip: TIPS[n.name] || '',
|
|
2218
|
+
color: n.color
|
|
2219
|
+
})),
|
|
2220
|
+
graphLinks: graph.links
|
|
2221
|
+
};
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
export async function diagram(port = 3333) {
|
|
2225
|
+
const server = http.createServer((req, res) => {
|
|
2226
|
+
try {
|
|
2227
|
+
if (req.url === '/api/data') {
|
|
2228
|
+
const data = buildApiData();
|
|
2229
|
+
const json = JSON.stringify(data);
|
|
2230
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
2231
|
+
res.end(json);
|
|
2232
|
+
return;
|
|
2233
|
+
}
|
|
2234
|
+
const html = generateHtml();
|
|
2235
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
2236
|
+
res.end(html);
|
|
2237
|
+
} catch (err) {
|
|
2238
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
2239
|
+
res.end(`Error: ${err.message}\n${err.stack}`);
|
|
2240
|
+
}
|
|
2241
|
+
});
|
|
2242
|
+
|
|
2243
|
+
await new Promise((resolve, reject) => {
|
|
2244
|
+
server.listen(port, '127.0.0.1', resolve);
|
|
2245
|
+
server.on('error', reject);
|
|
2246
|
+
});
|
|
2247
|
+
|
|
2248
|
+
const url = `http://localhost:${port}`;
|
|
2249
|
+
console.log(`\n✔ Testwise Architecture Diagram → ${url}`);
|
|
2250
|
+
console.log(' Refresh the page to regenerate from current source files.\n');
|
|
2251
|
+
console.log(' Ctrl+C to stop.\n');
|
|
2252
|
+
|
|
2253
|
+
try {
|
|
2254
|
+
const { execSync } = await import('node:child_process');
|
|
2255
|
+
const open =
|
|
2256
|
+
process.platform === 'win32'
|
|
2257
|
+
? `start "" "${url}"`
|
|
2258
|
+
: process.platform === 'darwin'
|
|
2259
|
+
? `open "${url}"`
|
|
2260
|
+
: `xdg-open "${url}"`;
|
|
2261
|
+
execSync(open);
|
|
2262
|
+
} catch {
|
|
2263
|
+
/* best-effort */
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
await new Promise(() => {});
|
|
2267
|
+
}
|