From e5e8acbc40acdba59b84163452e0c316ae2c8925 Mon Sep 17 00:00:00 2001 From: Jeff Spahr Date: Thu, 22 Oct 2026 19:32:20 -0400 Subject: [PATCH] feat(frontend): modernize the Pipelines UI Signed-off-by: Jeff Spahr --- .github/resources/ci-workflow-inventory.json | 6 +- .github/resources/scripts/artifact-files.sh | 22 +- .../scripts/artifact_download_callers_test.py | 16 + .../artifact_download_completeness_test.py | 177 +- .../artifact_download_triggers_test.py | 22 +- .../scripts/configure_frontend_apt.py | 60 + .../scripts/configure_frontend_apt_test.py | 79 + .../frontend_qualification_workflow_test.py | 91 + .github/workflows/frontend.yml | 125 +- docs/agents/ci.md | 7 +- docs/agents/frontend.md | 6 +- .../api/kubeflow-pipeline-api-spec.md | 23 + frontend/.gitignore | 3 +- frontend/CONTRIBUTING.md | 4 +- frontend/README.md | 24 +- frontend/components.json | 20 + frontend/docs/browser-support.md | 116 + .../docs/react-18-19-upgrade-checklist.md | 6 +- .../docs/ui-modernization/architecture.md | 85 + .../ui-modernization/archived-evidence.json | 1620 ++++ .../browser-accessibility-qualification.md | 97 + .../docs/ui-modernization/evidence-archive.md | 11 + frontend/docs/ui-modernization/foundation.md | 71 + .../hosted-browser-qualification.md | 220 + frontend/docs/ui-modernization/inspection.md | 37 + .../docs/ui-modernization/layout-stability.md | 110 + .../performance-comparison.md | 73 + .../performance-qualification.md | 86 + .../docs/ui-modernization/release-notes.md | 125 + .../release-qualification-plan.md | 198 + frontend/docs/ui-modernization/runs.md | 51 + .../supported-browser-qualification.md | 117 + frontend/docs/ui-modernization/workflows.md | 178 + frontend/index.html | 6 +- frontend/mock-backend/complex-run-samples.ts | 254 + frontend/mock-backend/fixed-data.ts | 219 +- frontend/mock-backend/mock-api-middleware.ts | 215 +- frontend/mock-backend/sample-run-tasks.ts | 408 + frontend/package-lock.json | 2575 +++--- frontend/package.json | 42 +- frontend/public/fonts/jetbrains-mono/OFL.txt | 93 + frontend/public/fonts/public-sans/OFL.txt | 93 + frontend/public/static/README.md | 5 + frontend/public/static/kubeflow-pipelines.svg | 1 + frontend/scripts/browser-launch.mjs | 34 + frontend/scripts/complex-run-samples.test.ts | 116 + frontend/scripts/editor-workers.test.mjs | 78 + .../fixtures/performance-protocol.json | 43 + .../scripts/performance-evidence.check.mjs | 491 + frontend/scripts/performance-evidence.mjs | 323 + frontend/scripts/precompress-assets.mjs | 43 + frontend/scripts/precompress-assets.test.mjs | 57 + .../production-bundle-diagnostics.check.mjs | 34 + .../scripts/production-bundle-diagnostics.mjs | 50 + frontend/scripts/production-bundle.smoke.mjs | 253 +- frontend/scripts/run-browser-suite.check.mjs | 97 + frontend/scripts/run-browser-suite.mjs | 129 + frontend/scripts/sample-run-tasks.test.ts | 146 + frontend/scripts/standalone-viewer.smoke.mjs | 4 +- frontend/scripts/subdag-expansion.smoke.mjs | 2 +- frontend/scripts/subdag-interaction.smoke.mjs | 10 +- frontend/scripts/tailwind.test.mjs | 77 + frontend/scripts/third-party-notices.mjs | 116 + frontend/scripts/third-party-notices.test.mjs | 101 + frontend/scripts/typescript-libs.check.mjs | 43 + .../ui-modernization-artifacts.smoke.mjs | 952 ++ .../ui-modernization-comparison.smoke.mjs | 837 ++ .../scripts/ui-modernization-graph.smoke.mjs | 850 ++ .../ui-modernization-pipelines.smoke.mjs | 722 ++ .../ui-modernization-run-details.smoke.mjs | 1275 +++ .../scripts/ui-modernization-runs.smoke.mjs | 843 ++ .../ui-modernization-workflows.smoke.mjs | 1007 +++ .../ui-smoke-test/capture-screenshots.js | 10 +- frontend/server/app.ts | 4 + .../integration-tests/mock-api-server.test.ts | 133 +- .../mock-backend-routes.test.ts | 206 +- frontend/server/static-assets.test.ts | 253 + frontend/server/static-assets.ts | 87 + frontend/src/Css.test.tsx | 62 - frontend/src/Css.tsx | 409 - frontend/src/TestUtils.ts | 2 +- frontend/src/__mocks__/typestyle.js | 33 - frontend/src/atoms/BusyButton.test.tsx | 19 +- frontend/src/atoms/BusyButton.tsx | 86 +- frontend/src/atoms/CardTooltip.tsx | 54 - frontend/src/atoms/ErrorBoundary.css | 34 + frontend/src/atoms/ErrorBoundary.test.tsx | 40 +- frontend/src/atoms/ErrorBoundary.tsx | 28 +- frontend/src/atoms/ExternalLink.test.tsx | 52 + frontend/src/atoms/ExternalLink.tsx | 35 +- frontend/src/atoms/HelpButton.test.tsx | 74 +- frontend/src/atoms/HelpButton.tsx | 47 +- frontend/src/atoms/IconWithTooltip.test.tsx | 41 - frontend/src/atoms/IconWithTooltip.tsx | 45 - frontend/src/atoms/Input.test.tsx | 44 - frontend/src/atoms/Input.tsx | 47 - frontend/src/atoms/MD2Tabs.test.tsx | 96 - frontend/src/atoms/MD2Tabs.tsx | 148 - frontend/src/atoms/Separator.test.tsx | 35 - frontend/src/atoms/Separator.tsx | 42 - frontend/src/atoms/SharedAtoms.css | 56 + .../__snapshots__/BusyButton.test.tsx.snap | 131 +- .../src/atoms/__snapshots__/Hr.test.tsx.snap | 9 - .../IconWithTooltip.test.tsx.snap | 39 - .../atoms/__snapshots__/Input.test.tsx.snap | 73 - .../atoms/__snapshots__/MD2Tabs.test.tsx.snap | 87 - .../__snapshots__/Separator.test.tsx.snap | 25 - frontend/src/components/ArtifactPreview.css | 62 + frontend/src/components/ArtifactPreview.tsx | 91 +- frontend/src/components/Banner.test.tsx | 102 - frontend/src/components/Banner.tsx | 199 - .../src/components/CollapseButton.test.tsx | 81 - frontend/src/components/CollapseButton.tsx | 69 - .../components/CollapseButtonSingle.test.tsx | 48 - .../src/components/CollapseButtonSingle.tsx | 63 - frontend/src/components/CompareTable.css | 48 + frontend/src/components/CompareTable.test.tsx | 45 +- frontend/src/components/CompareTable.tsx | 157 +- .../components/CustomTable.modern.test.tsx | 121 + frontend/src/components/CustomTable.test.tsx | 374 +- frontend/src/components/CustomTable.tsx | 648 +- .../components/CustomTableNameColumn.test.tsx | 36 +- .../src/components/CustomTableNameColumn.tsx | 38 +- .../src/components/CustomTableRow.test.tsx | 59 - frontend/src/components/CustomTableRow.tsx | 96 - frontend/src/components/DetailsTable.test.tsx | 522 -- frontend/src/components/DetailsTable.tsx | 127 - frontend/src/components/Editor.css | 58 + frontend/src/components/Editor.test.tsx | 26 + frontend/src/components/Editor.tsx | 9 +- .../src/components/ExperimentList.test.tsx | 24 +- frontend/src/components/ExperimentList.tsx | 51 +- frontend/src/components/LazyEditor.tsx | 36 + frontend/src/components/LogViewer.css | 44 + frontend/src/components/LogViewer.tsx | 83 +- .../src/components/NativeArtifactLineage.css | 177 + .../src/components/NativeArtifactLineage.tsx | 341 +- .../src/components/NativeLineageCanvas.tsx | 32 +- ...st.tsx => PipelineSelectorDialog.test.tsx} | 84 +- ...ialogV2.tsx => PipelineSelectorDialog.tsx} | 129 +- .../PipelineSpecTabContent.test.tsx | 30 +- .../src/components/PipelineSpecTabContent.tsx | 7 +- frontend/src/components/PlotCard.test.tsx | 51 +- frontend/src/components/PlotCard.tsx | 166 +- frontend/src/components/PodYaml.test.tsx | 184 - frontend/src/components/PodYaml.tsx | 141 - .../components/PrivateSharedSelector.test.tsx | 19 +- .../src/components/PrivateSharedSelector.tsx | 82 +- .../src/components/ReduceGraphSwitch.test.tsx | 54 - frontend/src/components/ReduceGraphSwitch.tsx | 68 - frontend/src/components/Router.test.tsx | 340 +- frontend/src/components/Router.tsx | 333 +- frontend/src/components/RunFormFields.css | 141 + ...V2.test.tsx => RunParametersForm.test.tsx} | 217 +- ...ParametersV2.tsx => RunParametersForm.tsx} | 201 +- .../components/RuntimeArtifactRows.test.tsx | 12 + .../src/components/RuntimeArtifactRows.tsx | 18 +- frontend/src/components/SideNav.test.tsx | 432 - frontend/src/components/SideNav.tsx | 693 -- .../components/SidePanel.interaction.test.tsx | 113 - frontend/src/components/SidePanel.test.tsx | 43 - frontend/src/components/SidePanel.tsx | 120 - frontend/src/components/Toolbar.test.tsx | 263 - frontend/src/components/Toolbar.tsx | 220 - frontend/src/components/Trigger.test.tsx | 665 +- frontend/src/components/Trigger.tsx | 784 +- .../components/UploadPipelineDialog.test.tsx | 20 +- .../src/components/UploadPipelineDialog.tsx | 239 +- .../__snapshots__/Banner.test.tsx.snap | 191 - .../CollapseButton.test.tsx.snap | 59 - .../__snapshots__/CompareTable.test.tsx.snap | 123 - .../CustomTableRow.test.tsx.snap | 50 - .../__snapshots__/Description.test.tsx.snap | 8 +- .../__snapshots__/LogViewer.test.tsx.snap | 128 +- .../__snapshots__/PlotCard.test.tsx.snap | 68 - .../PrivateSharedSelector.test.tsx.snap | 276 - .../__snapshots__/Router.test.tsx.snap | 925 +- .../__snapshots__/SideNav.test.tsx.snap | 7991 ----------------- .../__snapshots__/Toolbar.test.tsx.snap | 952 -- .../__snapshots__/Trigger.test.tsx.snap | 3124 ------- .../components/graph/ArtifactNode.test.tsx | 62 +- .../src/components/graph/ArtifactNode.tsx | 85 +- frontend/src/components/graph/Constants.ts | 5 + .../components/graph/ExecutionNode.test.tsx | 175 +- .../src/components/graph/ExecutionNode.tsx | 264 +- frontend/src/components/graph/Graph.css | 377 + .../components/graph/GraphControls.test.tsx | 79 +- .../src/components/graph/GraphControls.tsx | 68 +- .../src/components/graph/PipelineGraph.tsx | 39 +- .../components/graph/SubDagGroupNode.test.tsx | 214 +- .../src/components/graph/SubDagGroupNode.tsx | 113 +- .../src/components/graph/SubDagTypeIcon.tsx | 43 +- .../components/inspection/Inspection.test.tsx | 400 + .../inspection/InspectionFields.tsx | 73 + .../inspection/InspectionNotice.tsx | 40 + .../components/inspection/InspectionPanel.tsx | 183 + .../components/inspection/InspectionTabs.tsx | 64 + .../components/inspection/RunInspection.css | 373 + .../components/navigation/CommandPalette.css | 134 + .../navigation/CommandPalette.test.tsx | 375 + .../components/navigation/CommandPalette.tsx | 291 + .../navigators/PipelineVersionCard.test.tsx | 153 +- .../navigators/PipelineVersionCard.tsx | 127 +- .../pipelines/LatestPipelineVersion.test.tsx | 104 + .../pipelines/LatestPipelineVersion.tsx | 56 + .../pipelines/PipelineCollection.test.tsx | 81 + .../pipelines/PipelineCollection.tsx | 50 + .../pipelines/PipelineDefinitionDetails.tsx | 68 + .../components/pipelines/PipelineForms.css | 110 + .../src/components/pipelines/Pipelines.css | 115 + .../pipelines/UploadedDateFilter.tsx | 71 + .../components/runs/ExperimentRunPreview.css | 15 + .../runs/ExperimentRunPreview.test.tsx | 77 + .../components/runs/ExperimentRunPreview.tsx | 112 + .../components/runs/ExperimentWorkflows.css | 156 + .../components/runs/RecentRunHealth.test.tsx | 42 + .../src/components/runs/RecentRunHealth.tsx | 36 + .../components/runs/RecurringRunOverview.tsx | 73 + .../runs/RecurringRunPipeline.test.tsx | 56 + .../components/runs/RecurringRunPipeline.tsx | 70 + .../runs/RecurringRunSwitch.test.tsx | 261 + .../components/runs/RecurringRunSwitch.tsx | 160 + .../src/components/runs/RunElapsed.test.tsx | 59 + frontend/src/components/runs/RunElapsed.tsx | 43 + .../src/components/runs/RunStatus.test.tsx | 49 + frontend/src/components/runs/RunStatus.tsx | 51 + .../src/components/runs/ScheduleSwitch.tsx | 63 + frontend/src/components/shell/AppShell.css | 396 + .../src/components/shell/AppShell.stories.tsx | 113 + .../src/components/shell/AppShell.test.tsx | 401 + frontend/src/components/shell/AppShell.tsx | 404 + .../src/components/shell/ApplicationShell.css | 30 + .../shell/ApplicationShell.test.tsx | 222 + .../src/components/shell/ApplicationShell.tsx | 99 + frontend/src/components/shell/PageChrome.css | 194 + .../src/components/shell/PageChrome.test.tsx | 568 ++ frontend/src/components/shell/PageChrome.tsx | 337 + .../components/shell/ThemeProvider.test.tsx | 329 + .../src/components/shell/ThemeProvider.tsx | 155 + .../src/components/shell/WorkspaceMenu.tsx | 67 + .../components/tables/CollectionPreview.css | 53 + .../tables/CollectionScopeToggle.tsx | 45 + .../components/tables/CollectionToolbar.tsx | 60 + .../src/components/tables/ColumnFilter.tsx | 139 + .../components/tables/ResourceTable.test.tsx | 245 + .../src/components/tables/ResourceTable.tsx | 443 + frontend/src/components/tables/RunsTable.css | 460 + .../components/tables/RunsTable.stories.tsx | 121 + .../src/components/tables/RunsTable.test.tsx | 208 + frontend/src/components/tables/RunsTable.tsx | 57 + .../src/components/tables/TableFooter.tsx | 37 + .../tables/TablePagination.test.tsx | 75 + .../src/components/tables/TablePagination.tsx | 80 + .../components/tabs/RuntimeInputOutputTab.tsx | 20 +- .../components/tabs/RuntimeNodeDetails.css | 87 + ...2.test.tsx => RuntimeNodeDetails.test.tsx} | 785 +- .../components/tabs/RuntimeNodeDetails.tsx | 1223 +++ .../components/tabs/RuntimeNodeDetailsV2.tsx | 750 -- ...V2.test.tsx => StaticNodeDetails.test.tsx} | 28 +- ...odeDetailsV2.tsx => StaticNodeDetails.tsx} | 32 +- .../components/ui/InputActivation.test.tsx | 207 + .../src/components/ui/Primitives.stories.tsx | 115 + .../src/components/ui/Primitives.test.tsx | 91 + frontend/src/components/ui/alert.css | 20 + .../{atoms/Hr.tsx => components/ui/alert.tsx} | 33 +- frontend/src/components/ui/button.tsx | 55 + frontend/src/components/ui/checkbox.css | 27 + frontend/src/components/ui/checkbox.tsx | 46 + frontend/src/components/ui/dialog.css | 58 + frontend/src/components/ui/dialog.test.tsx | 97 + frontend/src/components/ui/dialog.tsx | 69 + frontend/src/components/ui/input.tsx | 36 + frontend/src/components/ui/notice-details.tsx | 61 + frontend/src/components/ui/switch.css | 55 + frontend/src/components/ui/switch.test.tsx | 50 + frontend/src/components/ui/switch.tsx | 34 + frontend/src/components/ui/text-field.css | 54 + .../src/components/ui/text-field.test.tsx | 89 + frontend/src/components/ui/text-field.tsx | 101 + .../src/components/ui/useInputActivation.ts | 92 + .../Hr.test.tsx => components/ui/utils.ts} | 17 +- .../components/viewers/ComparisonViewers.css | 257 + .../viewers/ConfusionMatrix.test.tsx | 18 + .../components/viewers/ConfusionMatrix.tsx | 116 +- .../components/viewers/HTMLViewer.test.tsx | 25 +- .../src/components/viewers/HTMLViewer.tsx | 48 +- .../viewers/MarkdownViewer.test.tsx | 22 +- .../src/components/viewers/MarkdownViewer.tsx | 42 +- .../components/viewers/PagedTable.test.tsx | 66 +- .../src/components/viewers/PagedTable.tsx | 213 +- frontend/src/components/viewers/ROCCurve.tsx | 55 +- .../src/components/viewers/RichViewers.css | 201 + .../RuntimeArtifactComparison.test.tsx | 157 +- .../viewers/RuntimeArtifactComparison.tsx | 345 +- .../RuntimeMetricsVisualizations.test.tsx | 47 +- .../viewers/RuntimeMetricsVisualizations.tsx | 103 +- .../components/viewers/Tensorboard.test.tsx | 93 +- .../src/components/viewers/Tensorboard.tsx | 232 +- .../viewers/ViewerContainer.test.tsx | 4 +- .../viewers/VisualizationNotice.test.tsx | 34 + .../viewers/VisualizationNotice.tsx | 37 + .../ConfusionMatrix.test.tsx.snap | 219 +- .../__snapshots__/HTMLViewer.test.tsx.snap | 8 +- .../MarkdownViewer.test.tsx.snap | 6 +- .../__snapshots__/PagedTable.test.tsx.snap | 1820 ++-- .../__snapshots__/Tensorboard.test.tsx.snap | 130 +- .../ViewerContainer.test.tsx.snap | 522 +- .../pipeline_spec/google/protobuf/any.ts | 12 +- .../pipeline_spec/google/protobuf/duration.ts | 12 +- .../pipeline_spec/google/protobuf/struct.ts | 12 +- .../pipeline_spec/google/rpc/status.ts | 12 +- .../generated/pipeline_spec/pipeline_spec.ts | 12 +- .../google/protobuf/struct.ts | 12 +- .../kubernetes_platform/index.ts | 5 +- .../kubernetes_executor_config.ts | 12 +- frontend/src/icons/kubeflowLogo.tsx | 112 + frontend/src/icons/statusCached.tsx | 41 - frontend/src/index.css | 12 + frontend/src/index.tsx | 36 +- frontend/src/lib/ApiFilter.test.ts | 36 + frontend/src/lib/ApiFilter.ts | 27 + frontend/src/lib/Buttons.ts | 61 +- frontend/src/lib/DetailsTableTypes.ts | 5 + frontend/src/lib/LocalStorage.test.ts | 84 +- frontend/src/lib/LocalStorage.ts | 92 +- frontend/src/lib/PageChromeTypes.ts | 67 + .../src/lib/PipelineVersionDiscovery.test.ts | 104 + frontend/src/lib/PipelineVersionDiscovery.ts | 86 + frontend/src/lib/ScheduleDisplay.test.ts | 74 + frontend/src/lib/ScheduleDisplay.ts | 123 + frontend/src/lib/StatusUtils.test.tsx | 50 +- frontend/src/lib/StatusUtils.ts | 50 +- frontend/src/lib/v2/FlowNodeSize.test.ts | 8 +- frontend/src/lib/v2/FlowNodeSize.ts | 29 +- frontend/src/lib/v2/GroupedFlow.test.ts | 43 +- frontend/src/lib/v2/GroupedFlow.ts | 9 +- frontend/src/lib/v2/RuntimeArtifactUtils.ts | 4 +- frontend/src/lib/v2/StaticFlow.ts | 6 +- frontend/src/pages/404.test.tsx | 11 +- frontend/src/pages/404.tsx | 19 +- .../src/pages/AllExperimentsAndArchive.tsx | 76 - frontend/src/pages/AllRunsAndArchive.test.tsx | 64 - frontend/src/pages/AllRunsList.test.tsx | 11 +- frontend/src/pages/AllRunsList.tsx | 16 +- frontend/src/pages/ArchivedExperiments.tsx | 12 +- frontend/src/pages/ArchivedRuns.test.tsx | 11 +- frontend/src/pages/ArchivedRuns.tsx | 16 +- frontend/src/pages/ArtifactDetails.test.tsx | 61 +- frontend/src/pages/ArtifactDetails.tsx | 93 +- frontend/src/pages/ArtifactList.test.tsx | 209 +- frontend/src/pages/ArtifactList.tsx | 190 +- frontend/src/pages/Artifacts.css | 102 + ...x => CompareRunsPage.integration.test.tsx} | 10 +- ...pare.test.tsx => CompareRunsPage.test.tsx} | 12 +- .../{Compare.tsx => CompareRunsPage.tsx} | 6 +- ...test.tsx => CreateExperimentPage.test.tsx} | 6 +- ...xperiment.tsx => CreateExperimentPage.tsx} | 76 +- frontend/src/pages/CreateRunForm.css | 101 + ...wRunV2.test.tsx => CreateRunForm.test.tsx} | 474 +- .../pages/{NewRunV2.tsx => CreateRunForm.tsx} | 529 +- ...itcher.test.tsx => CreateRunPage.test.tsx} | 68 +- .../{NewRunSwitcher.tsx => CreateRunPage.tsx} | 19 +- frontend/src/pages/DetailLayouts.css | 53 + frontend/src/pages/ExperimentDetails.test.tsx | 85 +- frontend/src/pages/ExperimentDetails.tsx | 212 +- frontend/src/pages/ExperimentList.test.tsx | 163 +- frontend/src/pages/ExperimentList.tsx | 157 +- ...er.test.tsx => ExperimentRunTabs.test.tsx} | 29 +- ...nListsRouter.tsx => ExperimentRunTabs.tsx} | 51 +- ...hive.test.tsx => ExperimentsPage.test.tsx} | 26 +- frontend/src/pages/ExperimentsPage.tsx | 70 + frontend/src/pages/FrontendFeatures.test.tsx | 61 + frontend/src/pages/FrontendFeatures.tsx | 113 +- frontend/src/pages/GettingStarted.test.tsx | 6 +- frontend/src/pages/GettingStarted.tsx | 35 +- frontend/src/pages/MetadataTransfer.test.tsx | 4 +- frontend/src/pages/MetadataTransfer.tsx | 136 +- frontend/src/pages/NewPipelineVersion.tsx | 784 -- frontend/src/pages/Page.tsx | 6 +- ....test.tsx => PipelineDetailsPage.test.tsx} | 253 +- ...ineDetails.tsx => PipelineDetailsPage.tsx} | 192 +- ...t.tsx => PipelineDetailsPageTest.test.tsx} | 52 +- frontend/src/pages/PipelineDetailsV2.test.tsx | 272 - frontend/src/pages/PipelineDetailsV2.tsx | 79 - .../src/pages/PipelineDetailsView.test.tsx | 324 + frontend/src/pages/PipelineDetailsView.tsx | 108 + frontend/src/pages/PipelineList.test.tsx | 289 +- frontend/src/pages/PipelineList.tsx | 243 +- frontend/src/pages/PipelineVersionList.tsx | 119 +- frontend/src/pages/PipelinesPage.test.tsx | 194 + ...dSharedPipelines.tsx => PipelinesPage.tsx} | 49 +- .../pages/PrivateAndSharedPipelines.test.tsx | 109 - ...urringRunDetailsPage.integration.test.tsx} | 8 +- ...t.tsx => RecurringRunDetailsPage.test.tsx} | 10 +- ...Router.tsx => RecurringRunDetailsPage.tsx} | 19 +- ...t.tsx => RecurringRunDetailsView.test.tsx} | 56 +- ...ailsV2.tsx => RecurringRunDetailsView.tsx} | 79 +- frontend/src/pages/RecurringRunList.test.tsx | 63 +- frontend/src/pages/RecurringRunList.tsx | 203 +- .../src/pages/RecurringRunsManager.test.tsx | 58 +- frontend/src/pages/RecurringRunsManager.tsx | 62 +- ...st.test.tsx => RecurringRunsPage.test.tsx} | 16 +- ...ringRunsList.tsx => RecurringRunsPage.tsx} | 22 +- frontend/src/pages/ResourceSelector.test.tsx | 58 +- frontend/src/pages/ResourceSelector.tsx | 30 +- frontend/src/pages/RunComparisonView.css | 56 + ...V2.test.tsx => RunComparisonView.test.tsx} | 115 +- .../{CompareV2.tsx => RunComparisonView.tsx} | 169 +- ...sx => RunDetailsPage.integration.test.tsx} | 12 +- ...outer.test.tsx => RunDetailsPage.test.tsx} | 79 +- ...unDetailsRouter.tsx => RunDetailsPage.tsx} | 40 +- ...ilsV2.test.tsx => RunDetailsView.test.tsx} | 792 +- .../{RunDetailsV2.tsx => RunDetailsView.tsx} | 318 +- .../src/pages/RunList.integration.test.tsx | 5 + frontend/src/pages/RunList.modern.test.tsx | 230 + frontend/src/pages/RunList.test.tsx | 152 +- frontend/src/pages/RunList.tsx | 314 +- frontend/src/pages/RunsPage.test.tsx | 96 + .../{AllRunsAndArchive.tsx => RunsPage.tsx} | 51 +- frontend/src/pages/SecondaryPages.css | 113 + frontend/src/pages/StatusV2.test.tsx | 118 - frontend/src/pages/StatusV2.tsx | 108 - ...n.test.tsx => UploadPipelinePage.test.tsx} | 282 +- frontend/src/pages/UploadPipelinePage.tsx | 811 ++ .../src/pages/__snapshots__/404.test.tsx.snap | 33 +- .../AllExperimentsAndArchive.test.tsx.snap | 585 -- .../AllRunsAndArchive.test.tsx.snap | 775 -- .../__snapshots__/AllRunsList.test.tsx.snap | 13 - .../ArchivedExperiments.test.tsx.snap | 2 +- .../__snapshots__/ArchivedRuns.test.tsx.snap | 13 - .../ExperimentDetails.test.tsx.snap | 3300 ++++--- .../ExperimentsPage.test.tsx.snap | 703 ++ .../GettingStarted.test.tsx.snap | 18 +- .../PrivateAndSharedPipelines.test.tsx.snap | 2077 ----- ...x.snap => RecurringRunsPage.test.tsx.snap} | 2 +- .../__snapshots__/StatusV2.test.tsx.snap | 598 -- .../CreateExperimentQueryPage.test.tsx} | 28 +- .../CreateExperimentQueryPage.tsx} | 59 +- .../RecurringRunDetailsQueryPage.test.tsx} | 90 +- .../RecurringRunDetailsQueryPage.tsx} | 76 +- frontend/src/pages/v2/DagCanvas.test.tsx | 41 +- frontend/src/pages/v2/DagCanvas.tsx | 227 +- .../src/pages/v2/DagCanvasKeyboard.test.tsx | 118 +- .../src/pages/v2/DagCanvasPosition.test.tsx | 61 +- frontend/src/pages/v2/RunTimeline.css | 50 +- frontend/src/pages/v2/RunTimeline.test.tsx | 22 +- frontend/src/pages/v2/RunTimeline.tsx | 48 +- frontend/src/standalone/README.md | 6 +- frontend/src/standalone/Viewer.test.tsx | 14 +- frontend/src/standalone/Viewer.tsx | 61 +- frontend/src/standalone/index.tsx | 27 +- frontend/src/standalone/viewer.css | 10 +- frontend/src/styles/modernization.css | 108 + frontend/src/styles/modernization.test.ts | 54 + frontend/src/tailwind.css | 50 +- .../testUtils/{muiSnapshot.ts => snapshot.ts} | 44 +- frontend/tailwind.config.js | 106 +- .../third_party/victory-vendor/37.3.6/LICENSE | 30 + frontend/vite.config.mts | 22 +- sdk/python/kfp/cli/viewer_assets/LICENSES.txt | 1345 +-- sdk/python/kfp/cli/viewer_assets/viewer.css | 3 +- sdk/python/kfp/cli/viewer_assets/viewer.js | 1360 +-- .../helloworld.spec.js | 49 +- .../literal-input.spec.js | 52 +- .../tensorboard-example.spec.js | 49 +- .../frontend-integration-test/test-helpers.js | 39 +- .../test-helpers.test.mjs | 66 +- 467 files changed, 48624 insertions(+), 39021 deletions(-) create mode 100644 .github/resources/scripts/configure_frontend_apt.py create mode 100644 .github/resources/scripts/configure_frontend_apt_test.py create mode 100644 .github/resources/scripts/frontend_qualification_workflow_test.py create mode 100644 frontend/components.json create mode 100644 frontend/docs/browser-support.md create mode 100644 frontend/docs/ui-modernization/architecture.md create mode 100644 frontend/docs/ui-modernization/archived-evidence.json create mode 100644 frontend/docs/ui-modernization/browser-accessibility-qualification.md create mode 100644 frontend/docs/ui-modernization/evidence-archive.md create mode 100644 frontend/docs/ui-modernization/foundation.md create mode 100644 frontend/docs/ui-modernization/hosted-browser-qualification.md create mode 100644 frontend/docs/ui-modernization/inspection.md create mode 100644 frontend/docs/ui-modernization/layout-stability.md create mode 100644 frontend/docs/ui-modernization/performance-comparison.md create mode 100644 frontend/docs/ui-modernization/performance-qualification.md create mode 100644 frontend/docs/ui-modernization/release-notes.md create mode 100644 frontend/docs/ui-modernization/release-qualification-plan.md create mode 100644 frontend/docs/ui-modernization/runs.md create mode 100644 frontend/docs/ui-modernization/supported-browser-qualification.md create mode 100644 frontend/docs/ui-modernization/workflows.md create mode 100644 frontend/mock-backend/complex-run-samples.ts create mode 100644 frontend/mock-backend/sample-run-tasks.ts create mode 100644 frontend/public/fonts/jetbrains-mono/OFL.txt create mode 100644 frontend/public/fonts/public-sans/OFL.txt create mode 100644 frontend/public/static/README.md create mode 100644 frontend/public/static/kubeflow-pipelines.svg create mode 100644 frontend/scripts/browser-launch.mjs create mode 100644 frontend/scripts/complex-run-samples.test.ts create mode 100644 frontend/scripts/editor-workers.test.mjs create mode 100644 frontend/scripts/fixtures/performance-protocol.json create mode 100644 frontend/scripts/performance-evidence.check.mjs create mode 100644 frontend/scripts/performance-evidence.mjs create mode 100644 frontend/scripts/precompress-assets.mjs create mode 100644 frontend/scripts/precompress-assets.test.mjs create mode 100644 frontend/scripts/production-bundle-diagnostics.check.mjs create mode 100644 frontend/scripts/production-bundle-diagnostics.mjs create mode 100644 frontend/scripts/run-browser-suite.check.mjs create mode 100644 frontend/scripts/run-browser-suite.mjs create mode 100644 frontend/scripts/sample-run-tasks.test.ts create mode 100644 frontend/scripts/tailwind.test.mjs create mode 100644 frontend/scripts/third-party-notices.mjs create mode 100644 frontend/scripts/third-party-notices.test.mjs create mode 100644 frontend/scripts/typescript-libs.check.mjs create mode 100644 frontend/scripts/ui-modernization-artifacts.smoke.mjs create mode 100644 frontend/scripts/ui-modernization-comparison.smoke.mjs create mode 100644 frontend/scripts/ui-modernization-graph.smoke.mjs create mode 100644 frontend/scripts/ui-modernization-pipelines.smoke.mjs create mode 100644 frontend/scripts/ui-modernization-run-details.smoke.mjs create mode 100644 frontend/scripts/ui-modernization-runs.smoke.mjs create mode 100644 frontend/scripts/ui-modernization-workflows.smoke.mjs create mode 100644 frontend/server/static-assets.test.ts create mode 100644 frontend/server/static-assets.ts delete mode 100644 frontend/src/Css.test.tsx delete mode 100644 frontend/src/Css.tsx delete mode 100644 frontend/src/__mocks__/typestyle.js delete mode 100644 frontend/src/atoms/CardTooltip.tsx create mode 100644 frontend/src/atoms/ErrorBoundary.css create mode 100644 frontend/src/atoms/ExternalLink.test.tsx delete mode 100644 frontend/src/atoms/IconWithTooltip.test.tsx delete mode 100644 frontend/src/atoms/IconWithTooltip.tsx delete mode 100644 frontend/src/atoms/Input.test.tsx delete mode 100644 frontend/src/atoms/Input.tsx delete mode 100644 frontend/src/atoms/MD2Tabs.test.tsx delete mode 100644 frontend/src/atoms/MD2Tabs.tsx delete mode 100644 frontend/src/atoms/Separator.test.tsx delete mode 100644 frontend/src/atoms/Separator.tsx create mode 100644 frontend/src/atoms/SharedAtoms.css delete mode 100644 frontend/src/atoms/__snapshots__/Hr.test.tsx.snap delete mode 100644 frontend/src/atoms/__snapshots__/IconWithTooltip.test.tsx.snap delete mode 100644 frontend/src/atoms/__snapshots__/Input.test.tsx.snap delete mode 100644 frontend/src/atoms/__snapshots__/MD2Tabs.test.tsx.snap delete mode 100644 frontend/src/atoms/__snapshots__/Separator.test.tsx.snap create mode 100644 frontend/src/components/ArtifactPreview.css delete mode 100644 frontend/src/components/Banner.test.tsx delete mode 100644 frontend/src/components/Banner.tsx delete mode 100644 frontend/src/components/CollapseButton.test.tsx delete mode 100644 frontend/src/components/CollapseButton.tsx delete mode 100644 frontend/src/components/CollapseButtonSingle.test.tsx delete mode 100644 frontend/src/components/CollapseButtonSingle.tsx create mode 100644 frontend/src/components/CompareTable.css create mode 100644 frontend/src/components/CustomTable.modern.test.tsx delete mode 100644 frontend/src/components/CustomTableRow.test.tsx delete mode 100644 frontend/src/components/CustomTableRow.tsx delete mode 100644 frontend/src/components/DetailsTable.test.tsx delete mode 100644 frontend/src/components/DetailsTable.tsx create mode 100644 frontend/src/components/Editor.css create mode 100644 frontend/src/components/LazyEditor.tsx create mode 100644 frontend/src/components/LogViewer.css create mode 100644 frontend/src/components/NativeArtifactLineage.css rename frontend/src/components/{PipelinesDialogV2.test.tsx => PipelineSelectorDialog.test.tsx} (52%) rename frontend/src/components/{PipelinesDialogV2.tsx => PipelineSelectorDialog.tsx} (57%) delete mode 100644 frontend/src/components/PodYaml.test.tsx delete mode 100644 frontend/src/components/PodYaml.tsx delete mode 100644 frontend/src/components/ReduceGraphSwitch.test.tsx delete mode 100644 frontend/src/components/ReduceGraphSwitch.tsx create mode 100644 frontend/src/components/RunFormFields.css rename frontend/src/components/{NewRunParametersV2.test.tsx => RunParametersForm.test.tsx} (87%) rename frontend/src/components/{NewRunParametersV2.tsx => RunParametersForm.tsx} (66%) delete mode 100644 frontend/src/components/SideNav.test.tsx delete mode 100644 frontend/src/components/SideNav.tsx delete mode 100644 frontend/src/components/SidePanel.interaction.test.tsx delete mode 100644 frontend/src/components/SidePanel.test.tsx delete mode 100644 frontend/src/components/SidePanel.tsx delete mode 100644 frontend/src/components/Toolbar.test.tsx delete mode 100644 frontend/src/components/Toolbar.tsx delete mode 100644 frontend/src/components/__snapshots__/Banner.test.tsx.snap delete mode 100644 frontend/src/components/__snapshots__/CollapseButton.test.tsx.snap delete mode 100644 frontend/src/components/__snapshots__/CompareTable.test.tsx.snap delete mode 100644 frontend/src/components/__snapshots__/CustomTableRow.test.tsx.snap delete mode 100644 frontend/src/components/__snapshots__/PlotCard.test.tsx.snap delete mode 100644 frontend/src/components/__snapshots__/PrivateSharedSelector.test.tsx.snap delete mode 100644 frontend/src/components/__snapshots__/SideNav.test.tsx.snap delete mode 100644 frontend/src/components/__snapshots__/Toolbar.test.tsx.snap delete mode 100644 frontend/src/components/__snapshots__/Trigger.test.tsx.snap create mode 100644 frontend/src/components/graph/Graph.css create mode 100644 frontend/src/components/inspection/Inspection.test.tsx create mode 100644 frontend/src/components/inspection/InspectionFields.tsx create mode 100644 frontend/src/components/inspection/InspectionNotice.tsx create mode 100644 frontend/src/components/inspection/InspectionPanel.tsx create mode 100644 frontend/src/components/inspection/InspectionTabs.tsx create mode 100644 frontend/src/components/inspection/RunInspection.css create mode 100644 frontend/src/components/navigation/CommandPalette.css create mode 100644 frontend/src/components/navigation/CommandPalette.test.tsx create mode 100644 frontend/src/components/navigation/CommandPalette.tsx create mode 100644 frontend/src/components/pipelines/LatestPipelineVersion.test.tsx create mode 100644 frontend/src/components/pipelines/LatestPipelineVersion.tsx create mode 100644 frontend/src/components/pipelines/PipelineCollection.test.tsx create mode 100644 frontend/src/components/pipelines/PipelineCollection.tsx create mode 100644 frontend/src/components/pipelines/PipelineDefinitionDetails.tsx create mode 100644 frontend/src/components/pipelines/PipelineForms.css create mode 100644 frontend/src/components/pipelines/Pipelines.css create mode 100644 frontend/src/components/pipelines/UploadedDateFilter.tsx create mode 100644 frontend/src/components/runs/ExperimentRunPreview.css create mode 100644 frontend/src/components/runs/ExperimentRunPreview.test.tsx create mode 100644 frontend/src/components/runs/ExperimentRunPreview.tsx create mode 100644 frontend/src/components/runs/ExperimentWorkflows.css create mode 100644 frontend/src/components/runs/RecentRunHealth.test.tsx create mode 100644 frontend/src/components/runs/RecentRunHealth.tsx create mode 100644 frontend/src/components/runs/RecurringRunOverview.tsx create mode 100644 frontend/src/components/runs/RecurringRunPipeline.test.tsx create mode 100644 frontend/src/components/runs/RecurringRunPipeline.tsx create mode 100644 frontend/src/components/runs/RecurringRunSwitch.test.tsx create mode 100644 frontend/src/components/runs/RecurringRunSwitch.tsx create mode 100644 frontend/src/components/runs/RunElapsed.test.tsx create mode 100644 frontend/src/components/runs/RunElapsed.tsx create mode 100644 frontend/src/components/runs/RunStatus.test.tsx create mode 100644 frontend/src/components/runs/RunStatus.tsx create mode 100644 frontend/src/components/runs/ScheduleSwitch.tsx create mode 100644 frontend/src/components/shell/AppShell.css create mode 100644 frontend/src/components/shell/AppShell.stories.tsx create mode 100644 frontend/src/components/shell/AppShell.test.tsx create mode 100644 frontend/src/components/shell/AppShell.tsx create mode 100644 frontend/src/components/shell/ApplicationShell.css create mode 100644 frontend/src/components/shell/ApplicationShell.test.tsx create mode 100644 frontend/src/components/shell/ApplicationShell.tsx create mode 100644 frontend/src/components/shell/PageChrome.css create mode 100644 frontend/src/components/shell/PageChrome.test.tsx create mode 100644 frontend/src/components/shell/PageChrome.tsx create mode 100644 frontend/src/components/shell/ThemeProvider.test.tsx create mode 100644 frontend/src/components/shell/ThemeProvider.tsx create mode 100644 frontend/src/components/shell/WorkspaceMenu.tsx create mode 100644 frontend/src/components/tables/CollectionPreview.css create mode 100644 frontend/src/components/tables/CollectionScopeToggle.tsx create mode 100644 frontend/src/components/tables/CollectionToolbar.tsx create mode 100644 frontend/src/components/tables/ColumnFilter.tsx create mode 100644 frontend/src/components/tables/ResourceTable.test.tsx create mode 100644 frontend/src/components/tables/ResourceTable.tsx create mode 100644 frontend/src/components/tables/RunsTable.css create mode 100644 frontend/src/components/tables/RunsTable.stories.tsx create mode 100644 frontend/src/components/tables/RunsTable.test.tsx create mode 100644 frontend/src/components/tables/RunsTable.tsx create mode 100644 frontend/src/components/tables/TableFooter.tsx create mode 100644 frontend/src/components/tables/TablePagination.test.tsx create mode 100644 frontend/src/components/tables/TablePagination.tsx create mode 100644 frontend/src/components/tabs/RuntimeNodeDetails.css rename frontend/src/components/tabs/{RuntimeNodeDetailsV2.test.tsx => RuntimeNodeDetails.test.tsx} (53%) create mode 100644 frontend/src/components/tabs/RuntimeNodeDetails.tsx delete mode 100644 frontend/src/components/tabs/RuntimeNodeDetailsV2.tsx rename frontend/src/components/tabs/{StaticNodeDetailsV2.test.tsx => StaticNodeDetails.test.tsx} (92%) rename frontend/src/components/tabs/{StaticNodeDetailsV2.tsx => StaticNodeDetails.tsx} (87%) create mode 100644 frontend/src/components/ui/InputActivation.test.tsx create mode 100644 frontend/src/components/ui/Primitives.stories.tsx create mode 100644 frontend/src/components/ui/Primitives.test.tsx create mode 100644 frontend/src/components/ui/alert.css rename frontend/src/{atoms/Hr.tsx => components/ui/alert.tsx} (53%) create mode 100644 frontend/src/components/ui/button.tsx create mode 100644 frontend/src/components/ui/checkbox.css create mode 100644 frontend/src/components/ui/checkbox.tsx create mode 100644 frontend/src/components/ui/dialog.css create mode 100644 frontend/src/components/ui/dialog.test.tsx create mode 100644 frontend/src/components/ui/dialog.tsx create mode 100644 frontend/src/components/ui/input.tsx create mode 100644 frontend/src/components/ui/notice-details.tsx create mode 100644 frontend/src/components/ui/switch.css create mode 100644 frontend/src/components/ui/switch.test.tsx create mode 100644 frontend/src/components/ui/switch.tsx create mode 100644 frontend/src/components/ui/text-field.css create mode 100644 frontend/src/components/ui/text-field.test.tsx create mode 100644 frontend/src/components/ui/text-field.tsx create mode 100644 frontend/src/components/ui/useInputActivation.ts rename frontend/src/{atoms/Hr.test.tsx => components/ui/utils.ts} (61%) create mode 100644 frontend/src/components/viewers/ComparisonViewers.css create mode 100644 frontend/src/components/viewers/RichViewers.css create mode 100644 frontend/src/components/viewers/VisualizationNotice.test.tsx create mode 100644 frontend/src/components/viewers/VisualizationNotice.tsx create mode 100644 frontend/src/icons/kubeflowLogo.tsx delete mode 100644 frontend/src/icons/statusCached.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/lib/ApiFilter.test.ts create mode 100644 frontend/src/lib/ApiFilter.ts create mode 100644 frontend/src/lib/PageChromeTypes.ts create mode 100644 frontend/src/lib/PipelineVersionDiscovery.test.ts create mode 100644 frontend/src/lib/PipelineVersionDiscovery.ts create mode 100644 frontend/src/lib/ScheduleDisplay.test.ts create mode 100644 frontend/src/lib/ScheduleDisplay.ts delete mode 100644 frontend/src/pages/AllExperimentsAndArchive.tsx delete mode 100644 frontend/src/pages/AllRunsAndArchive.test.tsx create mode 100644 frontend/src/pages/Artifacts.css rename frontend/src/pages/{Compare.integration.test.tsx => CompareRunsPage.integration.test.tsx} (89%) rename frontend/src/pages/{Compare.test.tsx => CompareRunsPage.test.tsx} (81%) rename frontend/src/pages/{Compare.tsx => CompareRunsPage.tsx} (89%) rename frontend/src/pages/{NewExperiment.test.tsx => CreateExperimentPage.test.tsx} (97%) rename frontend/src/pages/{NewExperiment.tsx => CreateExperimentPage.tsx} (75%) create mode 100644 frontend/src/pages/CreateRunForm.css rename frontend/src/pages/{NewRunV2.test.tsx => CreateRunForm.test.tsx} (72%) rename frontend/src/pages/{NewRunV2.tsx => CreateRunForm.tsx} (77%) rename frontend/src/pages/{NewRunSwitcher.test.tsx => CreateRunPage.test.tsx} (90%) rename frontend/src/pages/{NewRunSwitcher.tsx => CreateRunPage.tsx} (94%) create mode 100644 frontend/src/pages/DetailLayouts.css rename frontend/src/pages/{RunListsRouter.test.tsx => ExperimentRunTabs.test.tsx} (81%) rename frontend/src/pages/{RunListsRouter.tsx => ExperimentRunTabs.tsx} (61%) rename frontend/src/pages/{AllExperimentsAndArchive.test.tsx => ExperimentsPage.test.tsx} (67%) create mode 100644 frontend/src/pages/ExperimentsPage.tsx create mode 100644 frontend/src/pages/FrontendFeatures.test.tsx delete mode 100644 frontend/src/pages/NewPipelineVersion.tsx rename frontend/src/pages/{PipelineDetails.test.tsx => PipelineDetailsPage.test.tsx} (76%) rename frontend/src/pages/{PipelineDetails.tsx => PipelineDetailsPage.tsx} (69%) rename frontend/src/pages/{PipelineDetailsTest.test.tsx => PipelineDetailsPageTest.test.tsx} (72%) delete mode 100644 frontend/src/pages/PipelineDetailsV2.test.tsx delete mode 100644 frontend/src/pages/PipelineDetailsV2.tsx create mode 100644 frontend/src/pages/PipelineDetailsView.test.tsx create mode 100644 frontend/src/pages/PipelineDetailsView.tsx create mode 100644 frontend/src/pages/PipelinesPage.test.tsx rename frontend/src/pages/{PrivateAndSharedPipelines.tsx => PipelinesPage.tsx} (66%) delete mode 100644 frontend/src/pages/PrivateAndSharedPipelines.test.tsx rename frontend/src/pages/{RecurringRunDetailsRouter.integration.test.tsx => RecurringRunDetailsPage.integration.test.tsx} (94%) rename frontend/src/pages/{RecurringRunDetailsRouter.test.tsx => RecurringRunDetailsPage.test.tsx} (94%) rename frontend/src/pages/{RecurringRunDetailsRouter.tsx => RecurringRunDetailsPage.tsx} (82%) rename frontend/src/pages/{RecurringRunDetailsV2.test.tsx => RecurringRunDetailsView.test.tsx} (89%) rename frontend/src/pages/{RecurringRunDetailsV2.tsx => RecurringRunDetailsView.tsx} (60%) rename frontend/src/pages/{AllRecurringRunsList.test.tsx => RecurringRunsPage.test.tsx} (88%) rename frontend/src/pages/{AllRecurringRunsList.tsx => RecurringRunsPage.tsx} (80%) create mode 100644 frontend/src/pages/RunComparisonView.css rename frontend/src/pages/{CompareV2.test.tsx => RunComparisonView.test.tsx} (90%) rename frontend/src/pages/{CompareV2.tsx => RunComparisonView.tsx} (85%) rename frontend/src/pages/{RunDetailsRouter.integration.test.tsx => RunDetailsPage.integration.test.tsx} (89%) rename frontend/src/pages/{RunDetailsRouter.test.tsx => RunDetailsPage.test.tsx} (95%) rename frontend/src/pages/{RunDetailsRouter.tsx => RunDetailsPage.tsx} (90%) rename frontend/src/pages/{RunDetailsV2.test.tsx => RunDetailsView.test.tsx} (69%) rename frontend/src/pages/{RunDetailsV2.tsx => RunDetailsView.tsx} (71%) create mode 100644 frontend/src/pages/RunList.modern.test.tsx create mode 100644 frontend/src/pages/RunsPage.test.tsx rename frontend/src/pages/{AllRunsAndArchive.tsx => RunsPage.tsx} (51%) create mode 100644 frontend/src/pages/SecondaryPages.css delete mode 100644 frontend/src/pages/StatusV2.test.tsx delete mode 100644 frontend/src/pages/StatusV2.tsx rename frontend/src/pages/{NewPipelineVersion.test.tsx => UploadPipelinePage.test.tsx} (68%) create mode 100644 frontend/src/pages/UploadPipelinePage.tsx delete mode 100644 frontend/src/pages/__snapshots__/AllExperimentsAndArchive.test.tsx.snap delete mode 100644 frontend/src/pages/__snapshots__/AllRunsAndArchive.test.tsx.snap delete mode 100644 frontend/src/pages/__snapshots__/AllRunsList.test.tsx.snap delete mode 100644 frontend/src/pages/__snapshots__/ArchivedRuns.test.tsx.snap create mode 100644 frontend/src/pages/__snapshots__/ExperimentsPage.test.tsx.snap delete mode 100644 frontend/src/pages/__snapshots__/PrivateAndSharedPipelines.test.tsx.snap rename frontend/src/pages/__snapshots__/{AllRecurringRunsList.test.tsx.snap => RecurringRunsPage.test.tsx.snap} (87%) delete mode 100644 frontend/src/pages/__snapshots__/StatusV2.test.tsx.snap rename frontend/src/pages/{functional_components/NewExperimentFC.test.tsx => query/CreateExperimentQueryPage.test.tsx} (93%) rename frontend/src/pages/{functional_components/NewExperimentFC.tsx => query/CreateExperimentQueryPage.tsx} (76%) rename frontend/src/pages/{functional_components/RecurringRunDetailsV2FC.test.tsx => query/RecurringRunDetailsQueryPage.test.tsx} (83%) rename frontend/src/pages/{functional_components/RecurringRunDetailsV2FC.tsx => query/RecurringRunDetailsQueryPage.tsx} (71%) create mode 100644 frontend/src/styles/modernization.css create mode 100644 frontend/src/styles/modernization.test.ts rename frontend/src/testUtils/{muiSnapshot.ts => snapshot.ts} (55%) create mode 100644 frontend/third_party/victory-vendor/37.3.6/LICENSE diff --git a/.github/resources/ci-workflow-inventory.json b/.github/resources/ci-workflow-inventory.json index f6be8bd70c1..e99bdd33cba 100644 --- a/.github/resources/ci-workflow-inventory.json +++ b/.github/resources/ci-workflow-inventory.json @@ -357,12 +357,16 @@ { "path": ".github/workflows/frontend.yml", "name": "Frontend Tests", - "header_sha256": "ad213cb370af3e573a7e0c7bdcec60c0a93cb6c882ac2fe3b148934ba46d964b", + "header_sha256": "7db5b97ddafe4c0580c8d61ec01af6170b5bd274f7820316b43351b8adacd608", "pull_request": { "branches": [ "master" ], "paths": [ + ".github/actions/download-artifact-with-retry/**", + ".github/resources/scripts/artifact-files.sh", + ".github/resources/scripts/configure_frontend_apt*", + ".github/resources/scripts/ci-image-artifacts.sh", "frontend/**", "manifests/kustomize/base/installs/multi-user/pipelines-profile-controller/sync.py", "api/v2alpha1/*.proto", diff --git a/.github/resources/scripts/artifact-files.sh b/.github/resources/scripts/artifact-files.sh index 5c23f6c10b6..72fd834e8b4 100755 --- a/.github/resources/scripts/artifact-files.sh +++ b/.github/resources/scripts/artifact-files.sh @@ -31,12 +31,32 @@ if [[ -z "${required_files//[[:space:]]/}" ]]; then fi # Preparation and download must resolve the destination identically. Unlike # the download action, shell paths do not expand a tilde stored in a variable. -case "$DOWNLOAD_PATH" in +case "${DOWNLOAD_PATH:-}" in ""|"~"*) echo "::error::required-files needs an absolute or workspace-relative destination without tilde expansion" exit 2 ;; esac +# The download action returns native Windows paths even for relative inputs. +# Git Bash needs the equivalent POSIX path before physical-ancestor checks. +case "$DOWNLOAD_PATH" in + [[:alpha:]]:[\\/]*) + # cygpath collapses parent components; reject them before conversion so + # cleanup cannot hide traversal into a different directory. + windows_destination="${DOWNLOAD_PATH//\\//}" + case "$windows_destination/" in + */../*) + echo "::error::Artifact destination must not contain parent traversal: $DOWNLOAD_PATH" + exit 2 + ;; + esac + if ! command -v cygpath >/dev/null 2>&1; then + echo "::error::Windows artifact destinations require Git Bash cygpath" + exit 2 + fi + DOWNLOAD_PATH="$(cygpath -u -- "$DOWNLOAD_PATH")" + ;; +esac # Check the destination before following it or creating anything. Relative # paths start at the physical working directory; absolute paths must already # use physical ancestors (for example /private/tmp, not macOS's /tmp alias). diff --git a/.github/resources/scripts/artifact_download_callers_test.py b/.github/resources/scripts/artifact_download_callers_test.py index b7e41d43657..ad11dae40ed 100644 --- a/.github/resources/scripts/artifact_download_callers_test.py +++ b/.github/resources/scripts/artifact_download_callers_test.py @@ -32,6 +32,7 @@ WRAPPER = './.github/actions/download-artifact-with-retry' WRAPPERS = {WRAPPER, './tooling/.github/actions/download-artifact-with-retry'} CALLERS = { + ('.github/workflows/frontend.yml', 'browser-tests'), ('.github/actions/deploy/action.yml', 'composite'), ('.github/workflows/arm64-presubmit.yml', 'smoke'), ('.github/workflows/build-tools-images.yml', 'compare-generated'), @@ -228,6 +229,21 @@ def test_release_qualification_manifest_matches_attempt_bound_lane_artifacts( self.assertNotIn('continue-on-error', download) self.assertNotIn('if', verify) + def test_browser_bundle_uses_workspace_relative_paths_on_windows(self): + steps = workflow('frontend.yml')['jobs']['browser-tests']['steps'] + download = next(step for step in steps if step.get('uses') == WRAPPER) + self.assertEqual(download['with']['path'], 'frontend/.ci-bundle') + self.assertEqual( + download['with']['required-files'], 'frontend-bundle.tar.gz' + ) + extract = next( + step + for step in steps + if step.get('name') == 'Extract complete production bundle' + ) + self.assertIn('.ci-bundle/frontend-bundle.tar.gz', extract['run']) + self.assertNotIn('RUNNER_TEMP', extract['run']) + def test_deploy_manifest_matches_ci_producers_and_modelcar_selection(self): built_images = { item['image'] diff --git a/.github/resources/scripts/artifact_download_completeness_test.py b/.github/resources/scripts/artifact_download_completeness_test.py index c2c7a6cbb4f..3cf698a7f28 100644 --- a/.github/resources/scripts/artifact_download_completeness_test.py +++ b/.github/resources/scripts/artifact_download_completeness_test.py @@ -20,6 +20,7 @@ import tempfile import unittest from pathlib import Path +from unittest import mock import yaml from artifact_workflow_test_support import evaluate, render @@ -51,6 +52,7 @@ def __init__(self, path, attempts, required_files=None): self.path = path self.attempts = attempts self.downloads = [] + self.download_output_path = None self.steps = {} self.failed = False self.cancelled = False @@ -119,7 +121,10 @@ def run(self): destination.mkdir(parents=True, exist_ok=True) outcome = attempt(destination, download_inputs) if outcome == 'success': - outputs['download-path'] = os.path.abspath(destination) + outputs['download-path'] = ( + self.download_output_path + or os.path.abspath(destination) + ) else: if step['shell'] != 'bash': raise AssertionError(f'Unexpected shell: {step}') @@ -213,6 +218,36 @@ def test_complete_first_attempt_does_not_retry(self): output = runner.action['outputs']['download-path']['value'] self.assertEqual(runner.render(output), str(self.path)) + def test_windows_native_action_output_validates_complete_download(self): + tools = Path(self.directory.name) / 'bin' + tools.mkdir() + converter = tools / 'cygpath' + converter.write_text( + '#!/bin/sh\n' + '[ "$1" = "-u" ] && [ "$2" = "--" ] && ' + '[ "$3" = "$CYGPATH_INPUT" ] || exit 9\n' + 'printf "%s\\n" "$CYGPATH_TARGET"\n' + ) + converter.chmod(0o755) + runner = CompositeAction( + self.path, [download(files=self.complete)], self.required + ) + runner.download_output_path = r'D:\a\pipelines\downloads' + with mock.patch.dict( + os.environ, + { + 'PATH': str(tools) + os.pathsep + os.environ['PATH'], + 'CYGPATH_TARGET': str(self.path), + 'CYGPATH_INPUT': runner.download_output_path, + }, + ): + result = runner.run() + self.assertEqual(result, 'success', runner.log) + self.assertEqual(runner.downloads, ['primary']) + self.assertEqual( + runner.steps['verify-primary']['outputs']['complete'], 'true' + ) + def test_download_defaults_resolve_the_github_context(self): received = [] @@ -527,6 +562,146 @@ def test_physical_absolute_and_relative_destinations_are_supported(self): ) self.assertTrue((self.path / 'new/nested').is_dir()) + def test_native_windows_download_output_uses_actual_destination(self): + tools = self.path / 'bin' + tools.mkdir() + converter = tools / 'cygpath' + converter.write_text('#!/bin/sh\nprintf "%s\\n" "$CYGPATH_TARGET"\n') + converter.chmod(0o755) + target = self.path / 'download' + target.mkdir() + with mock.patch.dict( + os.environ, + { + 'PATH': str(tools) + os.pathsep + os.environ['PATH'], + 'CYGPATH_TARGET': str(target), + }, + ): + for destination in (r'D:\a\_temp\download', 'D:/a/_temp/download'): + with self.subTest(destination=destination): + archive = target / 'archive.tar' + archive.write_text('complete') + result = self.run_helper( + 'verify', 'archive.tar', destination + ) + self.assertEqual( + result.returncode, 0, result.stdout + result.stderr + ) + result = self.run_helper( + 'prepare', 'archive.tar', destination + ) + self.assertEqual( + result.returncode, 0, result.stdout + result.stderr + ) + self.assertFalse(archive.exists()) + + def test_normalized_windows_destinations_keep_symlink_and_traversal_guards( + self, + ): + tools = self.path / 'bin' + tools.mkdir() + converter = tools / 'cygpath' + converter.write_text('#!/bin/sh\nprintf "%s\\n" "$CYGPATH_TARGET"\n') + converter.chmod(0o755) + outside = self.path / 'outside' + outside.mkdir() + sentinel = outside / 'archive.tar' + sentinel.write_text('keep') + link = self.path / 'link' + link.symlink_to(outside, target_is_directory=True) + for target in (str(link), str(self.path / 'missing/../outside')): + with mock.patch.dict( + os.environ, + { + 'PATH': str(tools) + os.pathsep + os.environ['PATH'], + 'CYGPATH_TARGET': target, + }, + ): + for mode in ('prepare', 'verify', 'check'): + result = self.run_helper( + mode, 'archive.tar', r'D:\a\download' + ) + self.assertEqual( + result.returncode, 2, result.stdout + result.stderr + ) + self.assertEqual(sentinel.read_text(), 'keep') + self.assertFalse((self.path / 'missing').exists()) + + def test_windows_parent_traversal_is_rejected_before_normalization(self): + tools = self.path / 'bin' + tools.mkdir() + converter = tools / 'cygpath' + converter.write_text( + '#!/bin/sh\ntouch "$CONVERSION_MARKER"\nprintf "%s\\n" "$CYGPATH_TARGET"\n' + ) + converter.chmod(0o755) + outside = self.path / 'outside' + outside.mkdir() + sentinel = outside / 'archive.tar' + sentinel.write_text('keep') + marker = self.path / 'converted' + with mock.patch.dict( + os.environ, + { + 'PATH': str(tools) + os.pathsep + os.environ['PATH'], + 'CYGPATH_TARGET': str(outside), + 'CONVERSION_MARKER': str(marker), + }, + ): + for destination in ( + r'D:\allowed\..\outside', + 'D:/allowed/../outside', + ): + for mode in ('prepare', 'verify', 'check'): + result = self.run_helper(mode, 'archive.tar', destination) + self.assertEqual( + result.returncode, 2, result.stdout + result.stderr + ) + self.assertFalse(marker.exists()) + self.assertEqual(sentinel.read_text(), 'keep') + + def test_windows_conversion_failure_preserves_existing_files(self): + tools = self.path / 'bin' + tools.mkdir() + converter = tools / 'cygpath' + converter.write_text('#!/bin/sh\nexit 7\n') + converter.chmod(0o755) + sentinel = self.path / 'archive.tar' + sentinel.write_text('keep') + with mock.patch.dict( + os.environ, + { + 'PATH': str(tools) + os.pathsep + os.environ['PATH'], + }, + ): + result = self.run_helper('prepare', 'archive.tar', r'D:\a\download') + self.assertEqual(result.returncode, 7, result.stdout + result.stderr) + self.assertEqual(sentinel.read_text(), 'keep') + + def test_unset_destination_fails_before_touching_files(self): + sentinel = self.path / 'archive.tar' + sentinel.write_text('keep') + environment = dict(os.environ, REQUIRED_FILES='archive.tar') + environment.pop('DOWNLOAD_PATH', None) + for mode in ('prepare', 'verify', 'check'): + with self.subTest(mode=mode): + result = subprocess.run( + ['bash', str(ARTIFACT_FILES), mode], + env=environment, + cwd=self.path, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual( + result.returncode, 2, result.stdout + result.stderr + ) + self.assertIn( + 'absolute or workspace-relative destination', result.stdout + ) + self.assertNotIn('unbound variable', result.stderr) + self.assertEqual(sentinel.read_text(), 'keep') + def test_empty_or_parent_traversal_destination_is_rejected(self): for destination in ('', '../outside', 'new/../outside'): with self.subTest(destination=destination): diff --git a/.github/resources/scripts/artifact_download_triggers_test.py b/.github/resources/scripts/artifact_download_triggers_test.py index e978b61e78d..62ccfb6bc1f 100644 --- a/.github/resources/scripts/artifact_download_triggers_test.py +++ b/.github/resources/scripts/artifact_download_triggers_test.py @@ -73,8 +73,28 @@ def test_changes_trigger_all_pr_consumers(self): cwd=ROOT, ) triggered = set(json.loads(result.stdout)) + # Master-only qualification must not be required on release PRs. + branch_script = f""" +const gate = require({json.dumps(str(MODULE))}); +const {{inventory}} = gate.loadLocalInventory(process.cwd()); +console.log(JSON.stringify(inventory.workflows.filter(workflow => + workflow.pull_request !== null && gate.applicable( + Object.fromEntries(Object.entries(workflow.pull_request).filter( + ([key]) => key !== 'paths' && key !== 'paths-ignore')), + {json.dumps(branch)}, [])) + .map(workflow => workflow.path))); +""" + eligible = subprocess.run( + ['node'], + input=branch_script, + check=True, + capture_output=True, + text=True, + cwd=ROOT, + ) + required = consumers & set(json.loads(eligible.stdout)) self.assertTrue( - consumers.issubset(triggered), consumers - triggered + required.issubset(triggered), required - triggered ) diff --git a/.github/resources/scripts/configure_frontend_apt.py b/.github/resources/scripts/configure_frontend_apt.py new file mode 100644 index 00000000000..8657914559a --- /dev/null +++ b/.github/resources/scripts/configure_frontend_apt.py @@ -0,0 +1,60 @@ +# Copyright 2026 The Kubeflow Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Bound browser dependency acquisition on disposable hosted Ubuntu runners.""" + +import os +import re +from pathlib import Path + +AZURE_MIRROR = re.compile( + r'https?://azure\.archive\.ubuntu\.com/ubuntu(?=[/\s]|$)' +) +APT_LIMITS = """Acquire::http::Timeout "30"; +Acquire::https::Timeout "30"; +Acquire::Retries "2"; +""" + + +def configure(root): + sources = [root / 'apt-mirrors.txt', root / 'sources.list'] + for pattern in ('*.list', '*.sources'): + sources.extend((root / 'sources.list.d').glob(pattern)) + for source in sources: + if source.is_file(): + before = source.read_text() + after = AZURE_MIRROR.sub( + 'https://archive.ubuntu.com/ubuntu', before + ) + if after != before: + source.write_text(after) + print( + f'Replaced unavailable Azure Ubuntu mirror in {source.name}' + ) + (root / 'apt.conf.d' / '99-kfp-browser-acquisition').write_text(APT_LIMITS) + + +def main(): + if not ( + os.environ.get('CI') == 'true' + and os.environ.get('GITHUB_ACTIONS') == 'true' + and os.environ.get('RUNNER_ENVIRONMENT') == 'github-hosted' + ): + raise RuntimeError( + 'APT configuration requires a disposable GitHub runner' + ) + configure(Path('/etc/apt')) + + +if __name__ == '__main__': + main() diff --git a/.github/resources/scripts/configure_frontend_apt_test.py b/.github/resources/scripts/configure_frontend_apt_test.py new file mode 100644 index 00000000000..427d21c5c8c --- /dev/null +++ b/.github/resources/scripts/configure_frontend_apt_test.py @@ -0,0 +1,79 @@ +# Copyright 2026 The Kubeflow Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import os +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import configure_frontend_apt as apt + + +class FrontendAptTest(unittest.TestCase): + def test_mirror_replacement_preserves_suites_signatures_and_other_sources( + self, + ): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / 'apt.conf.d').mkdir() + (root / 'sources.list.d').mkdir() + body = ( + 'URIs: http://azure.archive.ubuntu.com/ubuntu\n' + 'Suites: noble noble-updates\n' + 'Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n' + 'https://packages.microsoft.com/ubuntu/24.04/prod\n' + 'https://azure.archive.ubuntu.com/ubuntu-other\n' + ) + sources = [ + root / 'apt-mirrors.txt', + root / 'sources.list', + root / 'sources.list.d/ubuntu.sources', + root / 'sources.list.d/legacy.list', + ] + for source in sources: + source.write_text(body) + apt.configure(root) + for source in sources: + self.assertEqual( + source.read_text(), + body.replace( + 'http://azure.archive.ubuntu.com/ubuntu\n', + 'https://archive.ubuntu.com/ubuntu\n', + ), + ) + limits = root / 'apt.conf.d/99-kfp-browser-acquisition' + self.assertIn('Acquire::http::Timeout "30";', limits.read_text()) + self.assertIn('Acquire::https::Timeout "30";', limits.read_text()) + self.assertIn('Acquire::Retries "2";', limits.read_text()) + apt.configure(root) + self.assertEqual(limits.read_text(), apt.APT_LIMITS) + + def test_local_and_self_hosted_execution_cannot_modify_apt(self): + for environment in ( + {}, + { + 'CI': 'true', + 'GITHUB_ACTIONS': 'true', + 'RUNNER_ENVIRONMENT': 'self-hosted', + }, + ): + with mock.patch.dict(os.environ, environment, clear=True): + with mock.patch.object(apt, 'configure') as configure: + with self.assertRaisesRegex(RuntimeError, 'disposable'): + apt.main() + configure.assert_not_called() + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/resources/scripts/frontend_qualification_workflow_test.py b/.github/resources/scripts/frontend_qualification_workflow_test.py new file mode 100644 index 00000000000..f07e0a3303e --- /dev/null +++ b/.github/resources/scripts/frontend_qualification_workflow_test.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +# Copyright 2026 The Kubeflow Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Keep frontend presubmit validation fast and complete.""" + +import json +import unittest +from itertools import product +from pathlib import Path + +import yaml +from generate_ci_workflow_inventory import UniqueKeyLoader + +ROOT = Path(__file__).resolve().parents[3] + + +def workflow(name): + return yaml.load( + (ROOT / '.github/workflows' / name).read_text(), Loader=UniqueKeyLoader + ) + + +def lanes(job): + matrix = job['strategy']['matrix'] + excluded = { + (row['os'], row['browser']) for row in matrix.get('exclude', []) + } + return set(product(matrix['os'], matrix['browser'])) - excluded + + +class FrontendQualificationWorkflowTest(unittest.TestCase): + def test_presubmit_bundles_after_validation_without_repeating_it(self): + scripts = json.loads((ROOT / 'frontend/package.json').read_text())[ + 'scripts' + ] + steps = workflow('frontend.yml')['jobs']['frontend-tests']['steps'] + checks = next( + i + for i, step in enumerate(steps) + if 'npm run test:ci' in step.get('run', '') + ) + bundle = next( + i + for i, step in enumerate(steps) + if 'npm run build:bundle' in step.get('run', '') + ) + self.assertLess(checks, bundle) + for check in ('lint', 'typecheck'): + self.assertIn(f'npm run {check}', scripts['test:ci']) + self.assertIn(f'npm run {check}', scripts['build']) + self.assertNotIn(check, scripts['build:bundle']) + self.assertIn('npm run build:tailwind', scripts['build:bundle']) + self.assertIn('vite build', scripts['build:bundle']) + self.assertNotIn('npm run build', scripts['test:ci']) + + def test_presubmit_runs_all_retained_node_check_suites(self): + steps = workflow('frontend.yml')['jobs']['frontend-tests']['steps'] + commands = '\n'.join(step.get('run', '') for step in steps) + scripts = json.loads((ROOT / 'frontend/package.json').read_text())[ + 'scripts' + ] + commands += '\n' + scripts['test:browser-runner'] + for suite in (ROOT / 'frontend/scripts').glob('*.check.mjs'): + with self.subTest(suite=suite.name): + self.assertIn(f'scripts/{suite.name}', commands) + + def test_presubmit_runs_linux_chromium_against_shared_bundle(self): + presubmit = workflow('frontend.yml') + self.assertEqual( + lanes(presubmit['jobs']['browser-tests']), + {('ubuntu-latest', 'chromium')}, + ) + self.assertIn('pull_request', presubmit['on']) + self.assertEqual( + presubmit['jobs']['browser-tests']['needs'], 'frontend-tests' + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index ac88928155d..31d729f8eca 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -8,6 +8,10 @@ on: branches: - master paths: + - ".github/actions/download-artifact-with-retry/**" + - ".github/resources/scripts/artifact-files.sh" + - ".github/resources/scripts/configure_frontend_apt*" + - ".github/resources/scripts/ci-image-artifacts.sh" - 'frontend/**' - 'manifests/kustomize/base/installs/multi-user/pipelines-profile-controller/sync.py' - 'api/v2alpha1/*.proto' @@ -65,6 +69,9 @@ jobs: - name: Install dependencies run: cd ./frontend && npm ci + - name: Verify frontend standard-library declarations + run: node --test frontend/scripts/typescript-libs.check.mjs + - name: Verify generated API clients are up to date run: | cd ./frontend @@ -91,8 +98,120 @@ jobs: - name: Run Frontend Tests run: cd ./frontend && npm run test:ci - - name: Verify production bundle startup + - name: Verify browser evidence runner + working-directory: frontend + run: | + npm run test:browser-runner + node --test scripts/production-bundle-diagnostics.check.mjs scripts/performance-evidence.check.mjs + + - name: Build shared production bundle working-directory: frontend run: | - npx playwright install --with-deps chromium - npm run test:bundle + npm run build:bundle + tar -czf frontend-bundle.tar.gz -C build . + + - name: Upload shared production bundle + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: frontend-production-bundle + path: frontend/frontend-bundle.tar.gz + if-no-files-found: error + overwrite: true + retention-days: 7 + + browser-tests: + name: Browser / ${{ matrix.os }} / ${{ matrix.browser }} + needs: frontend-tests + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + browser: [chromium] + defaults: + run: + shell: bash + working-directory: frontend + env: + KFP_BROWSER: ${{ matrix.browser }} + KFP_BROWSER_REPORT_DIR: browser-results + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 + with: + node-version-file: frontend/.nvmrc + + - name: Set up npm + run: | + npm install --global "$(node -p 'require("./package.json").packageManager')" + npm --version + + - name: Install browser test dependencies + # The shared bundle needs neither native build scripts nor nested server installs. + run: npm ci --ignore-scripts + + - name: Download shared production bundle + uses: ./.github/actions/download-artifact-with-retry + with: + name: frontend-production-bundle + path: frontend/.ci-bundle + required-files: frontend-bundle.tar.gz + + - name: Extract complete production bundle + run: | + mkdir -p build + tar -xzf ".ci-bundle/frontend-bundle.tar.gz" -C build + + - name: Configure bounded Ubuntu browser dependencies + if: runner.os == 'Linux' + run: sudo --preserve-env=CI,GITHUB_ACTIONS,RUNNER_ENVIRONMENT python3 ../.github/resources/scripts/configure_frontend_apt.py + + - name: Install browser + timeout-minutes: 15 + run: npx playwright install --with-deps "${KFP_BROWSER}" + + - name: Run full production browser suite + id: suite + run: npm run test:bundle:built + + - name: Record workflow outcome + if: always() + env: + JOB_STATUS: ${{ job.status }} + SUITE_OUTCOME: ${{ steps.suite.outcome }} + run: | + node --input-type=module <<'NODE' + import { mkdirSync, writeFileSync } from 'node:fs'; + import { arch, platform, release } from 'node:os'; + const directory = process.env.KFP_BROWSER_REPORT_DIR; + mkdirSync(directory, { recursive: true }); + writeFileSync(`${directory}/workflow.json`, JSON.stringify({ + sourceSha: process.env.GITHUB_SHA, + runId: process.env.GITHUB_RUN_ID, + runAttempt: process.env.GITHUB_RUN_ATTEMPT, + runnerOS: process.env.RUNNER_OS, + runnerImage: process.env.ImageOS, + runnerImageVersion: process.env.ImageVersion, + platform: platform(), release: release(), arch: arch(), + requestedEngine: process.env.KFP_BROWSER, + jobStatus: process.env.JOB_STATUS, + suiteOutcome: process.env.SUITE_OUTCOME, + }, null, 2) + '\n'); + NODE + + - name: Upload browser evidence + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: frontend-browser-${{ matrix.os }}-${{ matrix.browser }} + path: frontend/browser-results + if-no-files-found: error + overwrite: true + retention-days: 14 diff --git a/docs/agents/ci.md b/docs/agents/ci.md index 5f4e1429b61..52c9768bb8c 100644 --- a/docs/agents/ci.md +++ b/docs/agents/ci.md @@ -39,7 +39,8 @@ GitHub Actions workflows are in `.github/workflows/`; reusable composite actions - `validate-generated-files.yml` validates backend-generated outputs. Go-based API generator versions come from the root or `backend/api/tools` Go modules; a lightweight change detector skips this expensive validation for unrelated Go module updates. API generator toolchain upgrades must include every affected committed output and a root `go mod tidy`. Regenerated clients can also change handwritten integration points; go-swagger v0.36 and later pass request contexts through the operation's `...Context` method instead of a public parameter `Context` field. `frontend.yml` runs `npm run apis:all` and rejects stale frontend clients. - Presubmit enforces the bounded [Go version policy](go-version-policy.md): tracked modules, explicitly registered builder `FROM` lines, and the two setup-go actions must agree with the root compiler. A path-filtered job inspects each pinned builder digest and verifies its Linux AMD64 and ARM64 image configurations declare the expected Go version. Upstream tag changes do not invalidate existing pins; Dependabot proposes image updates. - Changes to `frontend/server/handlers/artifacts.ts` trigger `e2e-test.yml` so the multi-user artifact-proxy and SeaweedFS namespace-isolation checks run before artifact authorization changes merge. -- `frontend.yml` installs the standalone mock backend's nested dependency tree, and its server integration suite constructs that app through `frontend/mock-backend` so router upgrades are tested against the package that actually runs locally. It also installs Playwright Chromium and runs `npm run test:bundle`: a production build and browser startup check with empty API responses. This catches bundle-only initialization errors that Vitest's import transforms can mask. Ace modes and extensions belong in `Editor.tsx`, after the ReactAce import that initializes the global. +- `frontend.yml` installs the standalone mock backend's nested dependency tree, and its server integration suite constructs that app through `frontend/mock-backend` so router upgrades are tested against the package that actually runs locally. It builds the production bundle once after the Linux generation, unit, and server checks, then passes that immutable artifact to the required Linux Chromium browser job. Broad browser/device, historical performance and deployment qualification are maintained in [#14757](https://github.com/kubeflow/pipelines/pull/14757), separately from the application presubmit; reliability and promotion remain tracked in #14754. The presubmit runs the full production fixture suite through `npm run test:bundle:built`, including startup, navigation, mutation/recovery, keyboard, responsive, graph, comparison, and artifact behavior. `npm run test:bundle` remains the local build-and-test entry point. Browser lanes use `npm ci --ignore-scripts` because they consume the already-built artifact and do not need nested server installations or native build scripts. They do not run the POSIX-only `test:ci` command on Windows. The runner pins each suite to the browser version observed at preflight, records bundle hashes and OS/browser metadata, and writes TAP plus JUnit results. Every lane uploads available evidence on failure; setup failures have a separate workflow outcome record. Linux Chromium failures are blocking. Playwright engines provide automated cross-platform regression coverage, not certification of branded Safari, mobile devices, ESR, or Extended Stable versions. This also catches bundle-only initialization errors that Vitest's import transforms can mask. Ace modes and extensions belong in `Editor.tsx`, after the ReactAce import that initializes the global. + - Frontend integration tests require Node >=22.12 because of the `@puppeteer/browsers` 3.x pin explained in `test/frontend-integration-test/README.md`. `e2e-test-frontend.yml` runs `npm run test:dependencies` once in a plain Node job alongside the Kind lanes; the checks cover configuration merging, the installer API WebdriverIO calls, remote Selenium setup, and selector readiness across navigation. Controls shared across routes must be reselected on every readiness poll (`waitForSelectorDisplayed`), rather than waiting on an outgoing DOM element after the URL hash changes. Keep the pin, the README rationale, and the checks aligned. - `frontend.yml` sets up Python for the server integration test that feeds actual profile-controller artifact repositories into archived-log retrieval. Generate its configurations in one asynchronous `beforeAll` subprocess with a bounded setup timeout: cold Python startup must not consume each endpoint test's five-second budget. Keep the real producer and all endpoint/credential assertions, rather than replacing the output with handwritten fixtures. Changes to `pipelines-profile-controller/sync.py` also trigger this workflow so producer and consumer changes both exercise the test. - `.github/dependabot.yml` schedules weekly updates for every supported dependency ecosystem present in the repository: Go modules, Docker and Kubernetes image references, npm, pip, uv, GitHub Actions, and pre-commit. The root `uv.lock` has its own `uv` entry so its version and security updates are gated by `ci-passed` independently of legacy `pip` entries. New PRs rely on required `ci-passed` status enforcement and Tide policy; existing PR-specific `do-not-merge/hold` labels remain manual. The coordinated activation procedure is documented in [ci-passed.md](ci-passed.md). npm and pip updates are grouped by dependency name across their monorepo directories; generated Python API-client compatibility constraints remain excluded. Python coverage is checked against tracked manifests, including the root uv workspace, not dependencies installed into `.venv`. @@ -230,3 +231,7 @@ is inspected by the packaging regression suite to prevent tracked exports from r invoked without `gh`; missing runner dependencies are not publication delays. - `release-promote-latest.yml` is upstream/master dispatch-only. Its read-only job derives the image matrix from the immutable published release tag commit. The serialized promotion job alone has `packages: write`, rechecks highest stable release eligibility, snapshots every published full index, and copies only `@digest` to `:latest` without builds. It verifies full indexes and unchanged versioned tags and always uploads partial/success evidence. Dispatch through `kfpr promote-latest --version X.Y.Z`; this workflow must first be merged upstream. Test with `PYTHONPATH=release python -m unittest kfpr.promotion_test`. +- Artifact completeness checks normalize native Windows download outputs through Git Bash `cygpath` before checking physical ancestors. Relative inputs still yield native absolute outputs on Windows; preparation and verification must address the same directory. + +- Hosted Ubuntu frontend browser jobs replace the unavailable Azure Ubuntu mirror with the official HTTPS Ubuntu archive while retaining package signature verification, suites and components. Browser dependency downloads use 30-second APT transport timeouts, two retries and a 15-minute installation-step limit. The shared helper refuses local/self-hosted execution; other APT sources remain unchanged. +- The production router defers creation forms (run, experiment, pipeline version) and namespace transfer until navigation; shell and page chrome remain mounted during loading. Production smoke checks ensure these chunks are absent at startup and load through their real routes. Pending/rejected route imports retain loading feedback and navigation-error recovery; the existing entry payload budget remains unchanged. diff --git a/docs/agents/frontend.md b/docs/agents/frontend.md index d03d3ed2e97..4488ce9c6e6 100644 --- a/docs/agents/frontend.md +++ b/docs/agents/frontend.md @@ -1,6 +1,6 @@ # Frontend -The frontend is React 19, TypeScript, MUI, TanStack Query, Vitest, Prettier, and ESLint. Use the Node version in `frontend/.nvmrc` and the npm version pinned by `frontend/package.json`. +The frontend is React 19, TypeScript, Tailwind CSS 4, Base UI, TanStack Query, Vitest, Prettier, and ESLint. Use the Node version in `frontend/.nvmrc` and the npm version pinned by `frontend/package.json`. Keep `.nvmrc` and `package.json`'s Node engine identical when advancing Node. CI, frontend images, release tooling, and browser-integration images read this @@ -30,3 +30,7 @@ Use `npm run mock:api` with `npm start` for mock development, or `npm run start: When effect behavior changes, add or run a focused regression test for duplicate mutation success, refresh-preserved selections, retry recovery, or unexpected mount-time callbacks. Prettier uses single quotes, trailing commas, and a 100-character line width. + +## Component and styling ownership + +Follow [UI component ownership and styling](../../frontend/docs/ui-modernization/architecture.md). Group application components by role; `ui` holds shared primitives. Tailwind/CVA styles small primitives, while colocated stylesheets handle complex layouts with shared tokens. Retain one owner for request state, page chrome, and navigation; changing class syntax alone is not a migration. diff --git a/docs/reference/api/kubeflow-pipeline-api-spec.md b/docs/reference/api/kubeflow-pipeline-api-spec.md index 6dbb64a2210..3e593d62d19 100644 --- a/docs/reference/api/kubeflow-pipeline-api-spec.md +++ b/docs/reference/api/kubeflow-pipeline-api-spec.md @@ -19,6 +19,29 @@ Typically, you will need to provide a token or cookie in the request headers. Please refer to the documentation of your [Kubeflow distribution](https://www.kubeflow.org/docs/started/installing-kubeflow/#kubeflow-distributions) for more information. +### Pipeline creation and list visibility + +With the Kubernetes-native pipeline store, pipeline and pipeline-version list requests read an +informer cache. A successful creation or upload response acknowledges the write; it does not +ensure that an immediately following list request includes the new resource. The cache can take +time to observe it. There is no fixed list-visibility deadline guaranteed by the API. + +Lookups by pipeline or pipeline-version ID fall back to a direct Kubernetes read when the ID is +absent from the cache. This does not bypass a cached match, refresh arbitrary list results, or +provide a global read-after-write guarantee. + +Clients should retain the returned pipeline ID and retry the read when a successful list response +is empty. Do not repeat an acknowledged upload merely because its version is not listed yet; +that can create duplicate resources. Authentication, authorization and other API errors should +remain visible rather than being treated as empty results. + +For a new pipeline uploaded from a local file, the UI waits up to 10 seconds for version discovery. +If discovery times out or fails, **Retry version discovery** reads the already uploaded pipeline +without uploading again. Form values are retained. Changing the selected file, upload metadata +or namespace starts a different upload intent. This recovery identity is local to the open form; +navigating away or reloading does not retain it. The 10-second wait is a UI limit, not an API +consistency guarantee. + ### Example Usage To use the API, you will need to send HTTP requests to the appropriate endpoints. diff --git a/frontend/.gitignore b/frontend/.gitignore index e0165d8295f..185290bdbb7 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -11,6 +11,7 @@ server/dependency-licenses.txt swagger-codegen-cli.jar # testing +/browser-results /coverage # production @@ -42,4 +43,4 @@ src/build storybook-static # yarn.lock for license tool -yarn.lock \ No newline at end of file +yarn.lock diff --git a/frontend/CONTRIBUTING.md b/frontend/CONTRIBUTING.md index 0ea927f0220..185af5a4a3e 100644 --- a/frontend/CONTRIBUTING.md +++ b/frontend/CONTRIBUTING.md @@ -65,7 +65,7 @@ You will see a lot of `npm run xxx` commands in the instructions below. The actu ## Frontend stack - React 19 with TypeScript -- MUI v5 with Emotion +- Tailwind CSS 4 with Base UI - TanStack Query v5 - React Router v8 (declarative hash routing) - Vitest with Testing Library v16 for UI tests @@ -202,7 +202,7 @@ are failing, you can update them automatically with `npm test -u` or ## Frontend coding conventions -- Prefer Testing Library and assertions against user-visible behavior in new UI tests. Avoid Enzyme and implementation-detail testing in new code. For examples, see [frontend/src/pages/ArtifactDetails.test.tsx](src/pages/ArtifactDetails.test.tsx) and [frontend/src/pages/RunDetailsV2.test.tsx](src/pages/RunDetailsV2.test.tsx). +- Prefer Testing Library and assertions against user-visible behavior in new UI tests. Avoid Enzyme and implementation-detail testing in new code. For examples, see [frontend/src/pages/ArtifactDetails.test.tsx](src/pages/ArtifactDetails.test.tsx) and [frontend/src/pages/RunDetailsView.test.tsx](src/pages/RunDetailsView.test.tsx). - Keep snapshot tests small and intentional. Use them as focused regression coverage, not as a substitute for behavioral assertions. - Prefer prop/state-driven data flow over imperative refs. Reach for `useEffect` when synchronizing with systems outside React, not for derived UI state. For more detailed guidance used in reviews, see the [React effects guide](../docs/agents/frontend.md#react-effects). diff --git a/frontend/README.md b/frontend/README.md index 24020b70099..3db053ad39b 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -4,14 +4,34 @@ This section of the codebase contains the Kubeflow Pipelines (KFP) Frontend. ## Current Stack -- React 19 with TypeScript on Vite 7 -- MUI v5 with Emotion +- React 19 with TypeScript on Vite 8 +- Tailwind CSS 4 with Base UI - TanStack Query v5 - React Router v8 (declarative hash routing) - Vitest with Testing Library v16 for UI tests - Vitest for frontend server tests - Storybook 10 for component development +## Third-party notices + +Every production Vite build generates `build/THIRD_PARTY_NOTICES.txt`, served at +`THIRD_PARTY_NOTICES.txt` beneath the UI base URL. Vite inventories the dependencies +in the emitted browser chunks, including lazy-loaded code. The notices include +full license texts, supplemental package notices, the Tailwind license for generated +CSS, and the OFL licenses for copied fonts. This inventory covers browser assets, not the separate Node server. + +A bundled dependency without license text fails the build. Restore its packaged +license or add a reviewed, version-pinned upstream copy. `victory-vendor@37.3.6` +omits its root license from npm; [its retained license](third_party/victory-vendor/37.3.6/LICENSE) +comes from the [matching upstream tag](https://github.com/FormidableLabs/victory/blob/v37.3.6/LICENSE.txt). +Its packaged D3 vendor licenses are included too. Recheck this exception when +upgrading Victory. Generated notices stay in build output, not source control. + +## Browser support + +See the [browser support policy](docs/browser-support.md) for supported channels, +release qualification, and the distinction between product support and compiler targets. + ## Quick Start Development This guide will get you started with development on KFP standalone mode. diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 00000000000..0005b9ab1d0 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/styles/modernization.css", + "baseColor": "neutral", + "cssVariables": true + }, + "iconLibrary": "lucide", + "aliases": { + "components": "src/components", + "ui": "src/components/ui", + "utils": "src/components/ui/utils", + "lib": "src/lib", + "hooks": "src/hooks" + } +} diff --git a/frontend/docs/browser-support.md b/frontend/docs/browser-support.md new file mode 100644 index 00000000000..d5adeb3ace9 --- /dev/null +++ b/frontend/docs/browser-support.md @@ -0,0 +1,116 @@ +# Browser support + +The UI modernization adopts the following browser support policy, approved by a +maintainer on 2026-09-28. Qualification evidence is source-specific and does not automatically qualify a later revision. The [supported-browser checkpoint](ui-modernization/supported-browser-qualification.md) +records actual Chrome/Edge workflow runs and native Firefox stable/ESR smoke; +it does not establish support for every channel or OS below. + +| Browser | Supported releases | +| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| Chrome and Microsoft Edge, desktop | Latest two stable major releases, plus the current Extended Stable release for each browser. | +| Firefox, desktop | Latest two stable major releases, plus the current ESR and the outgoing ESR during Mozilla's supported transition overlap. | +| Safari on macOS | Current and previous annual Safari releases, each at its latest available patch on a vendor-supported macOS version. | +| Safari on iOS and iPadOS | Current and previous annual OS releases, each at its latest available patch. | + +Safari's window means annual releases, not the latest two minor updates. Use +vendor-supported operating systems. Beta, nightly, embedded webviews and other +unlisted browsers are outside the qualification commitment. Older browsers may +continue to work, but KFP does not qualify them or block access by user-agent +version. Browser support does not change Python `kfp.local` behavior or require a +local SDK or artifact migration. + +## Release and build rules + +For each KFP minor release, resolve the channel policy above into a dated matrix +of exact browser and OS versions. Retain browser/OS identities, application +commit, emitted asset hashes, test results and known limitations. Recheck vendor +channels before release rather than treating this document as a permanent version +list. For patch releases, test current supported channels while preserving the +minor release's minimum required browser capabilities and compiler targets. +Intentional increases in required browser capabilities belong in a KFP minor +release and must be announced in its release notes. + +The [production Browserslist](../package.json) and [Vite targets](../vite.config.mts) +currently retain conservative output targets: Chrome/Edge 111, Firefox 128 and +Safari/iOS 16.4. These are compiler compatibility settings, **not KFP's supported +browser matrix**. They accommodate the stack's technical minimums without +requiring qualification of those retired versions. Keep them explicit and aligned; +raising them is not necessary to adopt this policy. Any future target change must +remain compatible with the oldest supported enterprise/Safari release, pass +production build and browser qualification, and refresh asset-specific evidence. +JavaScript syntax transforms do not supply missing runtime APIs or CSS features. + +## Automated qualification + +Qualification uses automated browser workflows, keyboard/focus assertions, +accessibility audits and screenshot regression checks. Manual test sessions are +not part of the delivery plan. Automated coverage does not establish blanket WCAG +conformance or untested screen-reader/speech-control behavior; retain these limits +explicitly instead of treating them as passes. + +Required frontend presubmits retain unit/server tests, lint, type checks, the +production build and the Linux Chromium production fixture suite. [Qualification PR #14757](https://github.com/kubeflow/pipelines/pull/14757) provides all nine +OS/engine lanes, including Linux Chromium, for weekly/manual browser +qualification against the selected source alongside the exact +branded stable/enterprise/Safari matrix. Engine coverage remains separate from +supported-version evidence. Each lane retains its actual browser version, +OS/source identity, bundle hashes and outcomes; a failed lane must not be hidden +by a successful lane or `continue-on-error`. + +Browser qualification runs on disposable hosted CI runners; workstation browser +installation and local Safari/Xcode setup are not prerequisites. The broad qualification workflows are maintained in [qualification PR #14757](https://github.com/kubeflow/pipelines/pull/14757); they are designed to run weekly and on demand after landing. Weekly schedules start only after the workflow lands +on the default branch. Browser, performance and deployment/rollback qualification +are observational and do not block PRs or releases while +[qualification reliability](https://github.com/kubeflow/pipelines/issues/14754) +is being established. The qualification follow-up includes registration in the daily CI health report. +First-attempt failure reporting, deduplicated alerts, overdue-run detection and +triage ownership remain follow-up work alongside repeatability. Promoting these +workflows to required gates needs a separate maintainer decision. Manual runs on +release candidates provide advisory evidence in the meantime. Confirmed product +regressions still require triage; this cadence change does not change supported +browsers, acceptance budgets or failure assertions. +Its [dated catalog and coverage report](ui-modernization/hosted-browser-qualification.md) +separate supported-version evidence from supplementary builds and unavailable slots. +Refresh exact channel pins before release; a successful dated run does not prove +that later vendor releases have been tested. + +Actual Safari and iOS/iPadOS qualification uses automated native-browser/device +sessions. Unavailable environments remain coverage gaps. Investigate +[BrowserStack's open-source program](https://www.browserstack.com/open-source) for +future automated device and version coverage; eligibility and exact available +browser/device versions must be confirmed before relying on it. + +## Qualification before release + +- [x] Adopt the channel policy and retire Chrome/Edge 111, Firefox 128 and Safari/iOS 16.4 as release qualification targets. +- [x] Record a [dated qualification checkpoint](ui-modernization/supported-browser-qualification.md), with exact tested builds and remaining channel/OS gaps. +- [ ] Complete the dated exact-version/OS matrix for the release, including Chrome/Edge Extended Stable and all Firefox ESR releases still in their supported transition. +- [ ] Run the production workflow suite on current and previous stable Chrome/Edge/Firefox and the enterprise releases above. Retain failures and route/action/state results for each actual browser identity. +- [ ] Qualify current and previous annual macOS Safari releases and iOS/iPadOS devices or simulators, recording which was used. Automate touch, zoom, keyboard/focus, dialogs, inspector scrolling and visual-viewport checks in both themes; retain actual device identities and record any unavailable physical-device automation. +- [ ] Verify the final production bundle against the oldest supported versions as well as current releases. Resolve supported-browser failures that block loading, core workflows, data integrity, keyboard access or reachable content before release; document other limitations explicitly. +- [ ] Investigate BrowserStack open-source eligibility and automated Safari/iOS/iPadOS, enterprise-version and desktop-OS coverage. Confirm exact versions, CI integration, result retention and access requirements before proposing account setup. +- [ ] Publish the tested matrix and changed requirements in release notes. Keep deployment, automated accessibility, performance-budget and rollback qualification tracked separately, with untested assistive-technology limitations explicit. + +Playwright Chromium/Firefox/WebKit provide useful engine coverage; they do not +substitute for every branded browser version, Safari device or OS combination. +The [retained reports](ui-modernization/browser-accessibility-qualification.md) +identify their original versions and source assets. The Firefox 128 run remains +historical regression evidence, not qualification of the newly supported ESRs. +Keep the feature-detected input activation fallback and its regression tests; +retiring a version alone does not prove that behavior is absent in every supported +browser. + +The opt-in [native WebDriver smoke](https://github.com/jeffspahr/jeffspahr-pipelines/blob/54f78798fea94aecc64006a8296a9473f453d55a/frontend/scripts/ui-modernization-browser-floor.mjs) +requires `KFP_BROWSER_FLOOR_VERSION` to equal the actual browser's full version. +Set `KFP_WEBDRIVER_BROWSER` to `firefox`, `chrome`, `MicrosoftEdge` or `safari` and +use an isolated matching driver with the existing loopback fixture API and +production preview. The smoke fails if the reported version differs. Its seven +scenarios supplement the full workflow and device checks; they do not replace them. + +## References + +- [Chrome release channels](https://support.google.com/chrome/a/answer/16942104) +- [Microsoft Edge release schedule](https://learn.microsoft.com/en-us/deployedge/microsoft-edge-release-schedule) +- [Firefox ESR release cycle and overlap](https://support.mozilla.org/en-US/kb/firefox-esr-release-cycle) +- [Safari release notes](https://developer.apple.com/documentation/safari-release-notes) +- [Tailwind technical browser requirements](https://tailwindcss.com/docs/compatibility) diff --git a/frontend/docs/react-18-19-upgrade-checklist.md b/frontend/docs/react-18-19-upgrade-checklist.md index 89666c03cc6..86fd2688179 100644 --- a/frontend/docs/react-18-19-upgrade-checklist.md +++ b/frontend/docs/react-18-19-upgrade-checklist.md @@ -132,7 +132,7 @@ Replace deprecated `react-flow-renderer` v9 with `@xyflow/react`, updating the D **Depends on**: #2 **Status**: -Completed by [#12925](https://github.com/kubeflow/pipelines/pull/12925). The repo now uses `@mui/material`, `@mui/icons-material`, and Emotion. +Completed by [#12925](https://github.com/kubeflow/pipelines/pull/12925). That migration introduced `@mui/material`, `@mui/icons-material`, and Emotion. The subsequent [UI modernization](https://github.com/kubeflow/pipelines/issues/14572) replaces them with Tailwind CSS and Base UI. **Description**: Migrate `@material-ui/core` and `@material-ui/icons` to MUI v5, including theme migration and the few non-codemod-safe styling updates. @@ -228,14 +228,14 @@ Completed via [#13075](https://github.com/kubeflow/pipelines/pull/13075) and [#1 - Removed `notifyManager.setNotifyFunction` act() workaround for React Query from `frontend/src/vitest.setup.ts` - Removed `filterReactDeprecationWarnings` utility from `frontend/src/TestUtils.ts` - Updated `frontend/src/components/Metric.test.tsx` and `frontend/src/pages/ExperimentDetails.test.tsx` to remove filter usage -- Updated `frontend/src/pages/ExperimentDetails.test.tsx`, `frontend/src/pages/NewPipelineVersion.test.tsx`, `frontend/src/pages/NewRun.test.tsx`, and `frontend/src/pages/RunDetails.test.tsx` to wrap the remaining direct instance-method and modal interaction flows in explicit `act()` + flush handling +- Updated `frontend/src/pages/ExperimentDetails.test.tsx`, `frontend/src/pages/UploadPipelinePage.test.tsx`, `frontend/src/pages/NewRun.test.tsx`, and `frontend/src/pages/RunDetails.test.tsx` to wrap the remaining direct instance-method and modal interaction flows in explicit `act()` + flush handling - Regenerated all affected snapshots (95 snapshot updates across multiple files) **Acceptance Criteria**: - [x] `npm run check:react-peers:18` passes with an empty allowlist - [x] `npm ci` no longer depends on `legacy-peer-deps=true` for the frontend -- [x] Targeted reruns of `ExperimentDetails`, `NewPipelineVersion`, `NewRun`, and `RunDetails` are clean of React 18 `act(...)` warnings +- [x] Targeted reruns of `ExperimentDetails`, `UploadPipelinePage`, `NewRun`, and `RunDetails` are clean of React 18 `act(...)` warnings - [x] `npm run build` and the affected frontend suite verification pass --- diff --git a/frontend/docs/ui-modernization/architecture.md b/frontend/docs/ui-modernization/architecture.md new file mode 100644 index 00000000000..c292d349b85 --- /dev/null +++ b/frontend/docs/ui-modernization/architecture.md @@ -0,0 +1,85 @@ +# UI component ownership and styling + +## Component roles + +| Directory | Responsibility | +| ----------------------- | ------------------------------------------------------------------------- | +| `components/ui` | Shared accessible primitives and their visual variants. | +| `components/shell` | Application layout, theme, page chrome and deployment navigation framing. | +| `components/navigation` | Command search and navigation controls. | +| `components/tables` | Resource-table presentation, pagination controls and Runs presentation. | +| `components/inspection` | Reusable inspection fields, sections and tabs. | +| `components/pipelines` | Pipeline collection and pipeline form/layout presentation. | +| `components/runs` | Run status, recent-run health and recurring-run controls. | + +Existing graph, viewer and task-tab components keep their established role directories. New components belong with their responsibility, not a migration phase. The residual `atoms` directory is retained compatibility code, not a second destination for new primitives. Storybook identifiers remain stable when source files move. + +## Styling boundary + +Use Tailwind utilities and CVA variants for small shared primitives such as buttons and inputs. Use colocated stylesheets for complex page, table, graph and inspection layouts. Both consume the shared theme tokens; avoid parallel declarations for the same visual property across a primitive's utility classes and a caller's stylesheet. CVA selects utility variants rather than defining a separate theme. Keep class merging inside the shared primitive boundary. + +`src/tailwind.css` is the single owner of the global preflight/reset and layer order. Theme and reset rules are layered; generated utilities intentionally remain unlayered after the reset so that explicit primitive utilities retain their existing precedence alongside component styles. Do not add page-specific resets or compensate for conflicts with escalating specificity. Extend a primitive's supported variant or change its owning stylesheet instead. The global reset, component styles and utility overrides must be considered together when changing the build or Tailwind imports. + +## Page and chrome ownership + +| Concern | Owner and contract | +| ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Resource reads, mutations and loading | The page/controller or its query hook owns requests, pending state, stale-response protection, recovery and payloads. Presentation must not start duplicate requests or independently mirror those states. | +| Toolbar actions | The page defines available actions, icons, variants, eligibility and callbacks through `Buttons` / `PageChromeTypes`. `PageChrome` renders that contract without inferring behavior from action names. Tests exercise the real action definitions through the renderer. | +| Banners, dialogs and notifications | The routed page publishes its messages through the existing chrome callbacks. The route wrapper owns their presentation state and isolates it between pages. Child operations clear only the errors they own; successful child reads must not erase unrelated page errors. | +| Table state | `CustomTable` owns filtering, sorting, page size, cursors and request generations. `ResourceTable` and page-specific table components render those values and dispatch user actions; they do not predict generations or shadow encoded filters. | +| Navigation | The router owns resource paths and the URL owns top-level inspection selection. User navigation pushes history; automatic invalid-selection cleanup replaces it. Local state is reserved for ephemeral controls without a URL contract. | +| Theme and portals | The theme provider owns effective theme and the shared portal class. Components consume that context rather than rereading storage or duplicating theme logic. | + +Class-based `Page` controllers and their functional wrappers remain supported in this migration. The end state is one explicit owner per concern using these same contracts, regardless of component syntax. When a page needs an ownership change, migrate that page as a complete slice: remove the duplicate state/adapter responsibility, preserve resource scope and recovery, and verify navigation, mutation and stale-request behavior. A class-to-function conversion alone does not resolve an ownership problem and is not a prerequisite for this UI release. + +See [workflow contracts](workflows.md) for keyboard, form, namespace and functional compatibility rules. Generated qualification results belong in [retained evidence archives](evidence-archive.md), not in source directories. + +## Component names + +Name route entry points `Page` (for example `RunDetailsPage`, `PipelinesPage`, `CreateRunPage`, and `UploadPipelinePage`). Name their contents by responsibility: `RunDetailsView`, `CreateRunForm`, `PipelineSelectorDialog`, or `RunParametersForm`. A `View` may coordinate interactions; the suffix does not imply that it is stateless. Route URLs are independent of these implementation names. + +Use `WithContext` for small wrappers whose job is injecting namespace/query context, and `Controller` for an exported page controller behind a route wrapper. Use `Router` only for actual route selection, not data loading or an Active/Archived tab control. Avoid presentation names such as `Modern`, `Enhanced`, or `V2` when there is only one implementation. API schemas and format-specific utilities retain meaningful version labels such as `V2beta1Run`. + +The two feature-flagged query-based alternative page implementations live in `pages/query` as `CreateExperimentQueryPage` and `RecurringRunDetailsQueryPage`. They own requests, mutations and chrome, so they are not reusable form/view components. Their distinguishing names and existing feature flag remain until a separate ownership migration retires one implementation. Class pages otherwise remain unchanged. + +## Page layout + +Use a compact workbench layout across resource pages. `PageChrome` owns the title, primary actions, breadcrumbs and 16px page gutter. Align collection controls, tabs and content to that gutter; nested collections inherit the parent content width without adding another gutter. + +List pages place filters on the left and view/sort controls on the right in one wrapping control strip. Selection, rows and pagination share the same collection surface. Use a 52px minimum list header, 48px filter strip, and 40px minimum single-line rows with one comfortable default; allow content and responsive wrapping to grow naturally. Keep pagination compact and stable during filtering. Validate density with same-viewport screenshots and the distance from the page top to the first data row, not just shared CSS. Pipelines use a table with a linked latest-version column. Version management belongs on the pipeline detail page, not in an expanded list row. + +Detail pages use the same header and tab rhythm, with metadata grouped into readable sections. Graphs and timelines retain a large dedicated workspace and task inspectors; metadata views use responsive fields rather than narrow nested cards. Special-purpose content can differ, but spacing and control placement should not depend on the resource type. + +Collections use one compact layout with unchanged readable text sizes. There is no density preference; browser zoom controls overall scaling. Column resizing and remembered column widths remain independent. Namespace-wide import/export belongs in the shell Workspace menu. Pipeline detail pages put the version selector beside the title and full version management in the Versions tab. + + +## Page layout contract + +Collection and detail pages share Public Sans: body and table values 13px/20px, +secondary labels 12px/18px, section headings 16px/24px, and page titles 22px/30px. +Use monospace only for code, structured output, and identifiers where character +alignment is useful; ordinary names and type labels use the body face. +Page gutters are 16px, section gaps 12px, and control gaps 8px. Detail surfaces +use a 16px inset and a consistent label/value grid. Graphs, timelines, editors, +and comparison matrices retain their domain-specific layouts within that frame. + +CollectionToolbar owns the search and view-control strip. ResourceTable owns +cell padding, header styling, selection, scrolling, and pagination chrome. +Rows are 32px minimum without reducing text size; wrapped content can grow. +Declare metadata widths on each Column alongside its renderer: dates 190px, +status 120px, duration 100px, and other metadata 160–200px. Leave primary names +flexible and reserve at least 240px for them in the table minimum width. Wide +schemas scroll horizontally instead of squeezing names or wrapping every cell. + +Show selection only when the collection offers an operation on selected items. +Use the shared 18px checkbox in a 40px selection column; picker dialogs may use +radio selection. Clicking a selectable row toggles selection; links and other +controls perform their own actions. Read-only lists do not show dummy checkboxes. +Primary names use regular-weight foreground text; secondary metadata uses regular +body text with a quieter color. Differences in columns reflect resource semantics, not independent +page styling choices. + +### Expanded collection previews + +Expanded pipelines and experiments show at most five recent versions or runs in a compact list, with shared typography and spacing. Previews have no selection, sort controls or pagination. Their links open full detail-page management; pipeline versions use `?tab=versions` with search, sorting, pagination and bulk deletion. Top-level pipeline selection applies only to pipelines. Version labels must not infer a default from a display name: the v2 API has no default-version field. diff --git a/frontend/docs/ui-modernization/archived-evidence.json b/frontend/docs/ui-modernization/archived-evidence.json new file mode 100644 index 00000000000..700b2b11e46 --- /dev/null +++ b/frontend/docs/ui-modernization/archived-evidence.json @@ -0,0 +1,1620 @@ +{ + "sourceCommit": "54f78798fea94aecc64006a8296a9473f453d55a", + "archiveUrl": "https://raw.githubusercontent.com/kubeflow/pipelines/d9aed93d6184be1f65d36066b40eb92028a2aa25/archives/kfp-ui-evidence-54f78798f.tar.gz", + "archiveSha256": "96c1db7580168763eab5e12f41a6d602750dd2c9d4b220c76063599e7381a5e0", + "files": [ + { + "path": "frontend/docs/ui-modernization/foundation/primitives-dark.png", + "bytes": 61148, + "sha256": "fc9b141e19daeaedafc52e1a0be46aa1f1d8ca7d90c25da3167c364c78684cbf" + }, + { + "path": "frontend/docs/ui-modernization/foundation/primitives-light.png", + "bytes": 65092, + "sha256": "05f7d2be9c15bab7dac9b7148b195bf853809cf20e7efd9b3e5bf96ff9a0acd6" + }, + { + "path": "frontend/docs/ui-modernization/foundation/shell-dark.png", + "bytes": 51693, + "sha256": "29cfb2b45db3b702383bf79d566e82ee92965fc24204d79e2789cdae1206aa53" + }, + { + "path": "frontend/docs/ui-modernization/foundation/shell-light.png", + "bytes": 52119, + "sha256": "ada998526b54630877fab20fe81cf3ed9061f4716e29f7aa1f4da36a05f79bca" + }, + { + "path": "frontend/docs/ui-modernization/inspection/command-palette-dark.png", + "bytes": 80041, + "sha256": "e5148954c0dd1277e15308fd53f3d8dbd6f4be51496f60c996b88ef0f89f54ed" + }, + { + "path": "frontend/docs/ui-modernization/inspection/run-details-dark.png", + "bytes": 97099, + "sha256": "653cf48b3673b7cd8bdca703cd7a49e69edf1f2b54b76872690290663cd54240" + }, + { + "path": "frontend/docs/ui-modernization/layout-stability/browser-matrix.json", + "bytes": 17703, + "sha256": "b4b350f5bef1d558c2c5dab8eaf0a8ff5866915e79f1aaadc9ddfa40f3f7a2ef" + }, + { + "path": "frontend/docs/ui-modernization/layout-stability/bundle-inventory.json", + "bytes": 10306, + "sha256": "a93ca443a95c440485f9f258f7173647315a1aa5e8197c3649d142af64f3a793" + }, + { + "path": "frontend/docs/ui-modernization/layout-stability/comparison-chromium.json", + "bytes": 3320, + "sha256": "02ff583eb5461305b047f96ec8b99944a191d7766dcbb6b5c12a7d2c5fca1a9c" + }, + { + "path": "frontend/docs/ui-modernization/layout-stability/comparison-light.png", + "bytes": 92904, + "sha256": "94dd742fc2ba0da920872d3f9c10672e1012567515fc206b681cc2f381458970" + }, + { + "path": "frontend/docs/ui-modernization/layout-stability/graph-chromium.json", + "bytes": 2974, + "sha256": "de6ec97aa3b38aae30096db673d083bf8a374b854589d99d1477a7988442ed36" + }, + { + "path": "frontend/docs/ui-modernization/layout-stability/hosted-checks.json", + "bytes": 23419, + "sha256": "16b3460f1310fe0d8c24dcaea88d68a1dc3fa109d56cbbeada6df155b27087be" + }, + { + "path": "frontend/docs/ui-modernization/layout-stability/protocol.json", + "bytes": 13307, + "sha256": "5cc230efb17ae76faf831428c8606d5179c48b6fb0c5061e892b1a6ffe14352d" + }, + { + "path": "frontend/docs/ui-modernization/layout-stability/run-details-graph.png", + "bytes": 106971, + "sha256": "f7bac67c23bfbde80b0dcfcbcb932e70852c1c26feee748309ac4b89d8a4a4d9" + }, + { + "path": "frontend/docs/ui-modernization/layout-stability/run-details-narrow-inspector.png", + "bytes": 18157, + "sha256": "9cb946948f894deb9674ced98212bbe7231b1af4cd71a39471678833ec634498" + }, + { + "path": "frontend/docs/ui-modernization/layout-stability/runs-dark.png", + "bytes": 126685, + "sha256": "2cd2aa703c2464d3f87b7927d4e7390dbc72db61db87a95e2bf3d1402ca1a29c" + }, + { + "path": "frontend/docs/ui-modernization/layout-stability/runs-light.png", + "bytes": 123498, + "sha256": "70ee731a62d88ee244e56760d8fdfb8cfb1adac9e8c1ab812731868a63959181" + }, + { + "path": "frontend/docs/ui-modernization/qualification/browser-floor-firefox128.json", + "bytes": 4498, + "sha256": "80f7c16624a02b75700f3a4c39e01a9447955fb53b08d333099e87acdf9ffac1" + }, + { + "path": "frontend/docs/ui-modernization/qualification/browser-matrix.json", + "bytes": 7823, + "sha256": "ebba78623f54edd030e46123f8fa344dd49915afe95d981c1bf4188e6a823beb" + }, + { + "path": "frontend/docs/ui-modernization/qualification/bundle-sizes.json", + "bytes": 6056, + "sha256": "aba6f79cd268ef8d99fe3a0a6d0d034788a9d67f83e43898c7c1fbfafe93132e" + }, + { + "path": "frontend/docs/ui-modernization/qualification/comparison-chromium.json", + "bytes": 3304, + "sha256": "c73fd15d9ba2595467381baea1f0a360753726ecbeadc70fc04447ff6edc48c9" + }, + { + "path": "frontend/docs/ui-modernization/qualification/firefox128/command-dialog-narrow-dark.png", + "bytes": 82257, + "sha256": "b37f527ad62d3a47d5759078ede60fe8ab4259b016f8d2cd2ec8234b60996a3f" + }, + { + "path": "frontend/docs/ui-modernization/qualification/firefox128/comparison.png", + "bytes": 131433, + "sha256": "011148bd68f35bb9086791900503e797911d04db7facc6925cb38a1ad42994b5" + }, + { + "path": "frontend/docs/ui-modernization/qualification/firefox128/feature-switch.png", + "bytes": 74500, + "sha256": "0f0e63b34b7f35889987818baace185e5733c7e203bafa1d77fb684c0ac0b382" + }, + { + "path": "frontend/docs/ui-modernization/qualification/firefox128/pipeline-cards.png", + "bytes": 84174, + "sha256": "18f4be036985c0a2cbba560fe0b60ca329749ebc472fa1dc6a94417a4f0ffddc" + }, + { + "path": "frontend/docs/ui-modernization/qualification/firefox128/run-details-inspector.png", + "bytes": 204275, + "sha256": "a8b7920d9bb967fb2f8f5beeb96e0eb493aabaf6c5f5e87212ace65bd1285537" + }, + { + "path": "frontend/docs/ui-modernization/qualification/firefox128/runs-filtered.png", + "bytes": 95758, + "sha256": "9a999aee9e671e0b3b0eb6631c1fe6dd2204343d598965ebeb3a34d1b6425c2e" + }, + { + "path": "frontend/docs/ui-modernization/qualification/graph-chromium.json", + "bytes": 2966, + "sha256": "3462e645a1347664f2aadd5cea159e4aab82e3250f4624536ff81948e332ed8e" + }, + { + "path": "frontend/docs/ui-modernization/qualification/hosted-checks.json", + "bytes": 3238, + "sha256": "db6a59465f25e117490ec48afe14bbe8881d2254d2c6a0f908548fbef7c0167a" + }, + { + "path": "frontend/docs/ui-modernization/qualification/protocol.json", + "bytes": 13383, + "sha256": "def01757c99c9e99486f2bfbdea0be72b7dace40747eb398a6e00247190ee271" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/README.md", + "bytes": 4377, + "sha256": "b3c7247f754cf1beeba5cdd77f1635db016dce6a1bd703be3d86b8bff4992e16" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/engine-audit.json", + "bytes": 5924, + "sha256": "e0a33a7671e27f8c46fb40b74e8013ec374a0929adbcd5d56af1504a1f4708e0" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/hosted-protocol.json", + "bytes": 14325, + "sha256": "42837c8f0fd0b71d6cb4b6c5fc746af05d32c3acb1d3b2d0cbe8c7085afd224c" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/performance-audit.json", + "bytes": 1956, + "sha256": "2681c26b6b256c613b25112f8eead130ef9a2c9ca5924f796fbc3a9750acc12d" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/record-hashes.json", + "bytes": 5922, + "sha256": "e971d334c246ab78bcfcff45e0c0ec354f177abd9b41c2584a1ac8055f0b8a7c" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-audit.json", + "bytes": 24731, + "sha256": "1b9ff0d92b39d0722c8bb6d5799d9eb21ffe3a59323794c5f5578f22cd010292" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-apple-desktop-26/environment.json", + "bytes": 6113, + "sha256": "f783eaee8463d48d306bcbb3c671aa6fdaff17b6aa036e7fb61d5a924950e21c" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-apple-desktop-26/workflow.json", + "bytes": 230, + "sha256": "27ac66f48ac05d0e348cb8728eeab850dbce7fbdfd7d0d836acff86b31dd2cee" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-apple-desktop-27/environment.json", + "bytes": 6109, + "sha256": "879d8c75e09621f2a7269237fca11fc51a9ede32f9360c8db433ca36537a8ba4" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-apple-desktop-27/workflow.json", + "bytes": 230, + "sha256": "8df63146c4ab18b9c1538d05daa940698e275b07d12a0c9066ff49fe665df743" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-apple-ipad-26/environment.json", + "bytes": 35984, + "sha256": "f689ed18a680daae83f969fe6f3dca51ded0f31be7f4047175d851855a300097" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-apple-ipad-26/workflow.json", + "bytes": 227, + "sha256": "fa14f576b464ed409eae1aa3d8856fc7759979a18a147ded3700fb54b39221c6" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-apple-ipad-27/environment.json", + "bytes": 28512, + "sha256": "1efe0ed9341348c31f2f6900ac1adf261fa572bc0afbab87098f1a4e459f4da0" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-apple-ipad-27/workflow.json", + "bytes": 227, + "sha256": "cb3bb2f89134faacb7071c87be16cc33475f956225c35a718ab1c6875c960ad2" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-apple-iphone-26/environment.json", + "bytes": 36025, + "sha256": "f93b9c8f143b458dfd9fbdd655d7533763eba776ae9c6b26cb8a4e6127485d3c" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-apple-iphone-26/workflow.json", + "bytes": 229, + "sha256": "543895e3ba65f6f30f7543838b1beb9b81b88b7b1b6c620a133b99805e918065" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-apple-iphone-27/environment.json", + "bytes": 35193, + "sha256": "6ce10c9740f34e1dbcb76e1ddfa185c96c863327399430228825e4f0b02e26f0" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-apple-iphone-27/workflow.json", + "bytes": 229, + "sha256": "7fae5f87928e4866b9aef68ab8b44afba1735ab4a35574ea9d54c4b1f6a6ac84" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-chrome-stable/environment.json", + "bytes": 4305, + "sha256": "ad7a19d87afa967dc2dd171adeadb0fc3f2347483e3b2db30869f26a276b5d13" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-chrome-stable/workflow.json", + "bytes": 289, + "sha256": "4406d4167b473359c7a1feb009a36592f1c90c495e601b3b0381c831f7a77536" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-chrome-testing-current/environment.json", + "bytes": 4347, + "sha256": "80e56a9e296a76b0b584c760c3a003c06672e55856ab8d0798f7d1d9219b3a20" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-chrome-testing-current/workflow.json", + "bytes": 298, + "sha256": "2ae1fd6d7c47e4c772f1c45af2b7a8b82db5d49a8653d758bec197984f9dcada" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-chrome-testing-previous/environment.json", + "bytes": 4348, + "sha256": "907d3039f4a97d1eb1c5036d19610ac2a2ccb316b109d41e6593a2948a4c85b3" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-chrome-testing-previous/workflow.json", + "bytes": 299, + "sha256": "c5beb2eb3aea53740433e927501a85d2e2425c6abf82400f50cc6d867db09c42" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-edge-previous/environment.json", + "bytes": 4307, + "sha256": "acdf444085fa171c6dfcaeebf4be146c01f3531d186717c35a9fd67efad61614" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-edge-previous/workflow.json", + "bytes": 289, + "sha256": "ebbe33f6d28f89d1ad7f9a99e193ad3c60ec82ea702f14fff22ffdea4570f134" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-edge-stable/environment.json", + "bytes": 4305, + "sha256": "636907c8fcb8302a801f67432dc55d95f403730d1856998e90c0e63623d63b92" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-edge-stable/workflow.json", + "bytes": 287, + "sha256": "47d26450189afed8e0dc26a4ceea6f91d3a13606b23be91b4fd2625910032477" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-firefox-esr/workflow.json", + "bytes": 287, + "sha256": "82889f3256a4b69492e20e71b5aeec9983f5aa387051ab0b1c946830ba55235f" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-firefox-esr-linux/workflow.json", + "bytes": 301, + "sha256": "6457017ae3a3ec2bc7aad5a9e1bcaba7c6baa38fe072a86294c6c8adce0fe14e" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-firefox-esr-overlap/workflow.json", + "bytes": 295, + "sha256": "2db4df4da5a82ea039cf60f03458b24e1db60f183c5cac34fa221e87a9deb711" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-firefox-previous/workflow.json", + "bytes": 292, + "sha256": "fe385d9283713d89b5215c045182aad84be5554081e07975dd00ed40bbba115b" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-firefox-stable/workflow.json", + "bytes": 290, + "sha256": "5c0d5ed7db85eab0809ce0702a56dfeadf29b0d9f1062b03a04b8503815fe760" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-firefox-stable-linux/workflow.json", + "bytes": 304, + "sha256": "e524164c2e4d00553570a216ed18d097b3f248ef6ff0646254738382a107c418" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/README.md", + "bytes": 6018, + "sha256": "fcb0c55ba8c78ee7efd7b2155a5e6318919d0cd0aa1aba6917a52863aded830d" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-multiuser/authentication.json", + "bytes": 383, + "sha256": "6db37600012e147af17c98145b30c45ac6364157c315f2342faea35315220bdf" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-multiuser/baseline-image-identity.json", + "bytes": 1054, + "sha256": "2c7032f9b2b84cea58ec6027cac477e78adfd57cd964219615932ba12f03fe7a" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-multiuser/baseline-invariants.json", + "bytes": 48256, + "sha256": "54db8262dbc990a305a5c10b0e3351ea1275ef7d970b719e19dee2b56c5263bb" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-multiuser/baseline-rollout.json", + "bytes": 625, + "sha256": "9524f8f716f85c7125a46e3519fd164f079648170a21cc2e9e91dac5c2c3720c" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-multiuser/candidate-assets.json", + "bytes": 2436, + "sha256": "77f87c42b55010fe683635169855cf9e57a670a287b23874ba552a3b6b4e0dc1" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-multiuser/candidate-image-identity.json", + "bytes": 1415, + "sha256": "a1ae07afa3f05b614d452d249b61050445f72b69800a3cdd8c710a97aebd499d" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-multiuser/candidate-invariants.json", + "bytes": 48255, + "sha256": "1b0e614a804e7d447ae32ace48ba7afc9418b7d8c333a8808e39abdb43535c7d" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-multiuser/candidate-rollout.json", + "bytes": 635, + "sha256": "b90602a5a159d4a607c0f85212c76d19a5088e07e00b479b4ef96d1c0a0ac68d" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-multiuser/downloaded-client-hashes.json", + "bytes": 3649, + "sha256": "511c73f3d8e6ddda9f515d3758059fc13e2c06faa76f2ae7f73f66bd03ab0844" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-multiuser/images.json", + "bytes": 956, + "sha256": "f046408fa9df7883b4371c34b7753a2cc6ecf038b72691fce4fbd7979324c900" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-multiuser/legacy-assets.json", + "bytes": 594, + "sha256": "53ebed92acf411d84387d3c4d4344dd479d846355908c780fd5aa58810a8f956" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-multiuser/mesh-readiness.json", + "bytes": 2742, + "sha256": "83758d8e723365da6c89a3c3c6861a09e00c278994be172a3569565792af4a69" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-multiuser/profile-authorization.json", + "bytes": 645, + "sha256": "497c7122aabe5e9af27e91072e3b42dc248d265e1b17e55a10eb8c289b549491" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-multiuser/provenance.json", + "bytes": 774, + "sha256": "1afc47921d10380836b129f460e5f51ea415704d60717171e8a023ee6b270eaa" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-multiuser/rollback-image-identity.json", + "bytes": 1054, + "sha256": "e4a2f21ee08a757fcd3a7e3576a511f42de29c45b78b8156753935772930cc00" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-multiuser/rollback-invariants.json", + "bytes": 48254, + "sha256": "bd42d49a23c4a5ef9520b0b79ce538ad6e04c4121b9e2b3d1f0a37e55e25a787" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-multiuser/rollback-rollout.json", + "bytes": 626, + "sha256": "12af7de3838252be9f1fd12b8a856221bac052a97232dd13ce3a8743b4a6838a" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-multiuser/runtime-manifests/205cd386722c7db58eae9a97cfac19768e7e139b124c25b39ee3e3401323f016.json", + "bytes": 851, + "sha256": "205cd386722c7db58eae9a97cfac19768e7e139b124c25b39ee3e3401323f016" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-multiuser/runtime-manifests/7067fcdf846d0e81ce1daedee67b4fbe5409af986fd51533cef201c6e1ad347a.json", + "bytes": 2392, + "sha256": "7067fcdf846d0e81ce1daedee67b4fbe5409af986fd51533cef201c6e1ad347a" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-standalone/baseline-image-identity.json", + "bytes": 682, + "sha256": "d07d1d6db0b4f445fd59651cf47a76458b8f68b9b6c3d980b9c937786b786624" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-standalone/baseline-invariants.json", + "bytes": 10686, + "sha256": "231022394bff6d0b77bd9eedc7eee166c2a67fec3f9739a4bf6a2422370d445d" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-standalone/baseline-rollout.json", + "bytes": 625, + "sha256": "b02f20e214bb7f977ff57eed86dcf2bc32b8b911c7dd616cc07e4177cf7377ff" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-standalone/candidate-assets.json", + "bytes": 2436, + "sha256": "77f87c42b55010fe683635169855cf9e57a670a287b23874ba552a3b6b4e0dc1" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-standalone/candidate-image-identity.json", + "bytes": 1043, + "sha256": "25bb9e351e568c3f27878e5fc94df0c3efe65c2eb686ab48d813f269ca0cd459" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-standalone/candidate-invariants.json", + "bytes": 10686, + "sha256": "c3097128b30e50dde0ef789b18207b37c02b9023376bdeb67cfc8600df806242" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-standalone/candidate-rollout.json", + "bytes": 634, + "sha256": "c23d1422416757e03c13a93d4151e1c073f32523788739e89588b0f69e5792bf" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-standalone/downloaded-client-hashes.json", + "bytes": 3649, + "sha256": "7e72155b4e96117d754d1951a192fa741b69b5c754899a9f559c4bf6fafe5b29" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-standalone/images.json", + "bytes": 956, + "sha256": "f046408fa9df7883b4371c34b7753a2cc6ecf038b72691fce4fbd7979324c900" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-standalone/legacy-assets.json", + "bytes": 594, + "sha256": "53ebed92acf411d84387d3c4d4344dd479d846355908c780fd5aa58810a8f956" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-standalone/provenance.json", + "bytes": 732, + "sha256": "4df289677cf8b51f2f9bbcab29372fca1b294a160691f1581d5ef6633ee87c07" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-standalone/rollback-image-identity.json", + "bytes": 682, + "sha256": "f2bf6a09a92eb226d77e93e1fd0c8ab70c2b2f91500fe500c1e0a7a7a69401bb" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-standalone/rollback-invariants.json", + "bytes": 10685, + "sha256": "c7aa3fb761ef3b2f1c53d3a2f0f70f2f478f3eedca7151db7d1af3ea8f28ac07" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-standalone/rollback-rollout.json", + "bytes": 620, + "sha256": "fb7f007f066492d5b0f16120def9c6bf659b694681484af05b7037408d9d2344" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-standalone/runtime-manifests/205cd386722c7db58eae9a97cfac19768e7e139b124c25b39ee3e3401323f016.json", + "bytes": 851, + "sha256": "205cd386722c7db58eae9a97cfac19768e7e139b124c25b39ee3e3401323f016" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-standalone/runtime-manifests/7067fcdf846d0e81ce1daedee67b4fbe5409af986fd51533cef201c6e1ad347a.json", + "bytes": 2392, + "sha256": "7067fcdf846d0e81ce1daedee67b4fbe5409af986fd51533cef201c6e1ad347a" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/hosted-protocol.json", + "bytes": 14325, + "sha256": "42837c8f0fd0b71d6cb4b6c5fc746af05d32c3acb1d3b2d0cbe8c7085afd224c" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/performance-audit.json", + "bytes": 3432, + "sha256": "936ccb9a67d1c59d10504b859605701e33d0a8187ff1174d280fdbb3096b0ae1" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/record-hashes.json", + "bytes": 7409, + "sha256": "70e94d2a8735df2072aa323dfe6bd5a2ec3c0e6d29c5be115f4078667645fb2a" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/README.md", + "bytes": 5921, + "sha256": "a177dd27bd5078cd779a73ca1129430ea41240ab7d8ed0cddf7c4522cda7abd0" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/browser-status.json", + "bytes": 1963, + "sha256": "e2bb5d0e0d8c4ac7531481e3ab44644edc5dc198802420ac9617c0ead456742d" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/deployment-audit.json", + "bytes": 12378, + "sha256": "d2c3579337a49f69d96deb76b14dfb651cb1b55c7d1bf6a88649ca3c912715fa" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/deployment-multiuser/baseline-image-identity.json", + "bytes": 1054, + "sha256": "41a99f27e28ba469e7f92ad01b869e582c995421cd7588afa5f67e708adc33bc" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/deployment-multiuser/baseline-invariants.json", + "bytes": 48254, + "sha256": "559ad29e12a8b3830ae3c282bf48037802adc11ebf2aead79271a12c3f0bdb44" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/deployment-multiuser/baseline-rollout.json", + "bytes": 626, + "sha256": "8e9ccc4c2097d217e77f90ebba899e599e57a8888a1f2a275a4249024029820c" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/deployment-multiuser/candidate-assets.json", + "bytes": 3058, + "sha256": "8afb2fe8c3590ef676ca83cb88f7797fb3a355023606468d43edbd8c4f75a2c2" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/deployment-multiuser/candidate-image-identity.json", + "bytes": 1415, + "sha256": "57e4e8e8b2985c210d38f0a7442489d68f2c331eda61956beb909545f8590da6" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/deployment-multiuser/candidate-invariants.json", + "bytes": 48253, + "sha256": "55b5f2eca63921d02724e570c17a75d2d4e5e2f5dce9cfee26757b29539de3a5" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/deployment-multiuser/candidate-rollout.json", + "bytes": 634, + "sha256": "acec81612dc9d528f6084420d6e73aebcbd304d9caca5318a848e26cbd43c1cb" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/deployment-multiuser/images.json", + "bytes": 956, + "sha256": "45f7d03820231599c77b4fa669f6a7a212753517791b43b67def398e478bbeb0" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/deployment-multiuser/legacy-assets.json", + "bytes": 594, + "sha256": "53ebed92acf411d84387d3c4d4344dd479d846355908c780fd5aa58810a8f956" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/deployment-multiuser/mesh-readiness.json", + "bytes": 2742, + "sha256": "8a939876c90a2185088f10e816b43fbd3936614498a9b14fb4892d389b461cb2" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/deployment-multiuser/profile-authorization.json", + "bytes": 645, + "sha256": "497c7122aabe5e9af27e91072e3b42dc248d265e1b17e55a10eb8c289b549491" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/deployment-multiuser/rollback-image-identity.json", + "bytes": 1054, + "sha256": "42fb0491ebb0d94e3a3ce350ea546721c9c9464ca926b67906cbe76658e966ec" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/deployment-multiuser/rollback-invariants.json", + "bytes": 48252, + "sha256": "c34fb66dd29e86cb18290c824c5376576af872ad71691c8c62bf730d84d3f611" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/deployment-multiuser/rollback-rollout.json", + "bytes": 626, + "sha256": "a7cf113fb402923a30e6837ebf7352f4e0430a65cc949918aa77c57e7a589941" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/deployment-multiuser/runtime-manifests/11f5589a01cfaa9edafa8d86768b5b368a5cdb4aa57b2bffdf69042ce7378ff0.json", + "bytes": 851, + "sha256": "11f5589a01cfaa9edafa8d86768b5b368a5cdb4aa57b2bffdf69042ce7378ff0" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/deployment-multiuser/runtime-manifests/58cae62c0cbeecdc185d26b2d5a52b9dba1fc1e0680f36c5c4027945b89a9d40.json", + "bytes": 2392, + "sha256": "58cae62c0cbeecdc185d26b2d5a52b9dba1fc1e0680f36c5c4027945b89a9d40" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/deployment-standalone/baseline-image-identity.json", + "bytes": 682, + "sha256": "136c6ff49e2587f5a29e0da0187495918360ecb94c39e2ab52f9a53fc66c6ce4" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/deployment-standalone/baseline-invariants.json", + "bytes": 10686, + "sha256": "2d301c4da54b210a5e5025f10674cd06abfb30375f0131002aa21f782bbb3002" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/deployment-standalone/baseline-rollout.json", + "bytes": 625, + "sha256": "e6fd088e7b8db40783544a002683364d51e637254b0521b5d1ba45414b26cd78" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/deployment-standalone/candidate-assets.json", + "bytes": 3058, + "sha256": "8afb2fe8c3590ef676ca83cb88f7797fb3a355023606468d43edbd8c4f75a2c2" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/deployment-standalone/candidate-image-identity.json", + "bytes": 1043, + "sha256": "c107418b1f213b8f3ffce9f4ec058cd654dfad0798e9cf14356887f29936a303" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/deployment-standalone/candidate-invariants.json", + "bytes": 10686, + "sha256": "779536f1ed60db35c37d94195a683a692cba4e3f7fc094f90e60fa7ea5aae316" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/deployment-standalone/candidate-rollout.json", + "bytes": 635, + "sha256": "5e97626fe25d78d689499f97be0df1b46865776ef33e560c7d21b26aac840dc0" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/deployment-standalone/images.json", + "bytes": 956, + "sha256": "45f7d03820231599c77b4fa669f6a7a212753517791b43b67def398e478bbeb0" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/deployment-standalone/legacy-assets.json", + "bytes": 594, + "sha256": "53ebed92acf411d84387d3c4d4344dd479d846355908c780fd5aa58810a8f956" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/deployment-standalone/rollback-image-identity.json", + "bytes": 682, + "sha256": "28ca2196502f57ee0f469cfc3ecc1dbf63bb1206970feee9b2cbae39eb955f95" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/deployment-standalone/rollback-invariants.json", + "bytes": 10685, + "sha256": "d7776eb1201b39bddf1ec6979f57473b9fd48dba040d47f7aa1ca80f4cce55e0" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/deployment-standalone/rollback-rollout.json", + "bytes": 625, + "sha256": "1a3c7f2f929589a7e54bf9bc7e4b2f74c04803f02b28db7c085b5edc72673cf5" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/deployment-standalone/runtime-manifests/11f5589a01cfaa9edafa8d86768b5b368a5cdb4aa57b2bffdf69042ce7378ff0.json", + "bytes": 851, + "sha256": "11f5589a01cfaa9edafa8d86768b5b368a5cdb4aa57b2bffdf69042ce7378ff0" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/deployment-standalone/runtime-manifests/58cae62c0cbeecdc185d26b2d5a52b9dba1fc1e0680f36c5c4027945b89a9d40.json", + "bytes": 2392, + "sha256": "58cae62c0cbeecdc185d26b2d5a52b9dba1fc1e0680f36c5c4027945b89a9d40" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/engine-audit.json", + "bytes": 6819, + "sha256": "f81ef2fdea85e2199a7a4be91cb8330bd61ae3aa16382e91ada6982bc3fd974b" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/entry-gzip-audit.json", + "bytes": 178, + "sha256": "36c183776589767b20d99bb9e59744c9ee01284af5b1ca0fa932f4631166415f" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/hosted-protocol.json", + "bytes": 14325, + "sha256": "42837c8f0fd0b71d6cb4b6c5fc746af05d32c3acb1d3b2d0cbe8c7085afd224c" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/performance-audit.json", + "bytes": 3942, + "sha256": "1142c40c4455e732694ab3d0f66d79104ed2a3c63042e5953ba82013ef764bf5" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/record-hashes.json", + "bytes": 4936, + "sha256": "df0f143faddd514e97cf17de295dcb7f4272ca9fcfb2d0a0ebc63eac386eb2ac" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/static-audit.json", + "bytes": 1623, + "sha256": "d59f28ade640cae1c69d9ae3b25bdf7cd2d22578ddc7ea61f175579024f6ea09" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/tested-merge.json", + "bytes": 2602, + "sha256": "bb0cc59c7a7f54c4cddfa9558fb0984b16be8c108d77fa9656840dceee8408d1" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/README.md", + "bytes": 5958, + "sha256": "4ff3276013cdce3b0f5a2db98e57dc04a7094a465722bda74b2e230b36f55e0f" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/deployment-audit.json", + "bytes": 12371, + "sha256": "1f695fc4f6b5ce53d827c637e300c360c4c358d9e8b4dc08395453b8405e112c" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/deployment-multiuser/baseline-image-identity.json", + "bytes": 1054, + "sha256": "568b9106c46c3f1108d31fa51de36a2fa60661887be8b7dd8853a7d46b45d1c2" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/deployment-multiuser/baseline-invariants.json", + "bytes": 48254, + "sha256": "25ca2375cbcdf148790d00ab1b7b5e24b7c626dddf2ffa03f1d7d38bed907227" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/deployment-multiuser/baseline-rollout.json", + "bytes": 625, + "sha256": "396999a3640126266732e3f034b464f506fa949bcc7e2fb9e6327a56e065c25e" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/deployment-multiuser/candidate-assets.json", + "bytes": 3058, + "sha256": "0b4b36c5ddf57aaeb2f0338b1c02e16c1d793ee658f2f6377464802109156540" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/deployment-multiuser/candidate-image-identity.json", + "bytes": 1415, + "sha256": "0a60706810196bc5cd10f824e5f613f8ed832e5fbe514b6b879d5aa84a5097db" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/deployment-multiuser/candidate-invariants.json", + "bytes": 48253, + "sha256": "a260ac3f9e2c1c555c0559f50bdb411d613a8b0657c648c51ce85431275e00a7" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/deployment-multiuser/candidate-rollout.json", + "bytes": 634, + "sha256": "5925559f26fbb48be5b2546c01cf35566477f228caa9bbe1c89b9df291c0e08e" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/deployment-multiuser/images.json", + "bytes": 956, + "sha256": "f46d34d6d76b64063832421bba25fafdc4088c36d4be9ede3fc75dfd2758e6b3" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/deployment-multiuser/legacy-assets.json", + "bytes": 594, + "sha256": "53ebed92acf411d84387d3c4d4344dd479d846355908c780fd5aa58810a8f956" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/deployment-multiuser/mesh-readiness.json", + "bytes": 2742, + "sha256": "78c3844e595cc8fdc521f7adcf64d36f1f5d32e6224bf81f4fca63ab7de10b55" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/deployment-multiuser/profile-authorization.json", + "bytes": 645, + "sha256": "497c7122aabe5e9af27e91072e3b42dc248d265e1b17e55a10eb8c289b549491" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/deployment-multiuser/rollback-image-identity.json", + "bytes": 1054, + "sha256": "76edf482f533e6bc1b08d4dbb9584fb17b4e3c15bde31fb9949702a21806654f" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/deployment-multiuser/rollback-invariants.json", + "bytes": 48252, + "sha256": "83e05e6d7fc8fca4be2aa142b70d4189ef8c9a34076fa4db770718078e9819d1" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/deployment-multiuser/rollback-rollout.json", + "bytes": 625, + "sha256": "5647645923448c211b4efd0ee5736f4b13e7ff085be2df5c1345e2c5fe790545" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/deployment-multiuser/runtime-manifests/326b91b9dbcc9d15e89d68e1d7424c05ee6501806055d3030505b7a1efa0fed4.json", + "bytes": 851, + "sha256": "326b91b9dbcc9d15e89d68e1d7424c05ee6501806055d3030505b7a1efa0fed4" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/deployment-multiuser/runtime-manifests/d6c13993b0a91a553b8f3d2a5140d7f50865a14cc9e1c662d014c9f90fa017f3.json", + "bytes": 2392, + "sha256": "d6c13993b0a91a553b8f3d2a5140d7f50865a14cc9e1c662d014c9f90fa017f3" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/deployment-standalone/baseline-image-identity.json", + "bytes": 682, + "sha256": "1860da5a4c9868a7aed058308d304fcabd87d7d4a8c8d4ace5bb43e581b6f610" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/deployment-standalone/baseline-invariants.json", + "bytes": 10686, + "sha256": "fb1e1bad877a71726126a55ab585e518b52b9bd9bb52c72f68790ebae15e98ae" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/deployment-standalone/baseline-rollout.json", + "bytes": 625, + "sha256": "7c30d2165e58aedb15077022f4d6a3cb200d8b8753dad51e83ca3f0af0c02768" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/deployment-standalone/candidate-assets.json", + "bytes": 3058, + "sha256": "0b4b36c5ddf57aaeb2f0338b1c02e16c1d793ee658f2f6377464802109156540" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/deployment-standalone/candidate-image-identity.json", + "bytes": 1043, + "sha256": "0748af4ecee7bce59fc66bb05582ade31cda85e974ae5d4e7410ea407ef7d657" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/deployment-standalone/candidate-invariants.json", + "bytes": 10686, + "sha256": "d3ba4b9664f73c04a839bf4922e8a13e457057ac212771a7bad00801b75fd198" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/deployment-standalone/candidate-rollout.json", + "bytes": 634, + "sha256": "2f44fc0effb05bad40743f11f66a6dc628169c0cb2ab461c818dd688058eb908" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/deployment-standalone/images.json", + "bytes": 956, + "sha256": "f46d34d6d76b64063832421bba25fafdc4088c36d4be9ede3fc75dfd2758e6b3" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/deployment-standalone/legacy-assets.json", + "bytes": 594, + "sha256": "53ebed92acf411d84387d3c4d4344dd479d846355908c780fd5aa58810a8f956" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/deployment-standalone/rollback-image-identity.json", + "bytes": 682, + "sha256": "96581457e9d4eb220c383ced9c3cae8a1b70a1b53863bf19690d87816ef96283" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/deployment-standalone/rollback-invariants.json", + "bytes": 10685, + "sha256": "56f610a1b0fdadc37fecfc7e90d21a2a6099ef769cb84db32aec8d90e225846e" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/deployment-standalone/rollback-rollout.json", + "bytes": 625, + "sha256": "27561176f579f51cacf9a55175aa367658a9ae1415b4a023781927f7241665a9" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/deployment-standalone/runtime-manifests/326b91b9dbcc9d15e89d68e1d7424c05ee6501806055d3030505b7a1efa0fed4.json", + "bytes": 851, + "sha256": "326b91b9dbcc9d15e89d68e1d7424c05ee6501806055d3030505b7a1efa0fed4" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/deployment-standalone/runtime-manifests/d6c13993b0a91a553b8f3d2a5140d7f50865a14cc9e1c662d014c9f90fa017f3.json", + "bytes": 2392, + "sha256": "d6c13993b0a91a553b8f3d2a5140d7f50865a14cc9e1c662d014c9f90fa017f3" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/engine-audit.json", + "bytes": 6569, + "sha256": "57ae3e3854e023567ce01b082e8660848241ba02f3ff045619f0b5be813d96bb" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/entry-gzip-audit.json", + "bytes": 178, + "sha256": "36c183776589767b20d99bb9e59744c9ee01284af5b1ca0fa932f4631166415f" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/hosted-protocol.json", + "bytes": 14325, + "sha256": "42837c8f0fd0b71d6cb4b6c5fc746af05d32c3acb1d3b2d0cbe8c7085afd224c" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/mobile/ipad26/environment.json", + "bytes": 35984, + "sha256": "c5661ca91e180ce89330b96eec8ece1097122a9b391400b521f9f01152cf6c34" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/mobile/ipad27/environment.json", + "bytes": 35152, + "sha256": "8dfafc91a6a04d67da9e8526d481d4c35dd2b6bfa1d4d3e665b1ee621f91a457" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/mobile/iphone26/environment.json", + "bytes": 36025, + "sha256": "d130ff041e1fdc86e84e8e829660fdb71ac2f8037c463704482c7b0d0818e19a" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/mobile/iphone27/environment.json", + "bytes": 35193, + "sha256": "4a1a6cc55352285d057ff9b29bb8b3eccc041758dcbc35887c8d28e63e065dc8" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/mobile-audit.json", + "bytes": 6603, + "sha256": "9d0b160844dc7ec2083dc7595619d601a26de583ac79f54d53a24fee834819b7" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/performance-audit.json", + "bytes": 3932, + "sha256": "9173ae100e17e26fa860b2ef9996b67de7555ead4a9c0dda750b484a98f3fce6" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/record-hashes.json", + "bytes": 12718, + "sha256": "625fa2850f5af3f1e86b7b0798321f10580291a8b3a538b9b933d4882f8ea12f" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/static-audit.json", + "bytes": 1613, + "sha256": "65d4ab1cae3f7e53044f72dd849961c8d1279a06d26318697b42e55b84ce708f" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/tested-merge.json", + "bytes": 149, + "sha256": "54e850607b7c6faeeb480d78ef42b471b4291a7a92feddcf0c506614f76839da" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-apple-desktop-26/environment.json", + "bytes": 6113, + "sha256": "794518eb3b0bc1d37fe75669f8a3ba07a2a9aec527c48d47600e2e41f83048dc" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-apple-desktop-26/workflow.json", + "bytes": 230, + "sha256": "95dd65acc29a0d60379818cbbbfa64eae4dbeb868ca34e79855f79c1f618cb5d" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-apple-desktop-27/environment.json", + "bytes": 6109, + "sha256": "811d4aac21a5d0e01ded1553a334a30b145a11a7263c78cdf6f2f190da9e940a" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-apple-desktop-27/workflow.json", + "bytes": 230, + "sha256": "be69f9e0a2278e920296e9a2af1111d2756a5cca8639a9e353290ae1ad8c065e" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-chrome-stable/distribution.json", + "bytes": 4548, + "sha256": "39f52c685a229183a997d2b7cfc76287b77470a4e321fc450726bc3e6832d3aa" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-chrome-stable/environment.json", + "bytes": 5167, + "sha256": "a3a851a55524f35955f57a4b16e14baa86a10cd5cfcf7c8ad28aca9e2fc5cf82" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-chrome-stable/production-startup.json", + "bytes": 662, + "sha256": "b39d30b6260e6bf2d295d2a9051196536346bd7bdcdcde327aed2f48a6e5017e" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-chrome-stable/workflow.json", + "bytes": 289, + "sha256": "608277065d8fd3230bb42371416c1a433521f566472b2023d5627ee3486905a8" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-chrome-testing-current/distribution.json", + "bytes": 4446, + "sha256": "a12c8359cb8955e41894306357138fb19ef7df81398c59d489713a98999d8d1e" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-chrome-testing-current/environment.json", + "bytes": 5209, + "sha256": "95b458825a9c9c3ac965e28da619f4628428529014884270b2134bd291071f69" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-chrome-testing-current/production-startup.json", + "bytes": 662, + "sha256": "6885623da86224f8a108a874009456a5062b7321f586c7c03f0266fde003f556" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-chrome-testing-current/workflow.json", + "bytes": 298, + "sha256": "0cc21255bba70ce1331a8f73901a9b9ab731250564886dcbd49cb8266b010803" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-chrome-testing-previous/distribution.json", + "bytes": 4448, + "sha256": "cde3b5fc7cfd565b1a9c8d04bae5cf470dde42b10c952346951dca35d81b0523" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-chrome-testing-previous/environment.json", + "bytes": 5210, + "sha256": "f0e428ad62fa071af733b8ea208c51f80c020c659eaaff9b080faa24d8deb79a" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-chrome-testing-previous/production-startup.json", + "bytes": 662, + "sha256": "b35307be754e5774d3a7997e41205146cda65a3f5df258e3481f3cee28f7495e" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-chrome-testing-previous/workflow.json", + "bytes": 299, + "sha256": "ab4237810f4aa6f83b9c291175a7ddbf106745704e1de839398fd29f98b99662" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-edge-previous/distribution.json", + "bytes": 5034, + "sha256": "dc8dd909f4fc85da03e43e88fb2198fb575729ccd56647904c1ba5fd468bb092" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-edge-previous/environment.json", + "bytes": 5169, + "sha256": "52183159a05097e5476fbe114b2ebe5875e4164e66797daa50da14bc8b994e57" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-edge-previous/production-startup.json", + "bytes": 662, + "sha256": "8e427b23b7f7c2481613ec5f6812e49a1e4c79cfb23806a1efa0f3f4df163fbc" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-edge-previous/workflow.json", + "bytes": 289, + "sha256": "af6051e4fd0557d8825bf98ce4549a85f6663a79662a005d8fbfcfe3f99e6243" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-edge-stable/distribution.json", + "bytes": 5023, + "sha256": "feb56455bd95c4770b4d3b0f27b67cd3a9a7334d50a69a76a66b4f9be629bfd9" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-edge-stable/environment.json", + "bytes": 5167, + "sha256": "c801255a59fa5ec1e229d4cbf4823b81ffc15d4990ed647b4a70bffbfb993d98" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-edge-stable/production-startup.json", + "bytes": 662, + "sha256": "51893326f97f5fbb3e96493abdbcb935a363a6ea3f9e3725527db4c2b4d64651" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-edge-stable/workflow.json", + "bytes": 287, + "sha256": "fc5229251fba406642c70b20a20d61200e2d61ba02e0d9c5df2794019a843ffb" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-firefox-esr/distribution.json", + "bytes": 3579, + "sha256": "ec427960555929c53b714db058f0c87928e989909aa094b7eca5587738e75725" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-firefox-esr/native/lifecycle.json", + "bytes": 176, + "sha256": "8e236376d99b380cc87190efa6a14b2aafc3ec171bba91d0ef53f2b04d56b721" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-firefox-esr/workflow.json", + "bytes": 287, + "sha256": "044e7e006343b5a84613bc2dfcbdba564898874f5622a18fa0e8b1b89f8533cb" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-firefox-esr-linux/distribution.json", + "bytes": 2404, + "sha256": "d96d1dae3a3301245f03122d2746006e33bf21aa7bc04868279e1f6c08f220e0" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-firefox-esr-linux/native/lifecycle.json", + "bytes": 176, + "sha256": "04a2819f798284afc6230980c4abbe253c9b5b3457c8ffc314a4aa2507255824" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-firefox-esr-linux/workflow.json", + "bytes": 301, + "sha256": "eb0ed77a26d32d4beeea885ace63f3dc4fc517e65fd0589b039d279fad97dede" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-firefox-esr-overlap/distribution.json", + "bytes": 3621, + "sha256": "1eae647df620a86126cadc9bbe681f963e973ae922ca0596e0e63ebd23bae0bf" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-firefox-esr-overlap/native/lifecycle.json", + "bytes": 176, + "sha256": "a7032003b6ec98abf78c09369bf021e5ad04fcc394ac6c84f988d3d36335b0b8" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-firefox-esr-overlap/workflow.json", + "bytes": 295, + "sha256": "a7851cc6d96d7d6a68d3de98b39af27ed20e640a8bdec50bae51cbd8e41aa1d5" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-firefox-previous/distribution.json", + "bytes": 3575, + "sha256": "17151b376a394861262a7e602a722a532ef946cc9035b45877892587e2810ebe" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-firefox-previous/native/lifecycle.json", + "bytes": 176, + "sha256": "28d9748a63c31de407bc6541a76791c5c0b28015a19f226657aeb8e1f0334335" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-firefox-previous/workflow.json", + "bytes": 292, + "sha256": "27a66dda1a06b8fae295a8c507c00f75f379dda2f6672a3ba1de74abb3871fc4" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-firefox-stable/distribution.json", + "bytes": 3555, + "sha256": "92a578f676f8921e8316d9fb9fa04b59e8772ae519e3b7a49dad98f1fe5e2f4f" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-firefox-stable/native/lifecycle.json", + "bytes": 176, + "sha256": "78335df728f0c57753a6d3ebebcc2c42731610ba8d7f4d77e6d976e289e11268" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-firefox-stable/workflow.json", + "bytes": 290, + "sha256": "271aff7c188d31af42d98c6c87522acb1b8ae53a54cc6cec75f9d36dab3d8c7c" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-firefox-stable-linux/distribution.json", + "bytes": 2358, + "sha256": "ca54fbca0e007a8118440dead66fbaa411c63a67424164ad0373f289a961b26c" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-firefox-stable-linux/native/lifecycle.json", + "bytes": 176, + "sha256": "b47d0f1d5865255148132acd35d7e600fff2bec7259e547ffe118f6d7467b64d" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-firefox-stable-linux/workflow.json", + "bytes": 304, + "sha256": "253f301cff5871f60803cb88fec3645d551718ee950c7a4cdf867e779617f8b2" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop-audit.json", + "bytes": 9253, + "sha256": "2609184d1bd2b9079b58a4ac890a2e18abb41cd90ec2f697d31f068fc54970cb" + }, + { + "path": "frontend/docs/ui-modernization/runs/runs-dark-archive-dialog.png", + "bytes": 141328, + "sha256": "9d8a633e41cc686b9510d68d693a6f2ad0992c50841e8513ab27c9f6c593bd4d" + }, + { + "path": "frontend/docs/ui-modernization/runs/runs-dark.png", + "bytes": 134527, + "sha256": "d0a718be886b64b16e073d4708c63ae19dc3e42e5e4ae13a2459617ac2f43511" + }, + { + "path": "frontend/docs/ui-modernization/runs/runs-light.png", + "bytes": 132345, + "sha256": "3dc962e328edb21d2e6aeea6d7188b1cd40763f64dcb7fc4aad9d61c29de20cb" + }, + { + "path": "frontend/docs/ui-modernization/supported-browsers/branded-browser-matrix.json", + "bytes": 22415, + "sha256": "3a5e8f6585cb090d5f6e66185629c6626db65f65697bc75c11d2f9d7aa5cdf72" + }, + { + "path": "frontend/docs/ui-modernization/supported-browsers/edge-153.0.4234.48-distribution.json", + "bytes": 354, + "sha256": "f29203ec0aedcea1d513473e7ce93addf65260cb60be70123ceb9d44253c7944" + }, + { + "path": "frontend/docs/ui-modernization/supported-browsers/edge-154.0.4258.37-distribution.json", + "bytes": 354, + "sha256": "8af2c9cd2db738487cbb6876fc5c02dece730d7d945ea2f9a9741e58b762d257" + }, + { + "path": "frontend/docs/ui-modernization/supported-browsers/firefox140esr/command-dialog-narrow-dark.png", + "bytes": 78271, + "sha256": "efafed02371a1a34082f2bbf8ebc895bd81c81239187cc83e895f09aba65db2b" + }, + { + "path": "frontend/docs/ui-modernization/supported-browsers/firefox140esr/comparison.png", + "bytes": 133355, + "sha256": "b4ed06e077dc13f94bd00f8dd85e1417864f014336bf949d805e0dabd72e5082" + }, + { + "path": "frontend/docs/ui-modernization/supported-browsers/firefox140esr/feature-switch.png", + "bytes": 74591, + "sha256": "18855331db18ec4cbf74b91a15177396305d96ba973e6f7bf61a2ef9da0cbc63" + }, + { + "path": "frontend/docs/ui-modernization/supported-browsers/firefox140esr/pipeline-cards.png", + "bytes": 84190, + "sha256": "e89c614738df032ed93b3b1e0b74e3890bde1bfd9488e4e4160c3fb305c457d2" + }, + { + "path": "frontend/docs/ui-modernization/supported-browsers/firefox140esr/result.json", + "bytes": 4760, + "sha256": "4bc207be1122ed5c295d2a600828391ee19e82eccfae4c75e4da81fe4c25572d" + }, + { + "path": "frontend/docs/ui-modernization/supported-browsers/firefox140esr/run-details-inspector.png", + "bytes": 203784, + "sha256": "b41f7dd98b71d1f037a937e78c0ec8cc94e12fcf3b41928d05f8ccf358eaec38" + }, + { + "path": "frontend/docs/ui-modernization/supported-browsers/firefox140esr/runs-filtered.png", + "bytes": 98409, + "sha256": "64651a3727c4d24a268c43c392ee25e4816a7e911b1b945787192bfb07e80157" + }, + { + "path": "frontend/docs/ui-modernization/supported-browsers/firefox153esr/command-dialog-narrow-dark.png", + "bytes": 69496, + "sha256": "a2d7fd2cc4681f4e382c55560b17994030af8857047f4885b8ccfcb87a8020c9" + }, + { + "path": "frontend/docs/ui-modernization/supported-browsers/firefox153esr/comparison.png", + "bytes": 112577, + "sha256": "8e7eef326b19defa7c1b1b1c0eec38dae2cf1c6a54f844c17b98de2b61445739" + }, + { + "path": "frontend/docs/ui-modernization/supported-browsers/firefox153esr/feature-switch.png", + "bytes": 55654, + "sha256": "6372b623c84b3c822cf22daf64f3dd3b19bd708d1495335c39debc51818d3279" + }, + { + "path": "frontend/docs/ui-modernization/supported-browsers/firefox153esr/pipeline-cards.png", + "bytes": 65018, + "sha256": "d7d91d3be86a261d8693ac20e3ae6e012a4e0bcfc80337e66e910da467562c70" + }, + { + "path": "frontend/docs/ui-modernization/supported-browsers/firefox153esr/result.json", + "bytes": 4754, + "sha256": "7b146bef3bab7b33d2c28840b08632a6bd51d77b2fbc16c1debc0941995bccea" + }, + { + "path": "frontend/docs/ui-modernization/supported-browsers/firefox153esr/run-details-inspector.png", + "bytes": 184961, + "sha256": "ccd384b77ecb6b6a51a130fcf39f72e03be5976a009c68f19f96440c60b428c7" + }, + { + "path": "frontend/docs/ui-modernization/supported-browsers/firefox153esr/runs-filtered.png", + "bytes": 79537, + "sha256": "a1f5c105107afe904e82bca39a0aeacf2d4d4716a0e503ae75f5acf2bb4afabc" + }, + { + "path": "frontend/docs/ui-modernization/supported-browsers/firefox155/command-dialog-narrow-dark.png", + "bytes": 69496, + "sha256": "a2d7fd2cc4681f4e382c55560b17994030af8857047f4885b8ccfcb87a8020c9" + }, + { + "path": "frontend/docs/ui-modernization/supported-browsers/firefox155/comparison.png", + "bytes": 112577, + "sha256": "8e7eef326b19defa7c1b1b1c0eec38dae2cf1c6a54f844c17b98de2b61445739" + }, + { + "path": "frontend/docs/ui-modernization/supported-browsers/firefox155/feature-switch.png", + "bytes": 55650, + "sha256": "1d866b87bf393a6089ed2a3f20fc4f62db6584d8f1e25d451c62393bf5b3209d" + }, + { + "path": "frontend/docs/ui-modernization/supported-browsers/firefox155/pipeline-cards.png", + "bytes": 65014, + "sha256": "3eeb5a5db221c9feb584140af1bc42f84036c612733fcb8fde4e40bc7f558b95" + }, + { + "path": "frontend/docs/ui-modernization/supported-browsers/firefox155/result.json", + "bytes": 4742, + "sha256": "8bbf60da3de18c07046b0a800a82334f3df652ac8a1b6b7d2d43338da9d546af" + }, + { + "path": "frontend/docs/ui-modernization/supported-browsers/firefox155/run-details-inspector.png", + "bytes": 184515, + "sha256": "87fde4194b0618524a33843daf1908d2ee3bb86eb1cfd226e079d6390b939740" + }, + { + "path": "frontend/docs/ui-modernization/supported-browsers/firefox155/runs-filtered.png", + "bytes": 79537, + "sha256": "a1f5c105107afe904e82bca39a0aeacf2d4d4716a0e503ae75f5acf2bb4afabc" + }, + { + "path": "frontend/docs/ui-modernization/supported-browsers/firefox156/command-dialog-narrow-dark.png", + "bytes": 69496, + "sha256": "a2d7fd2cc4681f4e382c55560b17994030af8857047f4885b8ccfcb87a8020c9" + }, + { + "path": "frontend/docs/ui-modernization/supported-browsers/firefox156/comparison.png", + "bytes": 112577, + "sha256": "8e7eef326b19defa7c1b1b1c0eec38dae2cf1c6a54f844c17b98de2b61445739" + }, + { + "path": "frontend/docs/ui-modernization/supported-browsers/firefox156/feature-switch.png", + "bytes": 55647, + "sha256": "ed7f493483f3a6fb2afab6de94c616a1a344a2f1f384c96179fb0932ca85e93b" + }, + { + "path": "frontend/docs/ui-modernization/supported-browsers/firefox156/pipeline-cards.png", + "bytes": 64885, + "sha256": "c5270aafff1e92964f55acd2cb393313f556b36b0d3e398a18f13d43881c52c1" + }, + { + "path": "frontend/docs/ui-modernization/supported-browsers/firefox156/result.json", + "bytes": 4742, + "sha256": "e399f15755d6111aa678ad12a0c96369351320b3b2553f2bbf8ea9988c350032" + }, + { + "path": "frontend/docs/ui-modernization/supported-browsers/firefox156/run-details-inspector.png", + "bytes": 184566, + "sha256": "a9a65903752e8eab868f341c3e7f353def6cdf677f67ca0c73add2130b69aee9" + }, + { + "path": "frontend/docs/ui-modernization/supported-browsers/firefox156/runs-filtered.png", + "bytes": 79537, + "sha256": "a1f5c105107afe904e82bca39a0aeacf2d4d4716a0e503ae75f5acf2bb4afabc" + }, + { + "path": "frontend/docs/ui-modernization/workflows/artifact-lineage-dark.png", + "bytes": 74622, + "sha256": "f3c02764a97b55345ba9a51e45a1eed304f66ca8df42ae631a7dc3cd5d484135" + }, + { + "path": "frontend/docs/ui-modernization/workflows/browser-matrix.json", + "bytes": 10036, + "sha256": "fbaf6b0bd317bcfac3e54e2b7512700cc5a92303834c389fff3f0c01c853fd76" + }, + { + "path": "frontend/docs/ui-modernization/workflows/bundle-sizes.json", + "bytes": 5627, + "sha256": "db7e77d9cf9aaf2306d33063bc9b3c71892f45feded23f138d0329afcf75dad2" + }, + { + "path": "frontend/docs/ui-modernization/workflows/large-graph-dark.png", + "bytes": 242411, + "sha256": "b2f8fd2dcd69073c102baba25f340d307a726db4f7853813423ebcb42d8b4b43" + }, + { + "path": "frontend/docs/ui-modernization/workflows/new-run-dark.png", + "bytes": 100828, + "sha256": "27661015ccf267c6b825fbe13d81560be06dfb7036836896977c8414ecd1a2a3" + }, + { + "path": "frontend/docs/ui-modernization/workflows/performance-protocol.json", + "bytes": 7705, + "sha256": "58a38223a4238e16861ab93f89b208a2336821410a9e09c61eaa3ebc4943fd60" + }, + { + "path": "frontend/docs/ui-modernization/workflows/pipelines-expanded-light.png", + "bytes": 125098, + "sha256": "8a932bbf2651684389bff357577aa3d469fa0385244554a4aabb70bb9a69c686" + } + ], + "earlierArchive": { + "sourceCommit": "a058b10903912eb8bd29fc15a24b63b8e2722987", + "sourceTree": "bacd4c6227b6140f3c51092fd4f53fe5cb496563", + "archiveUrl": "https://raw.githubusercontent.com/kubeflow/pipelines/d9aed93d6184be1f65d36066b40eb92028a2aa25/archives/kfp-ui-modernization-evidence-a058b109.tar.gz", + "archiveSha256": "7cac429d045e7b2870c3199a76642f391667664abfcfb7f29fbc40352ca95bde", + "files": [ + { + "path": "frontend/docs/ui-modernization/layout-stability/performance-evidence.json", + "bytes": 747313, + "sha256": "ea8838a1388eae62db3799f2f8d16e83d1b6cd4b0cba016a6c587bc82d6a8aae", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/layout-stability/performance-evidence.json" + }, + { + "path": "frontend/docs/ui-modernization/qualification/lighthouse-accessibility.json", + "bytes": 145583, + "sha256": "f28d0b5210c7f71a801c63a1d878acb081d9245ee4dda72914673e6ab648820a", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/qualification/lighthouse-accessibility.json" + }, + { + "path": "frontend/docs/ui-modernization/qualification/performance-evidence.json", + "bytes": 370434, + "sha256": "02c2578bc8ce138e1284ec72c71785ba83312bf9b8e8c8deac900af7f09c8bb8", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/qualification/performance-evidence.json" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/performance.json", + "bytes": 2942478, + "sha256": "afd9c279dbf421148cad9717443e7d54f32bbc090b02bd766bd68fffb2bf3e3c", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/release-qualification-2026-09-30/performance.json" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/artifact-file-hashes.json", + "bytes": 44286, + "sha256": "4a5f504b4424368b5d6775c3ccd801b9d3679995453cb8a553daf4ae6439fcf5", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/artifact-file-hashes.json" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-apple-desktop-26/checks/result.json", + "bytes": 241997, + "sha256": "66edb5cd4bd92d69e52d277814f5a52626893c943e0f7e5281a1356a085b5062", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-apple-desktop-26/checks/result.json" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-apple-desktop-27/checks/result.json", + "bytes": 241291, + "sha256": "cfad214bbac0350bc41073ef38a4a9386164c4f5349088cf2f6005edfab7b7f1", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-apple-desktop-27/checks/result.json" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-apple-ipad-26/checks/result.json", + "bytes": 279474, + "sha256": "6eb8000bc076524c3161d767c82eeb6b201d8b7b016f6e95b691151048105b96", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-apple-ipad-26/checks/result.json" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-apple-iphone-26/checks/result.json", + "bytes": 285396, + "sha256": "383a92b05ef4c62bb3f16fb3573c66e53ec915f380e601ed5a802dfa22182a71", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-apple-iphone-26/checks/result.json" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-apple-iphone-27/checks/result.json", + "bytes": 286458, + "sha256": "c6e334630eafcf6e2d68aff5a7dbcfc0f52257f3340c509d84eeb635bb46ef5c", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-apple-iphone-27/checks/result.json" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-firefox-esr/native/result.json", + "bytes": 239642, + "sha256": "e1d92b01c436893727b85d3d6a451d2c5c3320d0f28fef0da13bca93477091ef", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-firefox-esr/native/result.json" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-firefox-esr-linux/native/result.json", + "bytes": 239583, + "sha256": "3fdca3c02a27c83b78f7790eae464f62cf07cdcd88d27785dfe14de1a6ab601f", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-firefox-esr-linux/native/result.json" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-firefox-esr-overlap/native/result.json", + "bytes": 239613, + "sha256": "b1e7ae2237732fb3c215ccb7b22d3fdfd93ed421b7fd496b32bbeee28c3767a0", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-firefox-esr-overlap/native/result.json" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-firefox-previous/native/result.json", + "bytes": 239642, + "sha256": "050443b996279f5dde87a78d255dcb105b29db93b7570d48b9f67eca0873da86", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-firefox-previous/native/result.json" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-firefox-stable/native/result.json", + "bytes": 239638, + "sha256": "af559c8280da5ee1fb41ba107d3c163cfeaf76da1402289f9492950108c0aee8", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-firefox-stable/native/result.json" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-firefox-stable-linux/native/result.json", + "bytes": 239579, + "sha256": "67091b812522b22f1bfb530911a884d0e6343238c762e57740f09caef8c88b27", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/release-qualification-2026-09-30/vendor-records/qualification-firefox-stable-linux/native/result.json" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-multiuser/audit.json", + "bytes": 64625, + "sha256": "c4fd65794b7d441556a8c340436fd13cdb84230e57284971ee80eaa51d05c88b", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-multiuser/audit.json" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-multiuser/profile-authorization-state.json", + "bytes": 59270, + "sha256": "49b5f5debb61df1de9fd06d424daafb0693ea2ffe4c70c0c2f78ba72b4ff2330", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-multiuser/profile-authorization-state.json" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-multiuser/result.json", + "bytes": 73700, + "sha256": "3ade2b9ccdf29c540217b3405fb199ce163748e4a23e9b04d089e92cdb1df50a", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-multiuser/result.json" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-standalone/audit.json", + "bytes": 61905, + "sha256": "a2ce6b98f3571c9d5e39133ee6154b003d64555d429863e00fa946f5e7b76003", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-standalone/audit.json" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-standalone/result.json", + "bytes": 69673, + "sha256": "80836c02ff0c460994ba7b2dbcc76a34797043760b95aec8510f3af49b2569fe", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/release-qualification-2026-10-02/deployment-standalone/result.json" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-02/performance.json", + "bytes": 2949737, + "sha256": "535cbd6df44a77076a20a48ab9acae9baa2bf68d4666359556a08cc232f86c49", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/release-qualification-2026-10-02/performance.json" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/deployment-multiuser/result.json", + "bytes": 73799, + "sha256": "f75151a0f9e141d25560b16e4720c534f00488307a42676865d8c5a17a9f086a", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/release-qualification-2026-10-05/deployment-multiuser/result.json" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/deployment-standalone/result.json", + "bytes": 70412, + "sha256": "271a8a41db30bbf57c7ab999fcde2d202de83dfec3a179d00c5f0c75d8ecc040", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/release-qualification-2026-10-05/deployment-standalone/result.json" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05/performance.json", + "bytes": 4319217, + "sha256": "5b7d34e7b0f5b6c6897b5f261147565d941ea622236cd54a746067854a6429ef", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/release-qualification-2026-10-05/performance.json" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/deployment-multiuser/result.json", + "bytes": 75206, + "sha256": "d136282fcc4ba94e38998e784599a2fc0ad650baedd570523e59cff5e2b7bde6", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/deployment-multiuser/result.json" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/deployment-standalone/result.json", + "bytes": 68488, + "sha256": "55a97fa9a2a59eeac8fc728b57c3c93e30563d691e3924a2ad947c319c986a76", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/deployment-standalone/result.json" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/mobile/ipad26/result.json", + "bytes": 279616, + "sha256": "82a3ff762857190792d13d1d550347b508116fed3cd8997005c322a567030dd7", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/mobile/ipad26/result.json" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/mobile/ipad27/result.json", + "bytes": 279294, + "sha256": "e7d06047d811042953fada123043a2f030933f5238fe410dccae3571f117998e", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/mobile/ipad27/result.json" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/mobile/iphone26/result.json", + "bytes": 285833, + "sha256": "c01af16c0e4943f928338095e49576801c2b52ce5fe5c5738b22d0619d23ab2e", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/mobile/iphone26/result.json" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/mobile/iphone27/result.json", + "bytes": 286436, + "sha256": "9afa464c89480c52ce5cdb4e213b15c79a09eb160543a02214818f7c16226cac", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/mobile/iphone27/result.json" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/performance.json", + "bytes": 4318744, + "sha256": "ad4d322af38cabe715c8456c334a0902a1f003d2b90a4bc741a3283adf7eb054", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/performance.json" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-apple-desktop-26/checks/result.json", + "bytes": 241762, + "sha256": "29ce5bf2ff9608fa1a75ce8f7e89b324ae62b62989465432a5b60f732feb058a", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-apple-desktop-26/checks/result.json" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-apple-desktop-27/checks/result.json", + "bytes": 241291, + "sha256": "62f774f64f48ef69621677d20b7ed21c071ebd468c6058f5cf43b6d623a24e58", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-apple-desktop-27/checks/result.json" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-firefox-esr/native/result.json", + "bytes": 239642, + "sha256": "23fba683c6628f091e95369c85511a05fc14f5897572bdf20f1e2aec29c20e35", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-firefox-esr/native/result.json" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-firefox-esr-linux/native/result.json", + "bytes": 239583, + "sha256": "c79150b33c37f7c6200f9e4ea558c4d1a734b41aa521594d9a3ad2d76938f1f8", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-firefox-esr-linux/native/result.json" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-firefox-esr-overlap/native/result.json", + "bytes": 239644, + "sha256": "577357f81b77fd59aaa88bbf27203e486c6ca8729ca40958b676c6583aea79d8", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-firefox-esr-overlap/native/result.json" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-firefox-previous/native/result.json", + "bytes": 239611, + "sha256": "e62dee394efa6c6bce6128566501f072fdc0a3c9a474eba1a47e76f876f0b5f9", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-firefox-previous/native/result.json" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-firefox-stable/native/result.json", + "bytes": 239638, + "sha256": "a0d702fd755b29946d18c20d671b572360cafd53a4d6f907a6aa31b6424084a9", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-firefox-stable/native/result.json" + }, + { + "path": "frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-firefox-stable-linux/native/result.json", + "bytes": 239579, + "sha256": "8c8bf4b65107ec93085b38ef70ac247d0184ceff1d1b08069000137b032d5997", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/vendor-desktop/qualification-firefox-stable-linux/native/result.json" + }, + { + "path": "frontend/docs/ui-modernization/workflows/performance-evidence.json", + "bytes": 115573, + "sha256": "abc710c9740645578e5f4ebdc2430ac9b082321a7059ae0de10a0b500ba09365", + "url": "https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/workflows/performance-evidence.json" + } + ], + "evidenceRepository": "kubeflow/pipelines", + "evidenceCommit": "d9aed93d6184be1f65d36066b40eb92028a2aa25" + }, + "evidenceRepository": "kubeflow/pipelines", + "evidenceCommit": "d9aed93d6184be1f65d36066b40eb92028a2aa25" +} diff --git a/frontend/docs/ui-modernization/browser-accessibility-qualification.md b/frontend/docs/ui-modernization/browser-accessibility-qualification.md new file mode 100644 index 00000000000..2742eda4e20 --- /dev/null +++ b/frontend/docs/ui-modernization/browser-accessibility-qualification.md @@ -0,0 +1,97 @@ +# Browser and accessibility qualification + +Broad qualification tooling is maintained in [qualification PR #14757](https://github.com/kubeflow/pipelines/pull/14757). Commands and results in this document refer to that tooling or the explicitly recorded historical source; they are not additional presubmit gates in the application PR. See [the evidence index](evidence-archive.md) for retained records. + +The [supported-browser checkpoint](supported-browser-qualification.md) records new branded Chrome/Edge and native Firefox stable/ESR results under the approved policy. Its remaining full-workflow, enterprise, Safari/device and OS gates stay open. + +Latest follow-up: [loading-layout stability](layout-stability.md) records the `e79f8d42` fixes, 141-case browser matrix and repeated matched measurements. The source-specific results below remain historical evidence. + +This report records the checks after the [legacy retirement checkpoint](workflows.md). The [retained production browser matrix](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/qualification/browser-matrix.json) passed 132 cases on application and harness source `b358c3b4dc6bcf1b47c2ae399b5e85dfc3df0269`, including toolbar-loading geometry, pointer resizing and task-log namespace readiness. The actual Firefox 128 evidence below uses the same application assets. The [performance qualification](performance-qualification.md) is reported separately. + +## Accessibility qualification + +The focused source audit reviewed application navigation, command search, the task inspector, shared dialogs and controls, and creation forms against the current [Web Interface Guidelines](https://github.com/vercel-labs/web-interface-guidelines/blob/main/command.md). This is a scoped engineering audit, not a WCAG conformance assessment. + +Confirmed issues and corrections: + +- **Inspector resizing:** the existing pointer handle now also exposes a named vertical separator with current, minimum and maximum widths. Left/Right resize by 20 CSS pixels; Home/End choose the bounds. Width survives ordinary rerenders and temporary viewport reduction. When the narrow modal layout removes a focused resize handle, focus moves to the existing Close button. Tests retain Escape dismissal, trigger focus restoration and narrow-screen focus trapping. +- **Dialog descriptions:** simple text dialogs keep their accessible description. Rich dialog content retains its own field, table and list semantics without becoming one flattened description, consistent with the [ARIA dialog guidance](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/). Focus trapping and restoration remain covered. +- **Command search:** the existing live status now announces the number of displayed matches when async results arrive. The count describes the bounded visible results, not a global resource total. Existing stale-response and partial-error behavior remains unchanged. +- **Schedule validation:** invalid start/end date-time messages are associated with both corresponding inputs through `aria-describedby` and `aria-invalid`. Correcting the value removes the invalid state and description. +- **JSON parameters:** the Ace input receives the parameter-specific accessible name through supported editor options, with its keyboard accessibility mode enabled. The editor remains lazy loaded and preserves typed parameter callbacks. +- **Forced colors:** switch tracks and thumbs use system colors in forced-colors mode so the state indicator does not disappear. An isolated Chromium probe reproduced the original white thumb on a white track and verified the corrected actual span-based control with emulated forced colors and reduced motion. A durable workflow assertion checks at least 3:1 thumb/track contrast, distinct checked/unchecked positions, and reduced transition duration alongside the existing exactly-once mutation/error-recovery checks. + +### Regression evidence + +The complete UI suite passed 1,753 tests in 133 files at application source `b358c3b4dc6bcf1b47c2ae399b5e85dfc3df0269`. The unchanged server suite passed 1,052 tests at source `8dba94d28aeeced75aed712b0025e9c7350718f2`. Frontend lint, type checking, formatting, production and Storybook builds, and commit hooks passed. The final production matrix passed 44 cases in each of Chromium 145.0.7632.6, Firefox 146.0.1 and WebKit 26.0. + +[Hosted frontend qualification](https://github.com/kubeflow/pipelines/actions/runs/36454277997) and all four [Kubernetes frontend integration lanes](https://github.com/kubeflow/pipelines/actions/runs/36454278412) passed on `b358c3b4`, covering Kubernetes 1.33/1.36 with TLS on/off. The [retained check identities](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/qualification/hosted-checks.json) distinguish these application-source results from later evidence-only commits. Passing these lanes does not close every standalone/embedded deployment authorization or rollback criterion. + +The matrix preserves content geometry while Details and Compare metadata responses are held. It verifies keyboard resizing, a native 40-pixel drag from 320 to exactly 360 pixels, width persistence through a task-tab rerender, a settled 600-pixel modal inspector, and restoration of the chosen desktop width. The unit regression also covers stale DOM measurements, repeated drags after keyboard changes and gestures without movement. + +Task logs wait only while experiment metadata is pending and no namespace is already available. The visible loading status reflects a pure derived flag that gates the existing query; no effect was added. Default, selected-namespace and failed-lookup behavior, plus artifact-log fallback, remain covered. The held-experiment browser regression verifies zero early pod/artifact reads, then exactly one pod read using `team-a` after resolution. The regression sources include: + +- [Inspector keyboard, viewport and focus tests](../../src/components/inspection/Inspection.test.tsx), [dialog semantics and keyboard tests](../../src/components/ui/dialog.test.tsx), and [command search tests](../../src/components/navigation/CommandPalette.test.tsx). +- [Schedule validation associations](../../src/components/Trigger.test.tsx) and [parameter editor naming](../../src/components/RunParametersForm.test.tsx). +- [Production Run Details harness](../../scripts/ui-modernization-run-details.smoke.mjs), including loading geometry, namespace readiness and actual inspector resizing; [comparison harness](../../scripts/ui-modernization-comparison.smoke.mjs) for held-response geometry. +- [Production workflow harness](../../scripts/ui-modernization-workflows.smoke.mjs), including the forced-colors assertions that passed in all three retained browser engines. + +No additional React state-derivation effects were introduced. Inspector viewport subscriptions synchronize browser APIs; the resize-handle ref cleanup synchronizes DOM focus when that element is removed. User resizing remains event driven. + +### Production Lighthouse snapshots + +Seven scoped Lighthouse 13.4.1 accessibility snapshots ran on application source `a2ea3e85c809cbfa6497452620e69743cd8a21c2`, with `index-Nqz-3hMY.js` and `index-BB6Gh9sf.css`. After the isolated inspector pointer-delta handler correction, the affected Run Details snapshot was repeated on `b80da47476a06bfd27e1318cb593ede91a869665`, with `index-BmtIJnMF.js` and the unchanged CSS. The table retains that Details result and the other six states from `a2ea3e85`; it does not claim seven fresh snapshots on `b80da474` or `b358c3b4`. The later `b358c3b4` task-log readiness correction is covered separately by the final unit and production browser tests above. The [retained audit evidence](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/qualification/lighthouse-accessibility.json) records asset hashes, browser identity, actual viewports, all accessibility audit outcomes and reviewed warnings. Headless Chrome 154 loaded the production bundle against native mock API fixtures. No run or experiment was submitted. Snapshot mode does not measure performance. + +| State | Actual viewport, CSS pixels | Lighthouse accessibility score | Experimental label warnings | +| ----------------------------------------------------------------- | --------------------------- | -----------------------------: | --------------------------: | +| Runs, four available rows, light | 1200 × 2029 | 100 | 1 | +| Run Details, two tasks and three artifacts, light | 1200 × 2029 | 100 | 6 | +| Two-run Compare, empty parameters/scalar metrics, light | 1440 × 900 | 100 | 1 | +| Creation before pipeline selection, light | 1440 × 900 | 100 | 1 | +| Creation with pipeline/version and three string parameters, light | 1440 × 900 | 100 | 1 | +| Command dialog navigation, dark | 600 × 800 | 100 | 0 | +| Command dialog with two matching resource links, dark | 600 × 800 | 100 | 0 | + +No weighted Lighthouse accessibility checks failed in these states. Earlier snapshots identified short sidebar metadata targets, low-contrast/small React Flow attribution, and an unnamed creation breadcrumb. The fixes keep metadata/attribution targets at least 24 pixels tall, theme the attribution without removing it, and remove the empty breadcrumb at its Router defaults. Increasing the attribution target exposed an overlap with the graph controls; the final scoped offset preserves four 30 × 30 pixel controls with no attribution overlap. Actual bounds and the [durable graph assertion](../../scripts/ui-modernization-graph.smoke.mjs) verify that separation. The associated shell/node suites passed 42 tests, including retained status descriptions and shortcut semantics. The readiness snapshots measured Details and Compare content at 108 CSS pixels from the viewport top, with no empty breadcrumb links; unconfigured creation had no breadcrumb navigation or unnamed links. These point-in-time bounds complement the held-response production regression and do not themselves measure layout shifts. + +The experimental `label-content-name-mismatch` rule still reports the Search shortcut and graph type/status text as serious findings. It has zero weight in Lighthouse's score, so a score of 100 does not mean the report contains no warnings. Review confirmed that the visible primary action labels match their accessible names; supplementary shortcuts remain available through `aria-keyshortcuts`/the description, and graph type/status remains explicitly associated through `aria-describedby`. The retained browser accessibility snapshots preserve those descriptions. These reviewed warnings are retained rather than changing meaningful action names to satisfy the heuristic. Actual speech-control and screen-reader testing remains necessary. + +The populated creation snapshot used the ordinary pipeline selector to establish a supported Various IO types/default-version URL, then loaded that URL on the audited `a2ea3e85` build. It covers three string inputs and two associated missing-parameter messages; it does not audit every parameter type or recurring schedule state. Empty Compare does not qualify populated charts. The narrow command snapshots use a desktop browser viewport, not a physical mobile device. Desktop/mobile audit presets are retained separately from the observed viewport dimensions. + +### Remaining accessibility limits + +The unit DOM and automated browsers cannot establish representative screen-reader announcements, actual Windows high-contrast behavior, touch/zoom usability on physical Safari/iOS devices, or every layout at 320 CSS pixels and 400% zoom. The forced-colors probe is browser emulation rather than an operating-system or assistive-technology test. The final production matrix covers pointer/keyboard resizing, focus, dismissal and desktop/narrow viewport transitions. This historical checkpoint originally called for representative assistive-technology and device-user acceptance. The later approved [automated qualification policy](../browser-support.md#automated-qualification) supersedes that manual-testing requirement: untested assistive-technology behavior remains a disclosed limitation and coverage follow-up, with no blanket conformance claim. Supported-browser failures affecting keyboard access or reachable content still block release. + +## Historical browser compatibility qualification + +The [maintainer-approved policy](../browser-support.md) supersedes the minimum-version matrix below. Chrome/Edge 111, Firefox 128 and Safari/iOS 16.4 are retired qualification targets. The new stable/enterprise/Safari matrix remains unqualified; no historical result below is relabeled as a run on those channels. The following audit and results describe the earlier checkpoint. + +At that checkpoint, the declared minimums were Chrome/Edge 111, Firefox 128, and Safari/iOS 16.4. The explicit Vite targets and Browserslist entries match those minimums. [Tailwind's compatibility guidance](https://tailwindcss.com/docs/compatibility) sets the same Chromium, Firefox and Safari floors. Base UI 1.8's [versioned browser targets](https://github.com/mui/base-ui/blob/v1.8.0/.browserslistrc) include these versions, but the actual Firefox check below demonstrated why a dependency target alone does not establish compatibility. Vite transforms syntax; it [does not automatically polyfill runtime APIs](https://v8.vite.dev/guide/build). + +### Historical audit and minimum-version matrix + +The emitted source-map audit checked application and dependency uses of dynamic viewport units, `color-mix()`, `:focus-visible`, `ResizeObserver`, `WeakRef`, `structuredClone`, `Object.hasOwn` and `Array.prototype.at`. These APIs fit the declared floors. Base UI's `checkVisibility` use and Floating UI's newer pseudo-class queries have fallbacks; React Router guards the View Transition API. No mandatory newer built-in API was identified. This audit is not equivalent to running each minimum browser. + +| Browser | Minimum | Qualification status | +| --------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| Chrome | 111 | The retained production Chromium matrix is recorded separately. An actual Chrome 111 run remains open. | +| Edge | 111 | No actual Edge 111 run has been completed. Chromium results do not establish branded Edge qualification. | +| Firefox | 128 | Seven-scenario native smoke passed on actual Firefox 128.0 after the input activation fix below; see `qualification/browser-floor-firefox128.json`. | +| Safari on macOS | 16.4 | An actual Safari 16.4 run remains open. Playwright WebKit is engine coverage, not Safari qualification. | +| Safari on iOS | 16.4 | An actual simulator/device run remains open, including touch, zoom, keyboard and visual-viewport checks. | + +The actual minimum-browser run used Firefox 128.0. No actual Chrome/Edge 111, Safari 16.4 or iOS simulator/device result has been retained. Existing Playwright engines provide current-engine coverage and [cannot substitute for branded Firefox or Safari](https://playwright.dev/docs/browsers). No browser automation preference or user profile was changed to obtain these results. + +The former uncompleted runs above are no longer release requirements. The replacement matrix needs isolated browser/OS images or a device service supplying the exact versions selected under the [approved policy](../browser-support.md). New runs must retain browser/OS identity, source and asset hashes, populated route readiness, keyboard selection and dialogs, theme screenshots, and any failure evidence. Current iOS also needs a visual check of dialog and inspector backdrops against browser chrome: [Base UI's current iOS guidance](https://base-ui.com/react/overview/quick-start) describes viewport behavior that desktop WebKit emulation cannot establish. + +### Firefox 128 activation regression + +The real Firefox 128.0 test loaded four native fixture runs, filtered them, and opened and closed an 18-node Run Details graph. It then reproduced an enabled pipeline checkbox failing to select. A separate native-input probe confirmed that a constructed `PointerEvent('click')` delivered the click event without toggling the checkbox or emitting `input`/`change`; both `MouseEvent('click')` and native `input.click()` activated it. Base UI 1.8 uses the constructed pointer event for its hidden checkbox input. + +The shared Checkbox/Switch boundary now probes that native behavior once per owner document. On affected engines only, it uses Base UI's documented `inputRef` and `preventBaseUIHandler()` APIs to deliver a modifier-preserving mouse click to the native input. Base UI retains checked state, form values, validation, keyboard behavior and change cancellation. The fallback respects disabled/read-only controls, inherited native-input disabled state and consumer cancellation. It uses no browser-version detection, global event patch or modified dependency files. + +Focused tests cover exactly-once pointer and Space changes, the original supported-engine path, controlled and mixed state, modifiers/form values, cancellation, disabled/read-only/group behavior, and forwarded-ref cleanup. React's imperative ref hook synchronizes the external DOM ref; no derived-state effect is introduced. The retained actual Firefox 128.0 smoke passed all seven scenarios on application source `b358c3b4dc6bcf1b47c2ae399b5e85dfc3df0269`, with `index-CMs0YGmE.js` (SHA-256 `d5e8a5dc6a56c0bcaa8bb2c678a9834ed82d209b3b32516e3b0c776c561c29cd`) and `index-BB6Gh9sf.css` (SHA-256 `872fed3daa914cb73dd3272d200481097fabde285213f18666cc43c7ac1c6338`). The [retained result and six screenshot hashes](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/qualification/browser-floor-firefox128.json) identify this application build. Its native probe still reports pointer-event activation absent and mouse-event activation present, while real pointer and Space interactions successfully toggle both shared primitives. The switch check changes only an unsaved feature draft and verifies stored/runtime flags remain unchanged. + +The same session passed Runs filtering, pipeline selection, Run Details graph measurement through inspector open/close, two-run comparison empty states, and a dark dialog at 600 CSS pixels with forward/reverse focus wrapping, Escape dismissal and trigger focus return. Six screenshots were recorded; the cards, inspector, switch and narrow dark dialog were visually inspected. No post-readiness page errors were captured, and session cleanup succeeded. This is scoped minimum-version smoke evidence rather than full minimum-version workflow parity. + +The opt-in [WebDriver floor smoke](https://github.com/jeffspahr/jeffspahr-pipelines/blob/54f78798fea94aecc64006a8296a9473f453d55a/frontend/scripts/ui-modernization-browser-floor.mjs) uses a fresh temporary Firefox profile and closes its session in `finally`. It uses the existing native fixture API and production preview without backend mutations. Mozilla's official geckodriver 0.37.1 [supports Firefox 115 ESR and newer](https://firefox-source-docs.mozilla.org/testing/geckodriver/Support.html); its release-asset SHA-256 was verified before use. The smoke records actual capabilities, entry-asset hashes, screenshots, route readiness, required platform features and post-readiness page errors. Its small comparison fixture has two runs but empty parameter and scalar-metric panels, so it does not qualify populated rich comparisons. Its 600 CSS pixel dialog check does not establish 320 pixel/400% zoom or device/assistive-technology acceptance. Errors before initial Runs readiness are not observed, and this is not a complete console or network trace. diff --git a/frontend/docs/ui-modernization/evidence-archive.md b/frontend/docs/ui-modernization/evidence-archive.md new file mode 100644 index 00000000000..60d6a3284ff --- /dev/null +++ b/frontend/docs/ui-modernization/evidence-archive.md @@ -0,0 +1,11 @@ +# Historical qualification evidence + +Generated screenshots, runtime manifests, measurements and dated qualification records are retained on a [Kubeflow-owned evidence branch](https://github.com/kubeflow/pipelines/tree/codex/ui-modernization-evidence) rather than in application source. Routine CI runs upload Actions artifacts; evidence intended for long-term retention must be copied to project-owned durable storage before those artifacts expire. + +- [October 8 archive](https://raw.githubusercontent.com/kubeflow/pipelines/d9aed93d6184be1f65d36066b40eb92028a2aa25/archives/kfp-ui-evidence-54f78798f.tar.gz): the complete documentation/evidence tree at `54f78798fea94aecc64006a8296a9473f453d55a`, including the 271 remaining generated files removed from the checkout. +- [October 6 archive](https://raw.githubusercontent.com/kubeflow/pipelines/d9aed93d6184be1f65d36066b40eb92028a2aa25/archives/kfp-ui-modernization-evidence-a058b109.tar.gz): the earlier complete tree, including large reports already removed before October 8. +- [Archive and file SHA-256 index](archived-evidence.json): current removed-file inventory plus the earlier archive inventory. The dedicated evidence commit retains browsable snapshots used by historical file links. + +The archives and snapshots are pinned to evidence commit `d9aed93d6184be1f65d36066b40eb92028a2aa25`; their original SHA-256 hashes are unchanged. The branch is separate from master and must not be merged into application source. It is not a Kubeflow Pipelines product release. Each report applies only to its recorded source, assets, environment and limitations. Historical success does not qualify a later commit. Current implementation status belongs in [the tracking issue](https://github.com/kubeflow/pipelines/issues/14572) and the associated PR checks. + +Verify the downloaded archive against its SHA-256 in the index before extracting into a separate directory. Then compare the archived files against the listed per-file hashes. Do not overlay historical captures on a checkout used for a new run or commit new dated CI output directories. diff --git a/frontend/docs/ui-modernization/foundation.md b/frontend/docs/ui-modernization/foundation.md new file mode 100644 index 00000000000..848a28c8aac --- /dev/null +++ b/frontend/docs/ui-modernization/foundation.md @@ -0,0 +1,71 @@ +# UI modernization foundation + +This historical checkpoint records the initial shared foundation and Storybook shell for [issue #14572](https://github.com/kubeflow/pipelines/issues/14572) and [KEP PR #14574](https://github.com/kubeflow/pipelines/pull/14574). Its screenshots and test totals describe that slice. See [Workflows and qualification](workflows.md) for the current application-wide implementation, browser configuration and remaining release gates. The migration remains one coordinated cutover. + +## Implemented at this checkpoint + +- Tailwind 4.3.3 and its CLI, retaining `npm run build:tailwind` and the existing generated CSS import. Existing custom MUI colors/spacing, legacy grow/shrink names, focus rings and relevant preflight defaults are retained. Utilities remain unlayered while MUI/TypeStyle consumers coexist, preserving their previous precedence. +- Local Public Sans 400/500/600 and JetBrains Mono 400/500 Latin font subsets through Fontsource 5.3.0. Vite resolves and hashes the font assets when the foundation is imported; no external font requests are needed. Font licenses ship in [Public Sans OFL](../../public/fonts/public-sans/OFL.txt) and [JetBrains Mono OFL](../../public/fonts/jetbrains-mono/OFL.txt). +- Scoped semantic colors, radii, typography, visible focus and reduced-motion rules; light/dark tokens follow the design handoff with the accessibility corrections below. +- Repository-owned shadcn-style [Button](../../src/components/ui/button.tsx) and [Input](../../src/components/ui/input.tsx), using Base UI 1.8.0 primitives. Links retain link semantics; buttons default to ordinary actions unless explicitly marked `type='submit'`. [components.json](../../components.json) records the Base UI style and existing import aliases. +- [ThemeProvider](../../src/components/shell/ThemeProvider.tsx): system/light/dark, live system changes, cross-tab updates, and safe missing/invalid/unavailable storage behavior. The separate `kfp.theme` key does not migrate or remove legacy preferences. Theme classes are scoped to `.kfp-theme`; `ApplicationShell` owns the runtime provider and modal portals mirror its resolved palette. +- [AppShell](../../src/components/shell/AppShell.tsx): 236/64 px rail, namespace/build/cluster presentation, controlled navigation and secondary destinations, theme selection, persistent collapse using the existing `navbarCollapsed` key, skip-to-main focus, and a hidden-navigation mode. Below 1024 px it collapses automatically without overwriting the user's wide-screen preference. + +The shell accepts route destinations, active state and metadata from its caller. It does not import the production Router, fetch counts, create a namespace selector, or add duplicate backend requests. The application adapter passes existing deployment flags/context, preserves nested route matching, and retains the existing toolbar, error and mutation contracts; see the [Runs integration](runs.md). The [run inspection, comparison and search slice](inspection.md) adds bounded command search and additional shared controls. The remaining workflow migration at this checkpoint is now covered by the [current report](workflows.md). + +## Review in Storybook + +Use the repository-pinned Node/npm versions, install with `npm ci` in `frontend`, then run: + +```sh +npm run storybook +``` + +Open **Modernization → App shell** or **Modernization → Primitives**. Light/dark stories use separate theme preference keys; navigation uses the existing collapse preference. Resize the actual canvas viewport below 1024 px for automatic collapse, then widen it to verify restoration. The hidden-navigation story demonstrates the layout contract, not live Central Dashboard integration. The primitive form changes only preview state and makes no API requests. + +![Light shell](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/foundation/shell-light.png) +![Dark shell](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/foundation/shell-dark.png) +![Light controls](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/foundation/primitives-light.png) +![Dark controls](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/foundation/primitives-dark.png) + +These Chrome 154 screenshots show the component previews at 1280×800 on macOS. The main workspace is illustrative shell content, not a migrated Runs page. + +## Accessibility adjustments + +Small text uses a minimum 4.5:1 contrast ratio against its intended surfaces. The supplied light palette needed these changes; dark text pairs already pass: + +| Token | Handoff | Foundation | Minimum tested ratio | +| -------------------- | --------- | ---------- | -------------------------------------------: | +| Muted foreground | `#737889` | `#626979` | 4.88:1 across card/background/muted surfaces | +| Succeeded foreground | `#1f8a4c` | `#18733d` | 5.20:1 on succeeded soft background | +| Warning foreground | `#b8700a` | `#975b08` | 4.96:1 on warning soft background | +| Neutral foreground | `#8a8f9d` | `#626979` | 4.74:1 on neutral soft background | + +Focus uses a 2 px primary-colored outline with offset. Input boundaries use muted foreground rather than the low-contrast decorative border token. The [token checks](../../src/styles/modernization.test.ts) verify 54 normal-text foreground/background pairs, including destructive button text in both themes. These checks complement keyboard/browser review; they do not certify every future control or complete accessibility conformance. + +## Browser and test compatibility + +Tailwind 4 requires at least Chrome 111, Safari 16.4 and Firefox 128 according to its [compatibility guide](https://tailwindcss.com/docs/compatibility#browser-support). This applies to the generated stylesheet imported by the existing app as well as to the new components. At this checkpoint the `supports es6-module` Browserslist and ES2015 target were broader. The [current policy](../browser-support.md) distinguishes the retained conservative compiler settings from supported browser channels. Policy acceptance is complete; qualification under the new matrix remains a release gate. Component-scoped theme classes do not isolate that application-wide CSS compatibility change. + +The test DOM uses jsdom 27.4.0, whose modern CSS parser supports the generated cascade layers. jsdom 24 discarded the Tailwind 4 stylesheet. The selected version supports the pinned Node 24.14 runtime; CSS processing remains enabled. Browser checks remain authoritative for layout, fonts and computed CSS variable values. + +At this checkpoint legacy raw inputs received Tailwind 4's transparent background and production forms still used MUI. Compatibility rules supported the temporary coexistence; this describes the historical slice, not the current control stack. The existing visual/behavioral [baseline PR](https://github.com/kubeflow/pipelines/pull/14576) remains the comparison reference. + +## Historical verification and follow-up + +Run from `frontend`: + +```sh +npm run test:ui -- --maxWorkers 2 +npm run build +npm run build:storybook +node --test scripts/production-bundle.smoke.mjs +npm run check:react-peers +npm run format:check +``` + +Initial foundation verification passed on the repository-pinned runtime: 132 frontend test files / 1,677 tests, including 59 new foundation and CSS-pipeline checks. Subsequent integration coverage is described in [Shell integration and Runs](runs.md). Production and Storybook builds, UI/server lint, TypeScript, the production-bundle browser smoke, React peer compatibility and formatting checks also passed. + +The new focused checks cover theme/media/storage behavior, preference preservation, responsive restoration, active navigation and destinations, hidden navigation, keyboard focus, form submission/disabled actions, input labeling/errors, and token contrast. Browser review verifies local font loading, theme/collapse persistence across reloads, live system-theme changes, narrow/wide restoration, hidden-navigation layout, and primitive form feedback. + +The route/deployment adapter and Runs page chrome were integrated after this checkpoint; subsequent workflows and current qualification status are documented in [Workflows and qualification](workflows.md). Before release, finish workflow/state parity, keyboard and accessibility review, browser-floor qualification, performance comparison and live UI/backend upgrade/rollback rehearsal. This slice changes no backend schema, API payload or KFP Local behavior and performs no preference migration. Release qualification must identify and rehearse rollback to a compatible prior UI image as required by the KEP. diff --git a/frontend/docs/ui-modernization/hosted-browser-qualification.md b/frontend/docs/ui-modernization/hosted-browser-qualification.md new file mode 100644 index 00000000000..83fee9bcf33 --- /dev/null +++ b/frontend/docs/ui-modernization/hosted-browser-qualification.md @@ -0,0 +1,220 @@ +# Hosted browser qualification + +Broad qualification tooling is maintained in [qualification PR #14757](https://github.com/kubeflow/pipelines/pull/14757). Commands and results in this document refer to that tooling or the explicitly recorded historical source; they are not additional presubmit gates in the application PR. See [the evidence index](evidence-archive.md) for retained records. + +Browser qualification runs in GitHub Actions. It requires no workstation browser +installation, local Safari/Xcode setup, or manual test execution. + +The historical [Frontend Tests workflow](../../../.github/workflows/frontend.yml) +passes [432/432 production-fixture cases](https://github.com/kubeflow/pipelines/actions/runs/36624711864) +across Chromium, Firefox and WebKit on Linux, Windows and macOS: 48 cases per lane, +with no skipped cases. At PR head `70cde25a20f80097a786a4a0f4c68392f4bccb57` +(tested merge `c062298a63a7fe427ac5815784a38a4b96ef787f`), audited TAP/JUnit and +workflow reports agree on source identity and identical hashes for all 22 shared +build files within the engine matrix. This is engine coverage, separate from exact-version qualification. +The same hosted source passes 1,760 UI tests and 1,103 server tests, formatting, +lint, type checks, React peer checks and the production build. + +## Workflow and evidence + +[Frontend Browser Qualification in #14757](https://github.com/kubeflow/pipelines/pull/14757) +will run weekly after that PR lands and through workflow dispatch against the chosen ref. It includes the +full nine-lane OS/engine matrix, including Linux Chromium, plus the 17 +vendor/native-browser lanes. Each qualification run tests its selected source; +it does not reuse a previous PR's Chromium result. Unit/server tests, lint, type checks, the +production build and Linux Chromium remain required in `frontend.yml`. Scheduled +runs begin only after the workflow lands on the default branch. Refresh the dated +vendor pins before a manual release-candidate run and use the intended release +commit. + +Browser, performance and deployment/rollback qualification are observational, +not PR or release gates. Manual release-candidate evidence is advisory until +[issue #14754](https://github.com/kubeflow/pipelines/issues/14754) establishes +repeatability and maintainers separately approve promotion. The daily CI health +report will include these workflows after #14757 lands. First-attempt failure reporting, deduplicated +alerts, overdue-run detection and triage ownership remain follow-up work. Confirmed product regressions still require triage. + +One production build is shared by all lanes. Every selected lane must pass for +the run to count as successful evidence; failures are retained and cannot be +hidden by another passing lane. Nonblocking status does not use +`continue-on-error`, relax supported-browser policy or weaken assertions. Artifacts +include browser distribution/signature provenance where applicable, exact browser +and OS identity, source SHA, asset hashes, checks/screenshots, and lifecycle logs. +Available failure evidence is uploaded even after setup errors. Results and the +version manifest and shared build are retained for seven days. GitHub clamped the +previous 90-day request to the repository maximum of seven days, verified from +artifact creation/expiry metadata on September 30. Selected sanitized release +records must be preserved in verified project-owned evidence storage before these downloads expire. + +The installers and native runners require disposable GitHub-hosted CI. Browser +archives, extracted apps, drivers and simulator tooling live in runner temporary +storage. Apple jobs create and remove their own simulator and driver processes. + +## Dated matrix: 2026-09-29 + +The [browser catalog maintained in qualification PR #14757](https://github.com/kubeflow/pipelines/pull/14757) contains +vendor URLs, published checksums, signing identities, sources and unresolved +policy slots. Exact pins are intentional; a mismatched browser or missing runtime +fails the lane instead of silently substituting a different version. + +Chrome stable was refreshed to 154.0.8037.93 after its vendor download changed. +[Google Version History](https://versionhistory.googleapis.com/v1/chrome/platforms/mac_arm64/channels/stable/versions/154.0.8037.93/releases) +records full ARM64 stable rollout from September 29 at 18:30 UTC. Earlier Chrome +154.0.8037.58 checkpoints remain historical; they do not qualify the new pin. + +| Lane | Version | Suite | Qualification role | +| --------------------------- | ------------------ | ------------------- | --------------------------------------------------------- | +| Chrome stable | 154.0.8037.93 | 48 production cases | Current stable policy row | +| Chrome for Testing current | 154.0.8037.57 | 48 production cases | Supplementary; different Mac patch | +| Chrome for Testing previous | 153.0.8010.52 | 48 production cases | Supplementary; different Mac patch | +| Edge stable | 154.0.4258.37 | 48 production cases | Current stable policy row | +| Edge previous | 153.0.4234.48 | 48 production cases | Previous stable policy row | +| Firefox stable | 157.0 | 12 native checks | Current stable policy row | +| Firefox previous | 156.0.1 | 12 native checks | Previous stable policy row | +| Firefox ESR | 153.4.0 | 12 native checks | Current ESR policy row | +| Firefox outgoing ESR | 140.17.0 | 12 native checks | Supported ESR overlap | +| macOS Safari | 26.6.2 | 12 native checks | Runner-available build; latest policy patches remain open | +| iPhone 17 simulator | iOS/Safari 26.5 | 12 native checks | Simulator coverage; not latest policy patch | +| iPad (A16) simulator | iPadOS/Safari 26.5 | 12 native checks | Simulator coverage; not latest policy patch | + +All lanes use standard `macos-26` ARM64 runners. Simulator OS initialization has +a separate five-minute deadline. Simulator jobs then build WebDriverAgent in a +separate eight-minute phase before Appium session startup, record its resolved +package version and output hashes, and require the generated test manifest, runner +app and test executable before reuse. Mobile jobs have a 45-minute outer limit; +desktop Safari retains 35 minutes. Native UI checks retain their own bounded +command and suite deadlines. Mobile Appium commands through first-page readiness allow 60 seconds; subsequent +suite interactions keep 40 seconds. Cold Web Inspector and native accessibility +queries have returned correct results just beyond 40 seconds. Appium also waits +for an in-flight native alert probe after JavaScript returns, so the startup +allowance includes initial page readiness. Alert detection remains enabled. +Native XCTest idle waits are set to a positive two seconds, with quiescence +checks retained. The harness reads the live setting from the owned WebDriverAgent +session and requires it to match before checking the application. This bounds the +stacked waits for native input without weakening readiness or exact-value assertions. +Apple automation uses Xcode 26.6 and pins Appium 3.8.0/XCUITest 12.13.3 for simulators. It records Safari bundle builds and simulator +runtime builds as well as marketing versions. The current Chrome vendor URL is +mutable; signed-app and exact-version checks reject a changed release. Chrome for +Testing is not substituted for a missing exact end-user Chrome patch. + +Downloaded Chrome, Edge and Firefox releases require strict app-signature and +vendor-team verification. +The two supplementary Chrome for Testing archives have observed linker ad-hoc +signatures without vendor resource seals. They instead require reviewed SHA-256 +pins matching two hosted downloads, a pinned official Google Storage object +generation, and matching vendor MD5 and size. Their recorded packaging identity +and exact browser version must also match. Actual Chrome and Edge run from owned +read-only disk images because hosted updaters can change a browser during the +suite. Each version, full signature and executable hash is verified again after +the suite, and the volume is detached before uploading final provenance. Update-policy changes and +mounts are restricted to the disposable qualification runner. + +## Native workflow coverage + +The native suite extends the earlier seven-smoke checkpoint to 12 checks: platform +capabilities; Runs filtering; graph/task inspection; pipeline selection; production +YAML editor rendering; import validation; experiment draft values; run/recurring-run +drafts; artifacts/lineage; settings controls; comparison selection; and command +palette, focus, themes and responsive behavior. Desktop checks exercise keyboard +controls; simulator checks use native page taps and WebDriver text-entry commands. +iOS WebDriver text entry uses Appium's WebKit typing atom and does not establish +physical-keyboard or IME behavior. Reports identify the interactions exercised on +each platform. The read-only YAML check compares the complete existing Ace +document and read-only setting before and after native keyboard input on desktop +and theme changes on all platforms; virtualized visible lines are not a stable +proxy for document contents. + +Desktop Safari CI configures macOS inline predictions and automatic spelling +correction off before browser launch and records each preference readback. +[Apple documents the user settings](https://support.apple.com/guide/mac-help/mchlp2299/mac); +the `NSGlobalDomain` key mapping follows the +[nix-darwin implementation](https://github.com/nix-darwin/nix-darwin/blob/4cff07de74b50e64bdd68cd4e722ab5b6b35ee48/modules/system/defaults/NSGlobalDomain.nix), +not an Apple-published CLI contract. Exact field assertions still apply. Native +IME, predictive-text and autocorrection compatibility remain unqualified; these +checks establish deterministic native keyboard input only. Retained input-event +diagnostics distinguish native composition edits from application value resets. + +These read/draft checks use fixed same-origin HTTP fixtures and reject backend +mutations. They do not establish parity with every request/mutation/recovery case +in the 48-case suite, real deployment authorization, physical-device behavior, +performance budgets, or untested assistive technology. Screenshots retain evidence; +they do not by themselves establish visual equivalence. + +## Hosted results + +[Run 36624712051](https://github.com/kubeflow/pipelines/actions/runs/36624712051) +passes **12/12 lanes and 324/324 checks**, all on attempt 1, at PR head +`70cde25a20f80097a786a4a0f4c68392f4bccb57` and tested merge +`c062298a63a7fe427ac5815784a38a4b96ef787f`. Every configured row in the dated matrix +above is verified for its stated scope: + +| Verified lanes | Passing checks | +| --------------------------------------------------- | ------------------------ | +| Actual Chrome stable and Edge stable/previous | 144/144 production cases | +| Supplementary Chrome for Testing current/previous | 96/96 production cases | +| Firefox stable/previous and both supported ESR rows | 48/48 native checks | +| Desktop Safari with recorded CI text preferences | 12/12 native checks | +| iPhone 17 simulator, Safari 26.5 | 12/12 native checks | +| iPad (A16) simulator, Safari 26.5 | 12/12 native checks | + +The independent audit verifies source/merge identity, all 22 shared build-file +hashes within the qualification matrix, exact browser and downloaded-distribution +identity, all 98 native screenshot hashes, Chrome/Edge final signed executable +identity, and owned volume/simulator cleanup. All 57 browser-free qualification +helper checks pass. The separate engine build has identical runtime files; its +`Editor-CiEMRP2l.js.map` differs only in two build-generated Vite placeholders. +The source-map difference is retained explicitly in the build comparison. + +Both simulators verify first-page startup completion before the checks, unchanged +filter scale 1→1, native run/artifact link activation with route assertions, and +native Recurring/One-off activation with associated-radio and checked-state +assertions. Live WebDriverAgent settings read back the positive two-second idle +wait. Prebuilt WDA package identity and output hashes are recorded with the exact +owned simulator and derived-data path; those binaries are not uploaded, so their +hashes are reported provenance rather than independently rehashed artifacts. + +Native Safari preparation handles its known first-launch prompts, keyboard and +active address editor before interaction. Wrapped inline targets use an actual +client fragment for hit testing. Uniquely typed native link/radio actions avoid +[Appium coordinate-calibration errors](https://github.com/appium/appium-xcuitest-driver/blob/v12.13.3/lib/commands/web-native-bridge.ts#L376) +while retaining viewport, route, selection and draft-value assertions. Native +alert detection remains enabled throughout the bounded startup and test phases. + +Qualification also caught two application regressions: artifact reads/downloads +now wait for experiment namespace resolution, and shared Input/TextField text is +16px on coarse-pointer devices to prevent Safari input focus zoom. The earlier +13px field produced scale 1.231343 on iPhone, matching +[WebKit's focus-zoom calculation](https://github.com/WebKit/WebKit/blob/main/Source/WebKit/UIProcess/API/ios/WKWebViewIOS.mm#L1752-L1773). +Current native checks preserve scale and inspector Close reachability. User zoom +remains enabled; arbitrary pinch-zoom modal reachability is still unqualified. + +Earlier failed attempts retain evidence of vendor-version drift, simulator +startup/discovery, native calibration and an isolated previous-Edge first-test +timeout. Those partial results are not counted in the passing matrix above. +The narrower native fixture scope, policy-version gaps and release acceptance +criteria remain separate from this completed qualification checkpoint. + +## Remaining policy coverage and BrowserStack follow-up + +- Resolve actual previous Chrome Mac and Chrome/Edge Extended Stable distributions; + the catalog records why the available supplementary builds do not close them. +- Qualify current/previous annual Safari and iOS/iPadOS at their latest policy + patches. The pinned runner-available builds above are partial Apple coverage. +- Extend native Firefox/Apple mutation and recovery parity beyond the read/draft + checks, and qualify the remaining exact desktop OS/version combinations. +- Automate native IME, predictive-text and autocorrection scenarios separately + from the deterministic desktop Safari input configuration. +- Investigate [BrowserStack open-source access](https://www.browserstack.com/open-source) + for missing exact versions and physical iPhone/iPad coverage. As of September 29, + its public program lists Automate, five users and five parallel sessions; project + approval, available inventory and retention still need confirmation. Its + [GitHub Actions integration](https://www.browserstack.com/docs/automate/selenium/github-actions) + supports automated execution and a CI tunnel to the fixture server. Use the + [Automate inventory API](https://www.browserstack.com/docs/automate/api-reference/selenium/browser) + to establish exact available combinations once credentials exist. +- Validate CI connection to the fixture server, immutable build provenance, + screenshots/log retention and account access requirements before proposing a + BrowserStack integration. No account application or purchase is part of this work. + +The [browser support policy](../browser-support.md) remains unchanged. Missing +coverage stays open in the issue and is not reclassified as a successful test. diff --git a/frontend/docs/ui-modernization/inspection.md b/frontend/docs/ui-modernization/inspection.md new file mode 100644 index 00000000000..c8c82e9a0c4 --- /dev/null +++ b/frontend/docs/ui-modernization/inspection.md @@ -0,0 +1,37 @@ +# Run inspection, comparison and search + +This historical checkpoint records the inspection, comparison and search slice after [shell and Runs](runs.md), toward the coordinated cutover in [KEP PR #14574](https://github.com/kubeflow/pipelines/pull/14574). It preserved existing data clients, run/task polling, graph mapping and mutations while graph/editor and some viewers still had temporary light boundaries. Those presentation boundaries have since been migrated; see [Workflows and qualification](workflows.md) for the current state and remaining release gates. Screenshots and test totals below describe this earlier checkpoint. + +## Implemented behavior at this checkpoint + +- Run Details uses the shared toolbar, confirmation and notification contracts. Metadata links preserve encoded pipeline/version IDs. The runtime-task summary uses the complete paginated task response and excludes structural root/DAG/loop records. Missing data is not reported as a global zero count. +- The failure banner selects an actual failed runtime task and opens its Logs tab through the existing `task` query and nested graph mapping. It preserves other query parameters, URL state and browser history. No exit code or Kubernetes event feed is inferred. +- The task inspector retains Input/Output, Task Details and Logs, including artifacts, parameters, pods, volumes and task state history. It is resizable on desktop and becomes a modal below 900px, with keyboard focus trapping and return. +- Log presentation follows light/dark tokens while preserving fixed 15px virtualized rows, horizontal scrolling, bounded overscan, follow-tail pause/resume, executor/artifact/driver source distinctions, retry handling and stale-response protection. +- Comparison retains selected-run order, encoded links, missing/empty/zero distinctions, artifact provenance and rich visualizations. ROC paging/search retains selections outside the displayed option page. Fullscreen visualizations use the shared modal. +- Search is available from the rail, embedded layout and Ctrl/Cmd+K. After a 275ms debounce and at least two characters it requests one page of at most five results from each of Runs, Pipelines and Experiments in the active namespace. Closing, unmounting or changing scope/query aborts old requests; stale results cannot replace the current query. Partial failures, retry, empty results and archived labels are explicit. No history scan or per-row fetch is added. +- Shared controls now include semantic inspection tabs/fields, modal dialogs, alerts, accessible text fields, and a resource-table presentation supporting expansion, radio pickers and selection limits. The existing table controller retains filtering, sorting, paging and preference storage. + +## Browser review + +![Run Details and task logs in dark mode](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/inspection/run-details-dark.png) +![Namespace-scoped command search in dark mode](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/inspection/command-palette-dark.png) + +These historical Chromium captures use synthetic fixtures at 1440×900. The white graph shows the temporary boundary at that checkpoint; the [current graph presentation and qualification](workflows.md#graph-editor-and-runtime-contracts) use light/dark tokens. + +## Historical verification + +Run with the repository-pinned toolchain from `frontend`: + +```sh +npm run test:ci +npm run test:bundle +``` + +The frozen slice passed 141 UI files / 1,802 tests and 29 server files / 1,052 tests with coverage. Formatting, UI/server lint, app/mock TypeScript, React peer compatibility and the production build pass. Existing behavioral tests are retained; snapshot updates cover intended presentation changes. + +The production browser suite at this checkpoint had 15 scenarios: startup, six Runs/search checks and eight Run Details checks. It covers nested task links, copied URLs, back/forward navigation, deleted-version read paths, initial/refresh failure recovery, exact-once retry, stale logs, source fallbacks, horizontal/follow-tail behavior, failed-task navigation, desktop/narrow focus, prefixed embedding and namespace changes. The run-inspection harness was wired into the existing `test:bundle` CI entry point. Set `KFP_RUN_DETAILS_SCREENSHOT_DIR` and `KFP_RUNS_SCREENSHOT_DIR` to retain review captures. + +The original six run-inspection scenarios also passed against the prior production bundle. For the deterministic five-node/six-edge fixture, old/new graph identities match; maximum node-geometry change was 0.00101px and maximum edge-coordinate change was 0.000718px, within the existing 0.05px jitter tolerance. This is geometry/workflow evidence, not qualification of the future graph redesign or large-graph performance. + +The evidence above uses local synthetic data and Chromium. Subsequent comparison, graph and workflow checks and all remaining qualification gates are tracked in [Workflows and qualification](workflows.md); this historical list must not be used as the current completion report. No backend schema, API payload or Python `kfp.local` behavior changes in this slice. diff --git a/frontend/docs/ui-modernization/layout-stability.md b/frontend/docs/ui-modernization/layout-stability.md new file mode 100644 index 00000000000..52dc32d393f --- /dev/null +++ b/frontend/docs/ui-modernization/layout-stability.md @@ -0,0 +1,110 @@ +# UI modernization layout stability + +The [supported-browser checkpoint](supported-browser-qualification.md) adds actual branded-browser evidence on identical emitted assets. It does not replace the measurements or source identities below. + +This report records the layout-stability follow-up at application source `e79f8d423e6b118e5df94815ee2f36f68570a9a9`. It includes the layout changes from `4ee7610b739ef57c54155e89c9f3111adee2118f` and the subsequent short-viewport correction. The [previous performance qualification](performance-qualification.md) retains results for `b358c3b4dc6bcf1b47c2ae399b5e85dfc3df0269`; those observations remain historical and are not measurements of this build. [Workflow coverage](workflows.md) and [browser/accessibility qualification](browser-accessibility-qualification.md) describe the wider migration and its remaining release gates. + +The practical question is whether the same requested content and interactions remain at least as responsive while avoiding the observed loading and filtering shifts. A smaller bundle or a successful functional smoke test alone cannot answer that question. The measurements below must be evaluated separately for content readiness, interaction readiness and layout stability; they do not establish an intrinsic framework speedup. + +## Confirmed shifts and corrections + +- **Runs loading and filtering:** the full Runs page keeps its table footer in place while results change from loading to ten rows, one row, no matches and back. Run columns now have stable widths, with the run-name column taking the remaining space. The table retains horizontal scrolling and its existing selection, sorting, paging and request controls. The fixed page-height treatment is scoped to the full Runs page, so nested resource tables retain their existing page flow. +- **Comparison overview and empty sections:** a comparison already knows the requested run IDs. Its overview reserves that row footprint while the existing reads resolve; it does not fabricate data rows. Loading and genuinely empty parameter/scalar sections share the same minimum placeholder height. Refresh preserves the existing data while pending and replaces it with the completed response, with no change to API or selection semantics. +- **Run summary:** stable columns and a minimum value footprint prevent completed runtime-task counts from moving adjacent fields. The summary switches to two columns below 1240 pixels and one column at 480 pixels, with wrapping permitted for long values. The wider breakpoint leaves room for classic scrollbars beside an expanded navigation rail. +- **Short Run Details viewports:** the taller reserved summary exposed a real regression at 780×437: the Detail panel could shrink to zero height. The run-page wrapper now honors its intrinsic minimum height so the existing outer page can scroll. The graph retains its 380-pixel minimum, and inspector/log scrolling and virtualization are unchanged. Regression checks cover readable, reachable Detail values at both 780×437 and 375×437, followed by opening Graph and task logs and returning to navigation. +- **Sidebar metadata and fonts:** expanded navigation reserves optional version, cluster and project rows while keeping unavailable values absent. Long values may wrap and grow; collapsed navigation has no metadata reservation. The four critical font faces are preloaded through Vite-rewritten relative asset URLs. This changes when those fonts are requested; they remain separate from the initial JS/CSS bundle totals. + +These changes preserve the existing namespace, query, mutation and persisted-preference contracts. They do not add a backend state filter, global resource statistics or a data migration. The deferred Runs status-filter dependency remains described in [workflow compatibility notes](workflows.md). + +## Measurement method + +The original application baseline is `02cbc725ac9ddcd950f4400d8355dd78bfcd6c57`; the candidate is `e79f8d423e6b118e5df94815ee2f36f68570a9a9`. The final [bundle inventory](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/layout-stability/bundle-inventory.json), [observations](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/layout-stability/performance-evidence.json) and [protocol](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/layout-stability/protocol.json) identify the emitted assets and exact sampling provenance. Earlier `4ee7610b` inventories and diagnostic observations are not final-build evidence. + +Apple M2/16 GiB, macOS 26.6.2/arm64; headless Chrome 154, 1280×720/DPR1/light, CPU 4× slowdown and Fast 4G. All baseline samples were rerun alongside the candidate: three interleaved trials per build for three loads, filtering and run/task navigation, totaling 30 observations. Every trial uses a fresh isolated context; the browser process, OS caches and local native fixture API remain warm. No task-owned builds, tests, browser audits or compression run during sampling. Native fixtures and instrumentation are unchanged from the prior protocol. DevTools summaries and browser entries are retained; raw traces were not exported. These are lab observations, not field performance or deployed-backend load results. + +Both builds use the same populated-route and successful-API predicates, followed by two animation frames. Fonts readiness is recorded separately. Runs requires four known links plus experiment metadata; Details requires the known visible task node and run/task reads; Compare requires both selected links, successful reads and the honest empty-parameters state. Filtering includes the existing debounce and ends after the matching result is rendered; run/task navigation ends at the same known graph node and inspector controls. Retained browser layout-shift observations and DevTools summaries preserve their actual capture windows and rounding. Lab interaction-to-next-paint values are not field INP or population percentiles. + +Median (minimum–maximum), milliseconds except CLS. Lower is better. + +| Route | Legacy content ready, ms | New content ready, ms | Change | Legacy observed CLS | New observed CLS | +| ------------- | -----------------------: | --------------------: | -----: | ---------------------: | ---------------------: | +| Runs | 2,405 (2,403–2,444) | 1,739 (1,731–1,783) | -27.7% | 0.0085 (0.0085–0.0085) | 0.0000 (0.0000–0.0000) | +| Run Details | 2,521 (2,506–2,531) | 2,076 (2,074–2,347) | -17.6% | 0.0014 (0.0014–0.0014) | 0.0000 (0.0000–0.0000) | +| Empty Compare | 2,332 (2,332–2,348) | 1,852 (1,850–1,872) | -20.6% | 0.0443 (0.0443–0.0443) | 0.0000 (0.0000–0.0000) | + +| Route | Legacy LCP, ms | New LCP, ms | +| ------------- | ------------------: | ------------------: | +| Runs | 2,023 (2,013–2,036) | 1,740 (1,733–1,784) | +| Run Details | 1,939 (1,899–1,948) | 1,900 (1,893–2,149) | +| Empty Compare | 2,283 (2,275–2,286) | 1,430 (1,427–1,442) | + +| Interaction endpoint | Legacy, ms | New, ms | Change | +| --------------------------------- | ------------: | ------------: | -----: | +| Filter result, including debounce | 746 (736–752) | 547 (547–551) | -26.6% | +| Run click → known task node | 623 (622–624) | 590 (574–591) | -5.4% | +| Task click → inspector controls | 127 (124–135) | 88 (80–88) | -31.2% | + +| Interaction trace | Legacy lab INP, ms | New lab INP, ms | Legacy reported CLS | New reported CLS | +| ------------------- | -----------------: | --------------: | ------------------: | ---------------: | +| Filtering | 54 (49–55) | 27 (27–27) | 0.00 (0.00–0.00) | 0.01 (0.01–0.01) | +| Run/task navigation | 137 (134–153) | 103 (90–104) | 0.00 (0.00–0.00) | 0.00 (0.00–0.00) | + +Load CLS uses retained `LayoutShift` entries through the recorded observer snapshot after fonts readiness, excluding recent input and taking the largest session window (under one second between shifts and under five seconds overall). Full precision is retained in JSON. Interaction CLS and LCP use separately rounded DevTools summaries; lab INP is the maximum insight value per trace. The JSON also retains FCP, fonts-ready, all API/resource entries, interaction marks and observation endpoints. LCP targets differ: the legacy sidebar cluster label versus redesigned run/title content. The separate DevTools and browser paint paths are not reconciled; they do not establish paint ordering. + +All nine candidate load observations contain no layout-shift entries. The preceding candidate recorded median observed CLS of 0.023850 on Runs, 0.004198 on Details and 0.061255 on empty Compare; those are historical diagnostic values, not contemporaneous samples. The new matched comparison shows content-readiness medians 27.7%, 17.6% and 20.6% below legacy, respectively. Filtering improves 26.6%, run opening 5.4%, and task opening 31.2%. Details LCP has overlapping ranges and one slower candidate sample; the lower median does not establish a universal improvement. + +**Filtering retains a residual difference:** observed CLS is 0.013583 (0.013583–0.013583) for the candidate versus 0.003459 (0.003459–0.003459) for legacy. DevTools rounds these to 0.01 and 0.00. In all three candidate samples, the only shift source is the same keyed matching row moving from y346.5 to y218.5 after its two preceding 64-pixel rows are removed; width, height and horizontal position are unchanged. The debounce plus response/render time puts this motion beyond the recent-input exclusion. Column headers, pagination and sidebar controls remain fixed. Legacy instead records a smaller footer movement. Retaining the matching row and compacting the filtered results is expected behavior; remounting it or delaying presentation merely to suppress CLS is not part of this fix. This residual remains subject to the issue's explicit performance-budget acceptance, rather than being represented as zero shift or an across-the-board performance pass. + +The result is improved loading stability with the measured readiness gains preserved. It applies to these fixtures and settings, not every dataset or field percentile. Arbitrary long metadata and unknown populated content may legitimately grow. + +## Representative larger workloads + +Three fresh-context samples each on the same final application, using Playwright Chromium 145 without CPU/network throttling and warm local route fixtures. Host-side timings include automation overhead, so these are separate from the native comparison and do not claim a baseline speedup or scale ceiling. Retained [graph evidence](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/layout-stability/graph-chromium.json) covers 200 executable tasks, 201 rendered nodes, 363 edges, two task pages and settled geometry. [Comparison evidence](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/layout-stability/comparison-chromium.json) covers two runs, typed zero/false/empty parameters, scalar zero, 111 classification artifacts, 100/11 paging, selected provenance and real curve expansion. Both records retain fixture, harness, index and loaded-asset hashes. + +| Endpoint | Median (minimum–maximum), ms | +| ----------------------------------- | ---------------------------: | +| 200-task graph ready | 628 (619–654) | +| Task activation → inspector | 61 (60–65) | +| Fit View → settled changed viewport | 114 (113–114) | +| Populated comparison ready | 209 (208–336) | +| Classification tab → three curves | 150 (133–150) | +| Off-page selection → four curves | 71 (71–72) | +| Expand → larger rendered chart | 94 (92–94) | + +## Verification and asset identity + +At application source `e79f8d423e6b118e5df94815ee2f36f68570a9a9`, UI coverage passes **1,756 tests in 133 files**. Formatting, UI/server lint, application/mock TypeScript, React peer checks, repository hooks, production build and Storybook build pass. This slice does not change the Express server or dependency manifests/lockfiles; the retained **1,052 server tests** remain attributed to their previously recorded source rather than described as a new server-suite run. + +The production matrix adds three named scenarios to the previous 44-per-engine inventory: + +1. [Runs](../../scripts/ui-modernization-runs.smoke.mjs): held initial data and metadata; stable columns, footer and sidebar controls through ten/one/zero/ten results; exact result IDs and page-token reset; collapsed/narrow navigation. +2. [Run Details](../../scripts/ui-modernization-run-details.smoke.mjs): held task data, stable summary geometry when counts arrive, exactly one task request, and containment at 1200 and 375 pixels. +3. [Comparison](../../scripts/ui-modernization-comparison.smoke.mjs): held initial reads and refresh, stable overview/section geometry, honest empty panels, retained selection/order and query contracts, and completed-response content replacing the previous names. + +An existing Run Details scenario is also strengthened with nonzero Detail-panel height and native scroll/hit-testing at 780×437 and 375×437. It retains its semantic-value, graph/log and navigation assertions; it does not add another case. + +The [final production browser matrix](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/layout-stability/browser-matrix.json) passes **141/141** cases: 47/47 in chromium 145.0.7632.6, 47/47 in firefox 146.0.1, 47/47 in webkit 26.0. Its asset hashes match the measured build. Fresh reviewed screenshots include [Runs light](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/layout-stability/runs-light.png), [Runs dark](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/layout-stability/runs-dark.png), [Run Details graph](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/layout-stability/run-details-graph.png), [populated comparison](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/layout-stability/comparison-light.png) and [narrow inspector](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/layout-stability/run-details-narrow-inspector.png). + +Entry JS/CSS only; offline per-file gzip level 9 and Node-default Brotli. Fonts, deferred editor/workers and other emitted files are itemized separately in the inventory; these are not HTTP transfer sizes. + +| Build | Raw bytes | gzip bytes | Brotli bytes | +| ------------------------------------ | --------: | ---------: | -----------: | +| Original legacy baseline | 2,832,056 | 785,657 | 635,011 | +| Prior qualified candidate `b358c3b4` | 2,141,724 | 596,193 | 488,701 | +| Layout-stable candidate `e79f8d42` | 2,143,872 | 596,673 | 489,101 | + +Initial gzip remains **24.1% below legacy** and **20.9% below retirement**, adding 480 bytes relative to the prior qualification. The four preloaded fonts were already used by these routes; the Runs regression asserts four distinct font URLs and exactly four requests. The pass adds no dependency or data request. + +The earlier actual Firefox 128 seven-scenario run and hosted frontend/four Kubernetes integration lanes passed on `b358c3b4`, as recorded in [browser/accessibility qualification](browser-accessibility-qualification.md). The scoped Lighthouse snapshots retain their individual `a2ea3e85` and `b80da474` source/asset identities, accessibility score 100 and reviewed experimental warnings. None of those prior results is relabeled as an `e79f8d42` rerun. + +Fresh hosted qualification at `e79f8d42` also passes [hosted frontend checks](https://github.com/kubeflow/pipelines/actions/runs/36487388896/job/109147489374), [Kubernetes 1.33.12 TLS on](https://github.com/kubeflow/pipelines/actions/runs/36487389231/job/109147490404), [Kubernetes 1.33.12 TLS off](https://github.com/kubeflow/pipelines/actions/runs/36487389231/job/109147490570), [Kubernetes 1.36.1 TLS on](https://github.com/kubeflow/pipelines/actions/runs/36487389231/job/109147490786), [Kubernetes 1.36.1 TLS off](https://github.com/kubeflow/pipelines/actions/runs/36487389231/job/109147490431). The [retained check snapshot](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/layout-stability/hosted-checks.json) also records successful pre-commit, DCO and aggregate CI. This does not close deployment-specific authorization or rollback gates. + +## Remaining acceptance criteria + +- Agree workload-specific performance and layout-stability budgets and explicitly disposition the final measurements; a low absolute CLS or a faster readiness median alone does not close every criterion. +- Retain the distinction between deterministic fixture coverage and real-backend performance. The graph/comparison records above have their own workload scope; editor-first-open latency remains unmeasured. +- Complete actual supported-browser and device checks under the [approved browser policy](../browser-support.md). Policy acceptance is complete; the recorded Playwright engines do not qualify all newly declared stable/enterprise channels or annual Safari/iOS releases. +- Complete real assistive-technology/device review. Scoped automated Lighthouse, forced-colors and keyboard checks do not establish blanket WCAG conformance. +- Qualify the remaining standalone/embedded authorization and deployment modes, and rehearse previous UI → candidate UI → previous UI against the same compatible backend using immutable image identities and retained data/preferences/signing state. + +KFP Local requires no migration for this UI slice; no SDK, pipeline-format, backend API or persisted-data change is introduced. The frontend image still includes Express, so static-bundle evidence alone does not complete deployment or rollback qualification. [Issue #14572](https://github.com/kubeflow/pipelines/issues/14572) tracks these open gates alongside [KEP PR #14574](https://github.com/kubeflow/pipelines/pull/14574) and [implementation PR #14584](https://github.com/kubeflow/pipelines/pull/14584). diff --git a/frontend/docs/ui-modernization/performance-comparison.md b/frontend/docs/ui-modernization/performance-comparison.md new file mode 100644 index 00000000000..4e7ccef991d --- /dev/null +++ b/frontend/docs/ui-modernization/performance-comparison.md @@ -0,0 +1,73 @@ +# UI modernization performance comparison + +Broad qualification tooling is maintained in [qualification PR #14757](https://github.com/kubeflow/pipelines/pull/14757). Commands and results in this document refer to that tooling or the explicitly recorded historical source; they are not additional presubmit gates in the application PR. See [the evidence index](evidence-archive.md) for retained records. + +**Historical retirement checkpoint:** newer application source, repeated measurements and compatibility checks are recorded in [performance qualification](performance-qualification.md) and [browser/accessibility qualification](browser-accessibility-qualification.md). The results below remain tied to their original source and assets. + +Retiring the unused presentation code reduces the initial compressed payload below the original UI baseline and improves the measured load medians relative to the [pre-retirement candidate](https://github.com/kubeflow/pipelines/blob/5008b24d93387dba1a2c71d79f3756ad67fee10f/frontend/docs/ui-modernization/performance-comparison.md). It does **not** establish overall performance parity: Runs and Run Details LCP remain slower than baseline, filtering readiness remains slower, and layout shifts remain higher. Performance acceptance and workload-specific budgets remain open. + +Application source: `2e52a3fa634fa0805511a37c982f8d13f690b868`. The [browser matrix](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/workflows/browser-matrix.json), reviewed screenshots and measurements identify the same entry assets: `index-CsUTqcBF.js` and `index-8lXTuxuW.css`. [Machine-readable measurements](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/workflows/performance-evidence.json) retain hashes, individual trials, browser performance entries, readiness assertions and DevTools summary excerpts. [Bundle inventory](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/workflows/bundle-sizes.json) and [measurement functions](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/workflows/performance-protocol.json) accompany the results. + +## Method and comparison scope + +The [published baseline](https://github.com/kubeflow/pipelines/blob/339670f5e/frontend/docs/ui-modernization/performance-baseline.md) uses application source `02cbc725ac9ddcd950f4400d8355dd78bfcd6c57` and harness `4699581fa9b4e2951105200724aa56e92c841791`. Both builds use unchanged native baseline API fixtures: four available succeeded runs, four pipelines and experiments, native tasks/artifacts for the selected run, and a two-run comparison with no runtime parameters or scalar metrics. Fixture commit fields are fixture data, not running source identities. + +Settings: HeadlessChrome 154.0.0.0, Chrome DevTools MCP 1.10.1, CPU slowdown 4×, Fast 4G, 1280×720 CSS pixels, DPR 1, light theme, en-US and America/New_York; Node 24.14.0. Vite preview serves production assets with the native mock API over HTTP/1.1 loopback. Each load starts in a unique isolated context at `about:blank`, with tracing before its first navigation. Browser process, OS caches and API server remain warm. Task-owned builds, tests and other browser runs were stopped during sampling. This is not a cold process/backend or production deployment experiment. + +Three loads per route verify expected names/API 200 responses or the known visible task node, then wait for fonts and two animation frames. FCP comes from browser paint entries; LCP/CLS and lab interaction-to-next-paint (INP) come from DevTools summaries. Candidate raw trace export/reconciliation was unavailable; only summaries and browser entries were retained. The baseline retains filtered trace extracts and reconciled interaction timings. On Compare, reported LCP is 1–2 ms below browser FCP; these separate reporting paths are not reconciled and do not establish paint ordering. These are lab observations, not field INP or population percentiles. + +## Fresh loads + +Median (minimum–maximum), milliseconds except CLS; three samples per route and build. + +| Route | Baseline FCP | Retired candidate FCP | Baseline LCP | Retired candidate LCP | LCP median delta | Baseline → candidate CLS | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| Runs | 1,836 (1,808–1,856) | 1,616 (1,604–1,692) | 2,002 (1,972–2,053) | 2,130 (2,107–2,148) | +6.4% | 0.01 → 0.02 | +| Run Details | 1,736 (1,732–1,748) | 1,556 (1,548–1,564) | 1,912 (1,910–1,919) | 2,190 (2,182–2,212) | +14.5% | 0.00 → 0.01 | +| Compare, empty parameters/metrics | 1,936 (1,888–1,968) | 1,648 (1,640–1,660) | 2,329 (2,300–2,364) | 1,646 (1,639–1,659) | -29.3% | 0.04 → 0.06 | + +The preceding candidate's median LCP was 2,266 ms for Runs, 2,323 ms for Run Details and 1,847 ms for empty Compare. Retirement reduces those medians to 2,130, 2,190 and 1,646 ms respectively, but the Runs and Details samples still exceed their baseline ranges. The largest painted elements can differ after a redesign. Empty comparison results do not establish populated-table/chart performance. + +Browser-reported transfers include the document, observed assets and API responses; they differ from offline compression: + +| Route | Baseline transfer bytes | Candidate transfer bytes | Candidate entries including document | +| --- | ---: | ---: | ---: | +| Runs | 962,495 | 992,572 | 13 | +| Run Details | 855,865 | 885,942 | 14 | +| Compare, empty parameters/metrics | 898,575 | 928,652 | 18 | + +Totals are identical within each route's three samples. Encoded/decoded body sizes are retained separately; cached repeated responses can contribute body size with zero transfer size. A smaller offline JS/CSS payload does not by itself guarantee lower total browser transfers, which include fonts and other assets. + +## Filtering and navigation + +Three separately loaded Runs pages receive physical filter input. Readiness runs from the last `xgboost` input event to one matching row plus two animation frames, including the existing 300 ms debounce and request wait. Median readiness is **725.5 (724.2–727.9) ms**, versus baseline **693.8 (693.7–695.6) ms** (+4.6%). Lab INP is **28 (27–32) ms**, versus **45 (42–45) ms**; filtering CLS remains **0.03**, versus **0.00**. Lower input latency does not remove the readiness or layout-shift regressions. + +Three physical run/task navigation trials measure click-handler start to the known task node or visible Task Details controls, then two animation frames. Task readiness does not imply loaded logs/artifacts. + +| Interaction | Candidate median (range), ms | Baseline exploratory observation, ms | +| --- | ---: | ---: | +| Open run from Runs | 567.2 (565.8–567.8) | 589.4 | +| Open task controls | 93.7 (76.0–95.8) | 121.3 | +| Maximum reported INP across each trace's insight sets | 110 (93–111) | 135 | + +The baseline navigation trace is one exploratory observation starting from a list filtered to one run; these three candidate trials start from four unfiltered runs. The starting states therefore differ, and the baseline is not a repeated distribution, so this does not establish a statistically reliable navigation improvement. Individual insight-set INPs and API statuses are retained; the maximum is used for each trace. + +## Bundle sizes + +Per-file offline gzip level 9 and Node Brotli defaults match the baseline method. These are encoded-file sizes, not observed HTTP transfers. + +| Entry | Baseline raw bytes | Candidate raw bytes | Baseline gzip bytes | Candidate gzip bytes | Baseline Brotli bytes | Candidate Brotli bytes | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| JavaScript | 2,803,952 | 2,631,014 | 780,136 | 737,377 | 630,132 | 600,695 | +| CSS | 28,104 | 96,222 | 5,521 | 16,704 | 4,879 | 14,643 | + +Combined initial JS/CSS gzip is **754,081 bytes**, down **31,576 bytes (4.0%)** from baseline and **78,467 bytes (9.4%)** from the pre-retirement candidate. JavaScript gzip is 5.5% below baseline; CSS remains 11,183 gzip bytes larger. The lockfile removes 74 packages with no retained version changes. There are no remaining MUI/Emotion/typestyle dependencies or application imports. Inventory separates emitted editor workers and font formats from entry assets, excluding sourcemaps/analyzer output; not every worker is loaded on first visit. + +## Open acceptance gates + +- Reduce or explicitly accept the remaining Runs/Details LCP, filtering-readiness and layout-shift regressions against agreed budgets. +- Repeat representative large-run/graph and populated-comparison timings with enough trials to assess variability. The 200-task functional fixture is not a repeated scalability benchmark. +- Qualify accepted minimum browser versions, actual Safari/iOS and representative assistive technology. +- Complete final-source hosted deployment qualification and the unchanged-backend upgrade/use/rollback rehearsal with immutable UI/Express images and retained data/preferences/signing key. + +These measurements do not establish a framework-wide speedup, release readiness or live rollback success. diff --git a/frontend/docs/ui-modernization/performance-qualification.md b/frontend/docs/ui-modernization/performance-qualification.md new file mode 100644 index 00000000000..08537c4afc8 --- /dev/null +++ b/frontend/docs/ui-modernization/performance-qualification.md @@ -0,0 +1,86 @@ +# Performance qualification + +Broad qualification tooling is maintained in [qualification PR #14757](https://github.com/kubeflow/pipelines/pull/14757). Commands and results in this document refer to that tooling or the explicitly recorded historical source; they are not additional presubmit gates in the application PR. See [the evidence index](evidence-archive.md) for retained records. + +Latest follow-up: [loading-layout stability](layout-stability.md) records the `e79f8d42` fixes, 141-case browser matrix and repeated matched measurements. The source-specific results below remain historical evidence. + +This report supersedes the timing conclusions in the [retirement checkpoint](performance-comparison.md), while retaining that checkpoint as historical evidence. It compares original baseline source `02cbc725ac9ddcd950f4400d8355dd78bfcd6c57` with candidate `b358c3b4dc6bcf1b47c2ae399b5e85dfc3df0269` under a matched protocol. [Browser/accessibility qualification](browser-accessibility-qualification.md) records separate coverage and remaining release gates. + +## Changes and diagnosis + +The old sidebar cluster label was the largest-painted element on Runs and Details; the redesigned page paints a run ID/title as LCP. LCP is retained, but it is not an equivalent requested-content endpoint across these layouts. The new protocol also measures the same populated route and successful API responses in both builds, with instrumentation installed before navigation. + +The candidate defers Ace and its modes until an editor is opened, starts independent experiment metadata alongside the Runs request, and reserves the breadcrumb/header footprint while Details and Compare metadata load. Editor code is deferred, not eliminated: first editor-open latency is outside these timings. Request generations and error handling remain covered; no API or persisted-data migration is introduced. + +The first post-audit measurements detected a new header regression: removing an unnamed breadcrumb also removed the pending header, moving Details content down 108 pixels when it appeared. Those observations were retained as diagnosis, not combined with the corrected candidate's results. Explicit pending-route header space preserves semantics and loading geometry; held-response browser assertions cover both routes. Runs still moves the table footer when loading rows become available; sidebar metadata and late fonts can also move content. Remaining shifts and budget acceptance are reported below. + +## Native-fixture method + +Apple M2 with 16 GiB RAM, macOS 26.6.2/arm64. Headless Chrome 154, viewport 1280×720/DPR1/light, CPU 4× slowdown and Fast 4G. Three trials per route and interaction per build, interleaved baseline/candidate; each uses a fresh isolated context. The browser process, OS caches and local native fixture API remain warm. No concurrent task-owned application builds, test suites, browser audits or compression runs during sampling. These are laboratory observations, not field INP, population percentiles or deployed-backend performance. + +For Runs, readiness requires the four known row links and successful experiment metadata. Details requires its known visible task node and successful run/task APIs. Compare requires its two selected run links, successful requests and the honest empty-parameters state. Content readiness ends two animation frames after that shared predicate; fonts-ready adds `document.fonts.ready` and two frames. The same observer runs in both builds. This comparison fixture has empty parameter/scalar panels; the populated comparison has separate timings below. + +Filtering measures the final input event through the single matching row and two frames, including the existing 300 ms debounce. Run navigation starts with the same four unfiltered rows in both builds and ends at the known graph node. Task navigation ends at the inspector Task Details controls, not loaded logs. DevTools trace summaries and browser entries are retained; raw traces were not exported. Reported lab INP is the maximum insight value per recorded interaction, not a field metric. + +Median (minimum–maximum), milliseconds except CLS; three samples per build/case. Lower is better. All individual values, browser entries and API readiness checks are in [the retained observations](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/a058b10903912eb8bd29fc15a24b63b8e2722987/frontend/docs/ui-modernization/qualification/performance-evidence.json); [the protocol](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/qualification/protocol.json) retains the instrumentation. + +| Route | Baseline content ready | Candidate content ready | Median change | +| ------------- | ---------------------: | ----------------------: | ------------: | +| Runs | 2,387 (2,381–2,408) | 1,756 (1,748–1,759) | -26.4% | +| Run Details | 2,538 (2,490–2,544) | 2,155 (2,138–2,170) | -15.1% | +| Empty Compare | 2,320 (2,315–2,329) | 1,899 (1,894–1,931) | -18.2% | + +| Route | Baseline LCP | Candidate LCP | Baseline observed CLS | Candidate observed CLS | +| ------------- | ------------------: | ------------------: | ---------------------: | ---------------------: | +| Runs | 2,009 (2,005–2,009) | 1,758 (1,750–1,761) | 0.0085 (0.0085–0.0085) | 0.0239 (0.0239–0.0239) | +| Run Details | 1,940 (1,912–1,970) | 1,956 (1,954–1,974) | 0.0014 (0.0014–0.0014) | 0.0042 (0.0042–0.0042) | +| Empty Compare | 2,276 (2,267–2,279) | 1,402 (1,397–1,414) | 0.0443 (0.0443–0.0443) | 0.0613 (0.0613–0.0613) | + +LCP uses DevTools summary values. The table’s CLS uses retained `LayoutShift` observations through the recorded observer snapshot after fonts readiness, excluding recent input and taking the largest session window (under 1 second between shifts and under 5 seconds overall). Separately rounded DevTools CLS values remain in the JSON; no raw-trace reconciliation is claimed. FCP, fonts-ready and transfer sizes are retained alongside these endpoints. + +| Interaction endpoint | Baseline | Candidate | Median change | +| --------------------------------- | ------------: | ------------: | ------------: | +| Filter result, including debounce | 738 (737–748) | 545 (544–566) | -26.1% | +| Run click → known task node | 575 (575–577) | 544 (542–557) | -5.4% | +| Task click → inspector controls | 117 (111–121) | 75 (72–78) | -35.6% | + +| Lab interaction-to-next-paint | Baseline | Candidate | +| -------------------------------------- | ------------: | ---------: | +| Filtering | 49 (42–64) | 25 (23–26) | +| Run/task navigation, maximum per trace | 127 (123–132) | 89 (84–90) | + +Filtering reported CLS is 0.00 (0.00–0.00) on the baseline and 0.03 (0.03–0.03) on the candidate. Input paint, result readiness and layout stability remain separate acceptance criteria. + +The matched content-readiness medians improve on all three routes, as do filtering and run/task opening. Details LCP remains slightly higher, with overlapping sample ranges; LCP targets differ between layouts. Observed CLS remains higher on all three routes, and filtering CLS remains higher. These are separate criteria rather than an overall pass/fail score. DevTools reports empty-Compare LCP a few milliseconds below browser FCP; the independent reporting paths are not reconciled and do not establish paint ordering. + +## Representative larger workloads + +These separate candidate-only runs use Playwright Chromium, a fresh context per trial, a warm browser process and local route fixtures, with **no CPU or network throttling**. Host-side timings include automation overhead. They are not directly comparable with the native-fixture table above or a baseline speedup. Each retained record identifies source, harness, fixture, index and loaded asset hashes. + +| Workload endpoint | Median (minimum–maximum), ms | +| ------------------------------------------- | ---------------------------: | +| 200-task graph ready | 624 (595–630) | +| Task keyboard activation → inspector ready | 62 (62–78) | +| Fit View → settled changed viewport | 114 (113–114) | +| Populated comparison tables ready | 209 (194–333) | +| Classification tab → three rendered curves | 133 (133–148) | +| Off-page selection → four curves/provenance | 70 (55–87) | +| Expand → larger rendered chart | 94 (93–95) | + +The graph-only timing harness follow-up `e54a5a4f4` changes the setup to Zoom In because the initial large graph is already at minimum zoom. The retained three samples follow that correction and use the unchanged `b358c3b4` application assets; the graph record retains the exact harness hash. The failed setup attempt produced no retained graph timing result. The populated-comparison harness is unchanged from `b358c3b4`. + +The [graph fixture](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/qualification/graph-chromium.json) contains 200 executable tasks plus a structural root, 201 rendered nodes including an artifact, 363 edges and two task-list pages. Readiness requires complete identities, stable world geometry and fonts; selection uses the real task button; Fit View must change and settle the viewport. The [comparison fixture](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/qualification/comparison-chromium.json) contains two runs, typed zero/false/empty parameters, scalar zero and 111 classification artifacts. It proves 100/11 option pagination, retained off-page selection/provenance, actual three→four curve rendering and chart expansion. This is representative client coverage, not a measured scale ceiling or backend load test. + +## Bundle and acceptance + +Entry JS/CSS only; offline per-file gzip level 9 and Node Brotli defaults, not HTTP transfer sizes. Fonts, deferred editor/workers and other assets remain itemized in [the complete bundle inventory](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/qualification/bundle-sizes.json). + +| Build | Raw bytes | gzip bytes | Brotli bytes | +| --------------------- | --------: | ---------: | -----------: | +| Original baseline | 2,832,056 | 785,657 | 635,011 | +| Retirement checkpoint | 2,727,236 | 754,081 | 615,338 | +| Qualified candidate | 2,141,724 | 596,193 | 488,701 | + +Initial JS/CSS gzip changes by **-24.1% against the original baseline** and **-20.9% against retirement**. No native load sample requested the lazy Editor chunk. This result combines presentation retirement, deferred editor code and other implementation changes; it does not isolate an intrinsic framework effect. + +These observations qualify the recorded fixtures and build. They do not establish a maximum supported graph size, rich-comparison capacity, a performance SLA, or an across-the-board framework speedup. Maintainers still need agreed budgets and explicit disposition of remaining shifts/regressions. Actual minimum-browser/device, assistive-technology, deployment authorization and same-backend rollback gates remain in #14572. diff --git a/frontend/docs/ui-modernization/release-notes.md b/frontend/docs/ui-modernization/release-notes.md new file mode 100644 index 00000000000..cab03723988 --- /dev/null +++ b/frontend/docs/ui-modernization/release-notes.md @@ -0,0 +1,125 @@ +# UI modernization: release and migration notes + +These notes prepare [PR #14584](https://github.com/kubeflow/pipelines/pull/14584) +for review. They do not announce a published release. The +[tracking issue](https://github.com/kubeflow/pipelines/issues/14572) owns the design, +compatibility requirements and current release checklist; no KEP is required. +Source-specific results are retained in the [rebased qualification report and dated matrix](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/release-qualification-2026-10-05-rebased/README.md). +Subsequent harness changes and eventual release-image checks must be assessed separately. + +## What changes for users + +The UI adopts a consistent light/dark theme, responsive navigation, keyboard +command search, resource tables, run/task inspection and themed pipeline graphs. +Pipelines, experiments, recurring runs, creation forms, artifacts, comparison and +rich viewers use the same shared controls. Existing resource URLs, namespace +selection, mutations, paging, sorting and persisted table preferences remain part +of the compatibility contract. The theme adds the `kfp.theme` preference; existing +preferences are not destructively migrated. + +The YAML/JSON editor loads on demand. The production server now negotiates gzip +for precompressed JavaScript, CSS and editor workers, with original-file fallback. +This reduces transfer cost without loading the editor on the main pages. In the +matched October 5 hosted experiment, median first-editor display fell from +3,376 to 1,399 ms and worker readiness from 3,725 to 1,493 ms. Entry JS/CSS gzip +remained 596,806 bytes, with identical original asset bytes. These are controlled +lab results; deployments that already compress assets at ingress can see smaller +gains. See the qualification report for network/CPU conditions and all budgets. + +The existing name and archive filters remain available. The proposed new Runs +status selector is deferred until backend filtering matches the effective status +shown for historical runs, including paused and canceled states. Global status +counts and the proposed 48-hour failure summary are omitted: a fetched page or +bounded recent-run sample is not a global total. These are explicit product-scope +follow-ups, not features advertised by this release. See +[workflow compatibility](workflows.md#runs-status-filter-compatibility-dependency). + +## Browser requirements + +Use the [approved browser policy](../browser-support.md): the latest two stable +major releases of desktop Chrome, Edge and Firefox; current Chrome/Edge Extended +Stable; current Firefox ESR and the outgoing ESR during its supported overlap; +and current/previous annual Safari on supported macOS and iOS/iPadOS, at latest +available patches. Older browsers are no longer qualification targets, although +there is no user-agent block. Conservative compiler targets are not a promise to +support every older browser that can parse the bundle. + +Publish a dated exact browser/OS matrix for the release. Engine tests do not +substitute for branded Safari, enterprise channels or devices. The +[hosted coverage report](hosted-browser-qualification.md) and machine-readable +[browser catalog maintained in qualification PR #14757](https://github.com/kubeflow/pipelines/pull/14757) identify tested scope and missing +slots. A successful test of one patch does not qualify a later vendor patch. + +## Operator migration and rollback + +This is a coordinated replacement of the frontend application and its Express +server. There is no backend API, database, pipeline-format or artifact migration. +No alternate legacy-renderer switch remains. Upgrade the UI image against the +same compatible backend using the deployment's existing configuration. + +Before rollout, retain immutable previous/candidate UI image identities and the +existing deployment configuration. Preserve root and `/pipeline/` entry points, +hash routes, namespace context, runtime flags, service ports, probes, security +context and the TensorBoard signing key. Preserve authentication and dashboard +integration for multi-user installations. The backend must remain unchanged for +a UI-only rollback. + +Ship the generated `.gz` sidecars with their original assets. The server selects +the representation through `Accept-Encoding` and sets `Vary: Accept-Encoding`; +intermediary caches must preserve that distinction. Do not rewrite compressed +bytes or strip their encoding header. Identity fallback remains available. +HTML, API responses and artifact/proxy responses are outside this static-asset +compression change. + +The existing `Recreate` rollout strategy interrupts UI availability during both +upgrade and rollback. Hosted rehearsals observed roughly 38–43 seconds; this is +not a maximum downtime guarantee. Plan a maintenance window rather than assuming +zero downtime. To roll back, restore the recorded compatible previous UI image +and configuration, preserving backend data, browser preferences, authentication +and signing state. Recheck list/create, task/log/artifact access and viewer links +through the actual entry path. Restore the UI only; no data repair or preference +reset should be necessary. The retained standalone and authenticated multi-user +rehearsals document those invariants and their tested image identities. + +## KFP Local Considerations + +Python `kfp.local` execution, local output artifacts, compilation and SDK behavior +are unchanged. No local SDK upgrade, recompilation, artifact conversion or local +state migration is required by this UI change. A local cluster serving the KFP UI +uses the same browser and frontend-image requirements as any other installation; +that deployment is distinct from Python local execution. + +## Release-readiness disposition + +| Item | Disposition and reason | +| --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Current-head frontend, repository formatting, performance and deployment/rollback checks | Required before calling the implementation qualified. Historical passing results retain their own source identities and do not turn an unfinished or failed current check green. | +| Supported-browser failures affecting load, core workflows, data integrity, keyboard access or reachable content | Release blockers under the approved policy. Fix and rerun the affected lane; do not mask failures or substitute another engine. | +| Missing exact versions, enterprise distributions and unavailable device/OS environments | Explicit coverage follow-ups under the approved CI-only plan; they remain unqualified. They do not add a release blocker under that disposition, but prevent claiming the complete browser policy is verified. This does not excuse a failure in an available configured lane or narrow the support policy. | +| Keyboard/focus, contrast, reduced motion, forced colors, dialogs and responsive/zoom behavior exercised by automation | Preserve passing assertions and source-specific evidence; failures affecting access to core content block release. Simulated conditions must be labeled. | +| Untested screen-reader, speech-control, physical-device, IME and predictive-input behavior | Disclosed limitations and automated-coverage follow-ups. The agreed plan does not require manual sessions and makes no blanket WCAG or assistive-technology conformance claim. Known core accessibility defects still block release. | +| BrowserStack access | Investigate exact inventory, CI integration, eligibility and retention. This is a route to close coverage gaps, not a prerequisite product dependency or authorization to purchase/apply for access. | +| Status selector and unavailable aggregate statistics | Explicit scope deferrals with the compatibility/data rationale above. Do not show guessed counts or change historical run-state semantics to close a design checkbox. | +| Downstream deployment variants and final published images | The retained standalone and authenticated dashboard rehearsals establish their recorded scope only. Release owners must validate the actual published image identities and supported deployment/architecture contract through the ordinary release process; untested downstream customization is not silently qualified. | + +### BrowserStack investigation + +The [open-source program](https://www.browserstack.com/open-source) advertises +Automate and Percy, five users, five parallel sessions and project-lifetime +membership. Official documentation describes +[GitHub Actions integration](https://www.browserstack.com/docs/automate/playwright/github-actions) +and [real iOS Safari automation](https://www.browserstack.com/docs/automate/playwright/playwright-ios/nodejs). +These establish a feasible CI path, not confirmed KFP eligibility or exact device +and browser inventory. Follow up on eligibility, required enterprise/Safari +versions, physical device availability, fixture tunneling and artifact retention +before proposing integration. No account application, activation or purchase has +been made; pending access is not qualified coverage. + +After review and all required gates, the release owner must link these notes and +the dated tested matrix from the eventual release notes, verify the published +images using the [release process](../../../release/README.md), and retain results. +Preparing these notes does not merge a PR, publish images or deploy a release. + +## Evidence retention + +Large generated qualification reports are preserved in the [evidence archive](evidence-archive.md), with per-file hashes and pinned source links. diff --git a/frontend/docs/ui-modernization/release-qualification-plan.md b/frontend/docs/ui-modernization/release-qualification-plan.md new file mode 100644 index 00000000000..2d4279a9673 --- /dev/null +++ b/frontend/docs/ui-modernization/release-qualification-plan.md @@ -0,0 +1,198 @@ +# Remaining release qualification + +Broad qualification tooling is maintained in [qualification PR #14757](https://github.com/kubeflow/pipelines/pull/14757). Commands and results in this document refer to that tooling or the explicitly recorded historical source; they are not additional presubmit gates in the application PR. See [the evidence index](evidence-archive.md) for retained records. + +Status: hosted deployment/rollback and editor qualification passed October 2, 2026. +See [retained results](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/release-qualification-2026-10-02/README.md) for exact sources, +measurements, image/state proofs and remaining coverage limits. +The maintainer accepted timing, payload and layout budgets on September 30. +The separate first-editor limits below were adopted on October 2 to complete +editor qualification; the slower cold-open tradeoff remains explicit. +They are unchanged acceptance criteria, not field-percentile claims. Unavailable provider/device rows remain explicit +follow-ups under the existing CI-only delivery plan; they do not count as qualified. + +## Qualification cadence and promotion + +Required frontend presubmits retain unit/server tests, lint, type checks, the +production build and Linux Chromium. Broader browser coverage, performance and +deployment/rollback are provided by [qualification PR #14757](https://github.com/kubeflow/pipelines/pull/14757). After that PR lands, they run weekly and by manual dispatch. The application PR alone does not install these workflows. + +These three qualification workflows are observational and nonblocking for PRs +and releases. Run them against the intended release candidate for advisory +evidence; do not treat historical success as current-candidate qualification. +[Issue #14754](https://github.com/kubeflow/pipelines/issues/14754) tracks flake +investigation, repeatability, first-attempt failure reporting, deduplicated +alerts, overdue-run detection and triage ownership. After #14757 lands, the daily CI health report +will include these workflows; the remaining operational mechanisms are follow-up work. Promotion to +required gates needs demonstrated reliability and a separate maintainer decision. +Keep failed evidence visible and triage confirmed product regressions. Browser +support, the budgets below and deployment/rollback assertions are unchanged; +nonblocking workflows still report failed checks as failures. + +## Available hosted browser coverage + +- Instrument the initial production-bundle smoke test with bounded stage evidence. + Preserve the 30-second deadline and capture startup stage evidence. The + September 30 engine runs pass this check on all nine OS/engine lanes, including + Windows Firefox; vendor and mobile qualification remain separate. +- Add exact Firefox stable and ESR Linux lanes using official checksums and the + existing native suite. Keep workstation guards and exact identity checks. +- Extend native Firefox/Apple fixtures with experiment creation failure/retry, + typed run creation, and recurring-run creation/toggle recovery. Allow only + explicit in-memory fixture mutations, record requests, and reject others. +- Add Safari 27 and iPhone/iPad iOS 27.0 lanes using the hosted `xcode-27` image, + alongside the existing 26.x lanes. The September 21 + [runner inventory](https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md) + lists Xcode 27.0, Safari 27.0 and those simulator runtimes. Require exact observed + identities; the inventory alone is not a passing qualification result. + Neither available annual family necessarily supplies its latest supported patch. +- Keep unavailable vendor versions and physical devices explicit. Proposed + disposition: complete available CI coverage and retain provider-dependent work + for the previously requested BrowserStack investigation. No account or purchase is assumed. + +## Accepted performance budgets + +Measure current and pinned original legacy source +`02cbc725ac9ddcd950f4400d8355dd78bfcd6c57` on the same disposable hosted runner. +Also rebuild the pre-compression modern control `dc1b3cfbaba17444408c9a59eb8ab9354b2dfd79`. +This is the rebased equivalent of the previously qualified `439304aa4ed3a20ae5a7c004b96936cb65c3aff0`, +with the same application code and candidate dependency lockfile. Master’s React Query +5.104.0 and XYFlow 12.12.0 updates increased candidate entry gzip by 62 bytes +(596,806 to 596,868), so the old dependency set is no longer a matched control for +the additional zero-growth compression check. Both original legacy and graph checkpoint +references, all accepted budgets, and the zero-growth assertion remain unchanged. +Retain historical results under their original source identities; this control update +requires a fresh hosted comparison and does not retroactively qualify the new dependencies. +Use seven interleaved trials per build, rotating all six build orders, fresh contexts, identical fixtures, +4x CPU slowdown, explicit numeric network conditions and retained raw traces. +Finish builds before sampling. Preserve all samples and failed attempts. +The earlier local protocol did not retain numeric Fast 4G settings, so this is a +new hosted protocol rather than an exact reproduction of those measurements. + +| Criterion | Accepted budget | +| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| Runs, Details, Compare readiness; filter/run/task interaction | Candidate median <= baseline median + max(10% of baseline median, 50 ms) | +| Entry JS/CSS gzip | <=80% of freshly built original legacy | +| Initial route loading CLS | Every sample <=0.005 | +| Filter CLS | Every sample <=0.02, only with the conditions below | +| Larger graph/comparison workloads | Same relative timing budget against a fresh build of measured checkpoint `e79f8d423e6b118e5df94815ee2f36f68570a9a9` | +| First editor open | Candidate median <=4,000 ms for complete editor display; <=5,000 ms for YAML worker readiness | + +The filter allowance accepts only expected result-row compaction: the same keyed +matching row moves upward when preceding results disappear. Headers, filter, +pagination and sidebar controls must remain within one CSS pixel, and retained +layout-shift sources must identify row compaction alone. Historical candidate CLS +was 0.013583 versus legacy 0.003459; this difference remains visible in the report. +Do not remount rows or delay results to manipulate the metric. + +Retain browser/OS/source identity, fixture/harness/build hashes, all samples, +API/readiness evidence, full-precision layout-shift entries and raw traces. +Use the existing large-graph and populated-comparison samplers with seven samples. +Validate aggregation, threshold boundaries, missing data and evidence integrity +with browser-free tests; actual results must come from hosted execution. + +The performance workflow and runner live in [qualification PR #14757](https://github.com/kubeflow/pipelines/pull/14757), not in the application checkout. Run them only from a checkout combining that tooling with its #14584 application dependency. Four immutable build +artifacts carry source and per-file SHA256 manifests. The matched runner uses a +new explicit profile: 150 ms latency, 1.6 Mbit/s download, 0.75 Mbit/s upload, +1280×720/DPR1 and 4x CPU slowdown. Six cases per build include first editor open, +for 126 retained matched trial records across legacy, previous modern and candidate. +The previous modern build also gates main-page and interaction timings, editor display +and worker readiness under the same relative timing allowance; candidate entry gzip +bytes must not exceed previous modern bytes. The fourth build is the scaling checkpoint. +Public static JS/CSS are served through the production gzip-sidecar handler for all +builds. Immutable older builds have no sidecars and retain identity transfer. Every +trial checks actual response encoding, length, Vary and no-store headers against +the build inventory. Compression stays build-time and does not preload the editor. Each matched trial exports its raw Chromium +trace; layout observations include a declared 500 ms settling interval. Hosted +content readiness requires the route/API predicate after fonts and two confirming +frames. Earlier transient readiness is retained separately; loss after confirmation +fails the trial even if it recovers before capture. The effective hosted protocol +and its hash are retained separately from the unchanged historical protocol. Editor +model readiness requires the complete fixture YAML in read-only Ace, fonts and +two animation frames on both builds. The candidate must additionally load its +YAML worker successfully and return the same complete model through a worker +round trip; that worker-ready duration is reported separately. The immutable +legacy build omits `worker-yaml.js`; each baseline editor trial must retain its +actual 404 and complete model evidence. This known baseline failure does not +waive candidate worker correctness or permit other missing assets/errors, and +there is no equivalent fully worker-ready legacy timing. + +The larger-workload samplers run seven samples per build without throttling, +sequentially after matched sampling. Graph uses checkpoint then candidate; +comparison reverses that order. Their existing raw readiness records retain +fixture, harness, browser, index and loaded-asset identities. Their automation +measurements remain separate from the throttled in-page timings. + +The machine-readable `performance-budgets.json` in [qualification PR #14757](https://github.com/kubeflow/pipelines/pull/14757) records status `accepted`; the budgets described here remain the policy reference when only the application PR is checked out. Measurement, +fixture and evidence-integrity errors fail CI immediately, as do exceeded accepted +budgets, including the separate absolute first-editor limits. The editor limits +apply to the same cold-context profile and do not assert legacy timing parity. +Retain the original legacy display timing and candidate worker timing separately. +The artifact +`frontend-performance-evidence/performance.json` records the run, attempt, tested +merge/source, PR head, every sample, comparisons and limitations. + +## Real deployment and UI-only rollback + +Use disposable hosted Kind clusters for standalone and authenticated embedded +multi-user configurations. The latter needs real Dex/OAuth2 Proxy/Dashboard login; +existing tests that inject identity headers do not establish browser authentication. +Install the pinned upstream aggregate roles used by profile owners, verify their +canonical namespace bindings, and require owner access plus cross-owner denial +before the browser rehearsal. Preserve this authorization configuration through +both UI transitions. +Use the repository's pinned Kubeflow manifests and disposable test users. + +Build complete legacy and candidate frontend containers from their own sources +and lockfiles. Their Express server and Dockerfile are currently identical; still +prove compatibility against the same candidate backend through the rehearsal. +Register both images in a disposable CI registry and record actual OCI manifest +digests, archive hashes, config IDs and running pod image IDs. If the runtime +reports a preloaded manifest alias, read the exact reported SHA256 manifest from +the node's containerd content store. Verify the raw manifest hash, image-manifest +schema and qualified archive config digest; a mutable alias is insufficient. An +index must identify exactly one distinct image child; verify both content hashes +and the child descriptor size and media type, and retain the exact bytes. + +Before the multi-user baseline, enable and verify native Istio sidecars on the +UI, API, required backend callers, MySQL and SeaweedFS. Their existing +DestinationRules require mutual TLS; preserve their authentication and authorization policies. Require +the proxy to start before network-wait initializers, retain its observed image +identity, and check its readiness across UI replacement and restoration. All +setup finishes before the invariant snapshot and measured UI-only transitions. +This lane does not establish CNI NetworkPolicy enforcement. + +1. Start the legacy UI; establish sessions/preferences and create a pipeline, + experiment, successful run, future/disabled schedule and inspectable artifacts. +2. Replace only the UI image. Exercise baseline resources, new run/schedule + creation, logs/artifacts/TensorBoard, preferences and embedded namespace changes. +3. Restore the captured legacy UI pod template and immutable image. Preserve the + same browser context and backend. Inspect candidate-created resources and + create another working run through the restored UI. + +Require unchanged backend pod templates/UIDs, authorization configuration, +service accounts, ConfigMaps, and TensorBoard signing state. Verify data and +preferences survive, old signed access still works, and normal authenticated +sessions have the expected namespace access. Check deep-link refresh, prefixed +assets and absence of mixed asset generations. Measure interruption during both +Recreate transitions; no zero-downtime claim is assumed. + +Upload sanitized image/deployment/resource evidence, JUnit, browser errors and +cluster diagnostics. Exclude credentials, cookies, tokens and Secret +contents; record signing-state equality without publishing its value. + +## Evidence retention + +The repository caps Actions artifacts at seven days. Preserve sanitized raw +measurement records, effective protocol, source/build/image identities and +independent audit results in this repository before expiry. Large traces, +screenshots and complete logs remain downloadable for the configured seven-day +window; published reports must state that limit and retain their verified hashes. + +## Completion + +Publish measured results and qualification budgets to issue #14572 and +implementation PR #14584. Maintainers confirmed no KEP is required; the issue +holds the complete design, compatibility and release requirements. Keep +unavailable coverage and the measured first-editor tradeoff explicit. +Real deployment and rollback must pass without backend changes or data repair. diff --git a/frontend/docs/ui-modernization/runs.md b/frontend/docs/ui-modernization/runs.md new file mode 100644 index 00000000000..841db077a86 --- /dev/null +++ b/frontend/docs/ui-modernization/runs.md @@ -0,0 +1,51 @@ +# Shell integration and Runs + +This historical checkpoint records the first shell and Active/Archived Runs integration after the [foundation](foundation.md), in [PR #14584](https://github.com/kubeflow/pipelines/pull/14584) for [issue #14572](https://github.com/kubeflow/pipelines/issues/14572). Other routes still used temporary light presentation at this point. See [Workflows and qualification](workflows.md) for their completed presentation migration and current release gates. The screenshots and totals below belong to this earlier slice. + +## Behavior and design at this checkpoint + +- Retain hash routes, encoded IDs, legacy execution redirects, browser history and page-owned state. Run Details keeps its query-driven task selection; namespace changes retain the existing list reset behavior without resetting the shell. +- Adapt existing build/GKE/namespace contexts and deployment flags to the shell. Marketplace keeps Getting Started, shared pipelines remain deployment-dependent, and embedded Runs exposes theme selection when `HIDE_SIDENAV` hides the rail. Preserve existing navigation preference storage and hosted browser-test selectors. +- Present Active and Archived views at their existing URLs. A row opens its run; its checkbox selects it. Keyboard users can operate the checkbox or run link independently. Related pipeline, experiment and recurring-run links retain their destinations; deleted versions remain distinguishable from request failures. +- Keep the existing `RunList` API/enrichment path and `CustomTable` paging/filter/sort controller. An optional render adapter supplies the semantic table only to the migrated views; experiment/comparison consumers retain their current presentation. +- Preserve Compare (2–10 runs), Clone (one run), Archive, Restore, Delete and Refresh through the existing action callbacks. Confirmations retain cancellation, error details and partial-success behavior. Notifications pause for focus, hover and inactive windows. Modal portals use the resolved theme and return keyboard focus to their trigger. +- Keep name-only server filtering, sorting, token pagination, page-size preferences and selected IDs. Filtering starts at the first-page cursor; superseded requests cannot replace newer rows, errors or paging state. Failed reads retain available rows and show explicit failure copy; successful recovery clears the page banner. +- Render every current runtime state with text and color, including Canceling, Paused, Skipped and Unknown. Contrast checks cover normal, hovered and selected row surfaces in both themes. + +The handoff's aggregate/status counts, recent-failure summary and history bars are omitted because this request path deliberately uses `skip_count=true`. The filter says **Filter runs by name**; it does not claim pipeline/experiment search. At this checkpoint, status chips, command search, notification history and additional workflows were follow-up items. Subsequent delivered scope and deliberate omissions are tracked in the [current report](workflows.md). No new data aggregation or per-row request path is introduced; existing version enrichment stays deduplicated by pipeline/version pair. + +## Browser evidence + +The deterministic [production browser harness](../../scripts/ui-modernization-runs.smoke.mjs) serves the emitted bundle through Playwright request interception. Each scenario gets fresh fixture state: 18 runs across available/archived storage states, mixed runtime states, token pagination, related resources and simulated failures. Requests stay on the fixture origin; unexpected endpoints fail the test. + +Its five scenarios exercise: + +1. Paging, filtering from page two, sorting, page-size changes, visible selection and one lookup per distinct referenced version. +2. Keyboard selection, independent row/resource-link navigation, compare/clone destinations and selection preservation on refresh. +3. Archive cancellation/confirmation, exactly-once mutations, partial forbidden writes with failed selection retained, restore and delete. +4. Failed reads and recovery, light/dark presentation, initial/trapped/restored modal focus, and narrow layout overflow. +5. An iframe under `/pipelines/`, namespace propagation/switching, selection reset and theme selection with hidden navigation. + +These Chromium screenshots use synthetic fixtures at 1440×900. Finite transitions are completed during capture. + +![Active Runs in light theme](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/runs/runs-light.png) +![Active Runs in dark theme](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/runs/runs-dark.png) +![Archive confirmation in dark theme](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/runs/runs-dark-archive-dialog.png) + +## Historical verification + +On Node 24.14.0 and npm 11.17.0, `test:ci` passed: 137 UI files / 1,770 tests and 29 server files / 1,052 tests with coverage, formatting, UI/server lint, TypeScript and React peer checks. Production and Storybook builds, the production startup smoke and all five Runs browser scenarios also passed. The token suite at this checkpoint verified 54 contrast pairs. + +Use the pinned Node/npm versions and run from `frontend`: + +```sh +npm run test:ci +npm run test:bundle +npm run build:storybook +``` + +At this checkpoint `test:bundle` built the application and ran the startup and Runs browser tests serially; the existing frontend CI job installs Chromium and executes this command. Set `KFP_RUNS_SCREENSHOT_DIR` when running the Runs harness to retain its three screenshots. Storybook includes **Modernization → Runs table** light/dark, loading and empty examples. + +The full application browser harness verifies UI/API contracts against controlled responses. It does not establish real cluster authentication, authorization, ingress behavior or a supported minimum browser version. The hosted frontend integration specs retain their real cluster assertions with updated semantic selectors. + +The [current report](workflows.md#final-qualification-checklist) supersedes this checkpoint’s remaining-work list. Before cutover, qualify deployment parity and the declared browser floor, compare representative performance against [baseline PR #14576](https://github.com/kubeflow/pipelines/pull/14576), and rehearse upgrade/use/rollback with identified compatible UI/backend images. This slice changes no backend API or schema and no Python `kfp.local` behavior or artifacts. The temporary mixed presentation is not a second released theme or a rollback mechanism. diff --git a/frontend/docs/ui-modernization/supported-browser-qualification.md b/frontend/docs/ui-modernization/supported-browser-qualification.md new file mode 100644 index 00000000000..2d98389bb1d --- /dev/null +++ b/frontend/docs/ui-modernization/supported-browser-qualification.md @@ -0,0 +1,117 @@ +# Supported-browser qualification checkpoint + +Broad qualification tooling is maintained in [qualification PR #14757](https://github.com/kubeflow/pipelines/pull/14757). Commands and results in this document refer to that tooling or the explicitly recorded historical source; they are not additional presubmit gates in the application PR. See [the evidence index](evidence-archive.md) for retained records. + +Follow-up: the [automated qualification plan](../browser-support.md#automated-qualification) expands CI across engines and desktop operating systems, uses native automation for Apple coverage, and tracks BrowserStack open-source access for future gaps. Manual test sessions are not part of the current delivery plan; the results below retain their original scope. + +This checkpoint applies the [approved browser policy](../browser-support.md) to +actual vendor browser builds. Channel selection was resolved on September 28, +2026 (America/New_York); native records retain their September 29 UTC timestamps. +Re-resolve the rolling channels before release. This is partial qualification, +not completion of the browser/device release gate. + +## Results and provenance + +The full production fixture suite passed **47/47 cases each** on Chrome +154.0.8037.58, Edge 154.0.4258.37 and Edge 153.0.4234.48: **141 branded-browser +cases**. The [matrix](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/supported-browsers/branded-browser-matrix.json) retains exact +browser identities, all case names, eight harness hashes and 20 emitted asset +hashes. These are new runs, separate from the earlier 141-case Playwright-engine +matrix. Tests cover production loading, Runs, Run Details, Pipelines, other +workflows, artifacts/viewers, populated comparisons and graph behavior. + +Four actual Firefox builds passed the native smoke: **7/7 checks each**, or 28 +checks. These cover required platform features, Runs filtering, graph/task +inspection, pipeline selection, pointer/Space switch activation, two-run empty +comparison and a narrow dark command dialog with keyboard containment. All four +retained results have no captured post-readiness page errors. This is scoped +native smoke, not the full 47-case workflow suite or rich-comparison coverage. + +| Policy slot | Exact tested browser | Evidence and result | +| --------------------------------------- | -------------------- | ----------------------------------------------------------------- | +| Chrome current stable | 154.0.8037.58 | 47/47 production cases | +| Edge current stable | 154.0.4258.37 | 47/47 production cases | +| Edge previous stable | 153.0.4234.48 | 47/47 production cases | +| Firefox current stable | 156.0.1 | [7/7 native checks](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/supported-browsers/firefox156/result.json) | +| Firefox previous stable | 155.0.1 | [7/7 native checks](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/supported-browsers/firefox155/result.json) | +| Firefox new ESR | 153.3.0 | [7/7 native checks](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/supported-browsers/firefox153esr/result.json) | +| Firefox outgoing ESR, supported overlap | 140.16.0 | [7/7 native checks](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/supported-browsers/firefox140esr/result.json) | + +All executed checks above used macOS 26.6.2 (25G83), arm64, with fresh temporary +browser profiles or contexts. This does not qualify Windows or Linux. Firefox's +privacy-reduced user-agent OS/architecture string is not the host identity. + +Application source is `8fe4fb4ec548e18268a5ea4c1a059992b24ee976`; all 20 emitted assets +match the [measured e79f8d42 build](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/layout-stability/bundle-inventory.json) byte for +byte. The full-suite harness source is `bbfe20296b9053e5dba9d3532dfb1f7517adda5b`; +the native smoke source is `8fe4fb4e` (the same blob at `bbfe2029`). No application source, dependency or compiler +target changed for this checkpoint. Previous unit, build and performance evidence +retains its original source identity; no new performance comparison is claimed. + +Firefox DMGs were checked against Mozilla's versioned SHA256SUMS and their app +signatures verified. Edge packages were checked against vendor enterprise API +SHA-256 values, package signatures and app signatures. The retained distribution +records identify [Edge 154](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/supported-browsers/edge-154.0.4258.37-distribution.json), +[Edge 153](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/supported-browsers/edge-153.0.4234.48-distribution.json), and each Firefox +archive. Chrome used the installed vendor binary, with its actual version asserted +by the harness. Extraction of portable apps did not run installers. + +All 24 Firefox screenshots and hashes are retained alongside their results. +Visual spot checks covered [current Firefox graph inspection](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/supported-browsers/firefox156/run-details-inspector.png), +[current Firefox narrow dark dialog](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/supported-browsers/firefox156/command-dialog-narrow-dark.png), +[outgoing ESR switch state](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/supported-browsers/firefox140esr/feature-switch.png) and +[outgoing ESR narrow dark dialog](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/supported-browsers/firefox140esr/command-dialog-narrow-dark.png). +No visual blocker was found in those four states; this is not an exhaustive visual +or accessibility assessment. + +## Remaining matrix + +| Policy slot | Resolved target or constraint | Qualification still required | +| ---------------------------- | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Chrome previous stable | Major 153; vendor Mac ARM releases include 153.0.8010.53 and staged .54/.55 variants | Choose and record an exact vendor distribution/patch; run the full suite. | +| Chrome Extended Stable | 152.0.7977.140 for Mac/Windows | Acquire the exact enterprise build and run the full suite. | +| Edge Extended Stable | Windows 152.0.4191.100; exact current Mac enterprise patch still unresolved | Resolve platform-specific package identity and run the full suite. | +| Firefox stable and both ESRs | The four exact versions above | Expand native smoke to full workflow/state coverage; do not substitute Playwright's patched Firefox. | +| Safari current annual | Safari 27; select latest patch for the chosen supported macOS | Actual Safari qualification, both themes and responsive/keyboard behavior. | +| Safari previous annual | Installed identity 26.6.2 (21624.5.1.11.3); latest eligible patch still needs confirmation | No completed Safari application run; obtain actual-browser workflow and viewport evidence. | +| iOS/iPadOS current annual | 27.0.1 | Actual device/simulator browser identity, touch/zoom/focus/dialog/inspector tests, and automated physical-device coverage where available; otherwise record the gap. | +| iOS/iPadOS previous annual | 26.7.1 | Same device checks, using a supported OS/device pair. | +| Additional desktop platforms | Vendor-supported Windows and Linux combinations | Record exact OS/browser identities and representative platform coverage. | + +Safari application qualification did not start successfully; no pass is claimed. +The exact Safari and mobile rows require a working browser/device test environment. +A desktop WebKit run does not close these rows. Mobile OS release numbers alone do +not establish the actual Safari/WebKit build; record that from the test device. +Neither a Chrome for Testing build with a different patch number nor an Edge +Windows catalog entry establishes the identity of a requested Mac enterprise build. + +The dated matrix is a checkpoint, not a completed release matrix: unresolved +patch/OS selections and all untested rows remain open. Deployment authorization, +assistive-technology acceptance, measured budgets and same-backend rollback remain +separate gates. + +## Reproduction and harness verification + +Use the repository-pinned Node/npm versions and the already built production +bundle. The eight production smoke files accept `KFP_BROWSER=chromium`, +`KFP_BROWSER_EXECUTABLE_PATH` and `KFP_EXPECTED_BROWSER_VERSION`; an explicit path +wins over `PLAYWRIGHT_CHANNEL`. Run them with `node --test --test-concurrency=1`. +The matrix records the complete command and case list for each browser. A deliberate +wrong-version run verified rejection before page checks, using a conflicting +channel to verify explicit-path precedence. All eight scripts passed syntax, +formatting and repository hooks. + +The following historical reproduction requires the qualification tooling from [#14757](https://github.com/kubeflow/pipelines/pull/14757) combined with application #14584, run from that checkout’s `frontend` directory. The native Firefox smoke uses the existing loopback API fixtures and Vite +production preview. Set `KFP_FIREFOX_BINARY`, `KFP_BROWSER_FLOOR_VERSION` and +`KFP_BROWSER_FLOOR_OUTPUT`, then run +`node scripts/ui-modernization-browser-floor.mjs` with a matching loopback +WebDriver. Each session closes in `finally`. Retained capabilities omit temporary +profile paths, process IDs and transport addresses; browser, driver, platform, +source, asset and screenshot identities remain explicit. + +## Vendor sources + +- [Chrome 154 stable release](https://chromereleases.googleblog.com/2026/09/stable-channel-update-for-desktop_0856730748.html), [Chrome September enterprise announcements](https://chromereleases.googleblog.com/2026/09/), and [Chrome Mac ARM stable version history](https://versionhistory.googleapis.com/v1/chrome/platforms/mac_arm64/channels/stable/versions/all/releases?filter=version%3E%3D153,version%3C154). +- [Microsoft Edge enterprise distribution API](https://edgeupdates.microsoft.com/api/products?view=enterprise), [release schedule](https://learn.microsoft.com/en-us/deployedge/microsoft-edge-release-schedule) and [Extended Stable Windows catalog](https://www.catalog.update.microsoft.com/Search.aspx?q=Microsoft+Edge-Extended+Stable+Channel). +- [Firefox 156.0.1](https://www.firefox.com/en-US/firefox/156.0.1/releasenotes/), [155.0.1](https://www.firefox.com/en-US/firefox/155.0.1/releasenotes/), [153.3.0 ESR](https://www.firefox.com/en-US/firefox/153.3.0/releasenotes/), [140.16.0 ESR](https://www.firefox.com/en-US/firefox/140.16.0/releasenotes/) and [live Mozilla channel metadata](https://product-details.mozilla.org/1.0/firefox_versions.json). +- [Apple current releases](https://support.apple.com/en-us/100100) and [Safari 27 availability](https://support.apple.com/en-us/149039). Apple's security index is not a complete standalone patch catalog for every supported macOS version. diff --git a/frontend/docs/ui-modernization/workflows.md b/frontend/docs/ui-modernization/workflows.md new file mode 100644 index 00000000000..2ceabcce486 --- /dev/null +++ b/frontend/docs/ui-modernization/workflows.md @@ -0,0 +1,178 @@ +# UI workflow contracts + +This reference describes the implemented workflows and intentional interaction choices for [#14572](https://github.com/kubeflow/pipelines/issues/14572). No KEP is required. [Component ownership and styling](architecture.md) defines the implementation boundaries. Dated results below are historical evidence, not current-head qualification; consult the implementation PR checks for current status. + +## Workflow coverage + +| Area | Presentation and preserved behavior | Authoritative checks | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Shell and Runs | System/light/dark theme, responsive persisted navigation, deployment-aware destinations, keyboard command search, active/archive routes, row/checkbox selection with explicit resource links for navigation, compare/clone/archive/restore/delete, confirmation and notification behavior. Existing namespace and table preferences remain intact. | [Runs/search browser scenarios](../../scripts/ui-modernization-runs.smoke.mjs), [ApplicationShell tests](../../src/components/shell/ApplicationShell.test.tsx), [Runs tests](../../src/pages/RunList.modern.test.tsx). | +| Pipelines and versions | Table with latest-version links and detail-page version management, private/shared scoping, filtering/sorting/token paging, encoded resource links, version switching, YAML view, file and URL imports, validation and recovery. | [Pipeline browser scenarios](../../scripts/ui-modernization-pipelines.smoke.mjs), [upload/version tests](../../src/pages/UploadPipelinePage.test.tsx), [version detail tests](../../src/pages/PipelineDetailsView.test.tsx). | +| Experiments and recurring runs | Active/archive lists, expandable run rows, recent-run samples truthfully labeled as bounded samples, experiment creation/detail, recurring-run enable/disable and management through the existing mutation path. Controls prevent duplicate pending writes and retain retry after failure. A successful schedule mutation updates the switch immediately; a subsequent list-refresh failure is reported separately and does not undo the confirmed state. | [Workflow browser scenarios](../../scripts/ui-modernization-workflows.smoke.mjs), [Experiment Details tests](../../src/pages/ExperimentDetails.test.tsx), [recurring switch tests](../../src/components/runs/RecurringRunSwitch.test.tsx). | +| Run creation | One-off and recurring forms, pipeline/version selection, inline experiment creation, latest-version mode, custom root, service account, typed parameters and JSON editor. Periodic/cron schedules preserve date/timezone, catchup and concurrency contracts. Supported `0`, `false` and empty values survive validation and payload construction. | [NewRun tests](../../src/pages/CreateRunForm.test.tsx), [inline creation regression](../../src/pages/CreateRunPage.test.tsx), [Trigger tests](../../src/components/Trigger.test.tsx), [parameter tests](../../src/components/RunParametersForm.test.tsx), [workflow browser scenarios](../../scripts/ui-modernization-workflows.smoke.mjs). | +| Run/task inspection | Real failed-task navigation to Logs, complete task-list summary with structural records excluded, nested task URLs/history, inspector keyboard behavior, all existing parameters/artifacts/pod/volume content and task state history. Logs retain executor/artifact/driver sources, retries and stale-response protection. | [Run Details browser scenarios](../../scripts/ui-modernization-run-details.smoke.mjs), [RuntimeNodeDetails tests](../../src/components/tabs/RuntimeNodeDetails.test.tsx), [Run Details tests](../../src/pages/RunDetailsView.test.tsx). | +| Artifacts and lineage | Combined name/type filtering, paging, encoded task/artifact destinations, bounded lineage neighborhoods, history, preview consent, provider-aware download and recovery. | [Artifact browser scenarios](../../scripts/ui-modernization-artifacts.smoke.mjs), [artifact detail tests](../../src/pages/ArtifactDetails.test.tsx), [lineage tests](../../src/components/NativeArtifactLineage.test.tsx). | +| Comparison and viewers | Selected-run order, missing/empty/zero distinctions, artifact provenance, ROC off-page selection and search, fullscreen focus restoration, paged/sortable tables, Markdown, isolated HTML and TensorBoard lifecycle/proxy readiness. | [Comparison browser scenarios](../../scripts/ui-modernization-comparison.smoke.mjs), [rich-viewer browser scenarios](../../scripts/ui-modernization-artifacts.smoke.mjs), [comparison tests](../../src/components/viewers/RuntimeArtifactComparison.test.tsx), [TensorBoard tests](../../src/components/viewers/Tensorboard.test.tsx). | +| Secondary pages and errors | Tutorial lookup and fallback, correct internal/external link behavior, feature-flag drafts that persist only on Save, Reset/reload semantics, accessible 404 recovery and self-contained native error diagnostics. | [Secondary-route browser scenario](../../scripts/ui-modernization-workflows.smoke.mjs), [feature tests](../../src/pages/FrontendFeatures.test.tsx), [404 tests](../../src/pages/404.test.tsx), [ErrorBoundary tests](../../src/atoms/ErrorBoundary.test.tsx). | + +Shared [inspection tabs](../../src/components/inspection/InspectionTabs.tsx), [fields](../../src/components/inspection/InspectionFields.tsx), [resource tables](../../src/components/tables/ResourceTable.tsx), [text fields](../../src/components/ui/text-field.tsx), dialogs, buttons, checkboxes and switches provide the presentation contracts. Existing data clients, backend endpoints and page-owned actions remain the source of behavior. The handoff's unavailable aggregate counts and invented operational metadata are not presented as measured data. + +## Functional contracts and intentional UX changes + +Compare observable behavior against [SideNav](https://github.com/kubeflow/pipelines/blob/b018d7edddca42d3082fa52acd044e3175863719/frontend/src/components/SideNav.tsx) and [CustomTable](https://github.com/kubeflow/pipelines/blob/b018d7edddca42d3082fa52acd044e3175863719/frontend/src/components/CustomTable.tsx). Legacy behavior is a compatibility reference, not the design standard for this major release. The checklist covers shell, tables, page chrome, forms, dialogs and keyboard interaction; it does not substitute for the page-specific workflows above. Preserve functional guarantees such as correct resource scope, payloads and recovery. Deliberate interaction improvements are recorded alongside their tests rather than inheriting accidental legacy behavior. + +| Checked | Baseline behavior and modern contract | Regression coverage / intentional difference | +| --- | --- | --- | +| [x] | Pipelines, Experiments, Runs, recurring runs and Artifacts retain destinations and active groups for details, shared, archived and comparison routes. Getting Started remains Marketplace-only, including the default landing route. | [Router tests](../../src/components/Router.test.tsx) cover nested navigation groups, deployment landing and shared-pipeline availability. | +| [x] | Documentation, repository, issue reporting, full commit destination and optional cluster destination remain available; missing metadata does not invent destinations. | [AppShell tests](../../src/components/shell/AppShell.test.tsx) and [ApplicationShell tests](../../src/components/shell/ApplicationShell.test.tsx) cover utilities, build precedence and cluster metadata. Utility placement, labels and icons change intentionally. | +| [x] | `HIDE_SIDENAV` removes standalone navigation without discarding namespace or page state. | [ApplicationShell tests](../../src/components/shell/ApplicationShell.test.tsx). Search remains available in embedded mode as an intentional addition. | +| [x] | JupyterHub remains unavailable in navigation, as intentionally disabled in [#1046](https://github.com/kubeflow/pipelines/pull/1046). The baseline initializes `jupyterHubAvailable` to false and never updates it or calls the availability API; its notebook-link test manually sets otherwise unreachable state. | [ApplicationShell tests](../../src/components/shell/ApplicationShell.test.tsx) assert no notebook link or availability probe in standalone and embedded mode. Retiring that unreachable branch is not removal of an available destination; enabling notebook discovery would be a separate feature. | +| [x] | Manual collapse uses the existing preference key and does not overwrite other preferences. | [AppShell tests](../../src/components/shell/AppShell.test.tsx). Intentional responsive change: below 1024px navigation collapses regardless of the saved wide-screen choice, restoring that choice when widened. Legacy used 800px and let a manual choice override automatic collapse. | +| [x] | Clicking a selectable row toggles selection; resource links navigate. Checkbox/radio controls, selection limits and expand/collapse preserve their separate actions. | [ResourceTable tests](../../src/components/tables/ResourceTable.test.tsx), [RunsTable tests](../../src/components/tables/RunsTable.test.tsx), [CustomTable tests](../../src/components/CustomTable.test.tsx) and [RunList tests](../../src/pages/RunList.modern.test.tsx). A navigation callback must not override selection. | +| [x] | The table controller owns filter, sort, page size and page tokens. Filtering resets the cursor; sortable columns reverse direction; page size persists. | [CustomTable tests](../../src/components/CustomTable.test.tsx). Predicate changes now reload in place while retaining user filter/sort state; stale requests cannot overwrite newer results. No remount or caller prediction of request generation is required. | +| [x] | Presentation additions remain explicit: theme selection, keyboard command search, named keyboard-accessible controls and semantic tables. | [AppShell tests](../../src/components/shell/AppShell.test.tsx), [CommandPalette tests](../../src/components/navigation/CommandPalette.test.tsx) and [RunsTable tests](../../src/components/tables/RunsTable.test.tsx). These additions do not replace existing resource actions. | +| [x] | Toolbar actions specify their icons and button variants; the renderer honors callbacks, disabled and busy state without guessing from action names. Banners consistently align left and honor explicit troubleshooting-link visibility and refresh callbacks. | [Page chrome tests](../../src/components/shell/PageChrome.test.tsx) exercise real action factory definitions through the renderer. Creation actions are filled; secondary actions outlined. Removed unused alignment flags are an intentional presentation simplification. | +| [x] | Single-line fields in New Pipeline Version submit a valid form with Enter; invalid forms make no create request, and multiline fields keep Enter as a newline. | [NewPipelineVersion tests](../../src/pages/UploadPipelinePage.test.tsx). Native form submission is an intentional keyboard improvement over the legacy no-op, not an accidental parity exception. | +| [x] | A pending pipeline creation accepts one submission and disables Create and Cancel until completion. Failure retains the draft and re-enables retry. | [NewPipelineVersion tests](../../src/pages/UploadPipelinePage.test.tsx) cover Enter, repeated submit events, busy state, no premature navigation and retry. Disabling Cancel while the mutation is pending avoids suggesting that navigation cancels an in-flight server operation. | +| [x] | Escape dismisses dismissible dialogs and restores trigger focus; it does not silently discard a full-page creation draft. Full-page Cancel is an explicit navigation action and does not submit. | [Dialog tests](../../src/components/ui/dialog.test.tsx) cover focus trapping, Escape and focus return; [NewPipelineVersion tests](../../src/pages/UploadPipelinePage.test.tsx) cover page Escape/Cancel. Dialog-specific pending/confirmation rules remain in their owning action rather than an implicit global keyboard submit. | +| [x] | Top-level run inspection tabs and task destinations create history entries, so Back/Forward restores the prior inspected view. Stale task-query cleanup replaces the current entry. | [RunDetails tests](../../src/pages/RunDetailsView.test.tsx) exercise the real router. The URL is authoritative; unrelated query parameters, hash and navigation state survive. This deliberately favors retracing inspection steps over legacy history suppression. | +| [x] | Multi-user command search keeps runs and experiments in the selected namespace. Pipeline scope is explicitly selectable between current namespace and shared pipelines; switching scope aborts superseded reads. | [CommandPalette tests](../../src/components/navigation/CommandPalette.test.tsx). Shared pipeline requests omit namespace like [PipelineSelectorDialog](../../src/components/PipelineSelectorDialog.tsx); this does not broaden runs/experiments. Multi-user search waits for namespace selection, including shared scope, so the combined search never issues unscoped run/experiment reads. Standalone search requires no namespace. | + +### Navigation and keyboard policy + +User navigation to a resource or top-level inspection tab pushes a history entry; automatic normalization or removal of an invalid selection replaces it. Pipeline discovery can explicitly span shared scope, while runs and experiments remain namespace scoped; changing a scope must invalidate older responses before presenting results. + +Native form Enter submits only an enabled, valid primary action; multiline Enter inserts a newline. Escape dismisses a dismissible overlay and restores focus but does not discard a page draft, and pending mutations prevent duplicate submission. Confirmation and selector dialogs must specify their own confirmation/dismissal rules and test them through the real action boundary rather than relying on page-level form bubbling. + +## Graph, editor and runtime contracts + +Run inspection uses the URL as the sole top-level tab state: absent/unknown `tab` selects Graph, `timeline` (or the legacy `waterfall` alias) selects Timeline, `details` selects Details, and `spec` selects Pipeline spec. Tab changes, Timeline task navigation and View logs use one navigation helper that retains unrelated query parameters, hash and navigation state. [RunDetails tests](../../src/pages/RunDetailsView.test.tsx) cover deep links and browser history in a real memory router. Task-inspector subtabs retain their separate local state. Inspection tabs use manual activation: arrows/Home/End move focus, and Enter/Space select the focused tab. Pointer activation requests one change even while URL navigation is pending. + +Portal surfaces consume the same theme class from ThemeProvider as the application root; theme preference, system changes and cross-tab storage updates apply to menus, tooltips, dialogs and help popovers. [Theme tests](../../src/components/shell/ThemeProvider.test.tsx) verify an actual portal follows the root. + +Runtime/static graph nodes, controls and canvas now consume light/dark tokens. The white graph in the earlier [inspection screenshot](inspection.md#browser-review) is historical, not the current theme boundary. The React Flow renderer, task-to-node mapping, nested DAG/loop identities and query-driven selection remain in place. + +The deterministic [large-graph harness](../../scripts/ui-modernization-graph.smoke.mjs) has 200 executable tasks, 201 rendered nodes and 363 directed edges. It verifies the exact node and edge identities from all task pages, explicit 200×56 leaf/artifact node bounds, and stable world geometry across selection, zoom, pan, fit, theme changes and reload. The second fixture preserves nested loop iterations and hierarchical deep links. This establishes fixture integrity and interaction stability; it is not a claim that every historical pixel coordinate is unchanged or that the new stack is faster. + +[LogViewer](../../src/components/LogViewer.tsx) retains its fixed 15px virtualized rows, horizontal scrolling, bounded overscan and follow-tail behavior. Runtime CSS preserves the scroll-container contracts. [Editor](../../src/components/Editor.tsx) retains its editor implementation and explicitly emits YAML/JSON worker URLs through Vite, including prefixed deployments. Product behavior and worker delivery are tested separately from cosmetic snapshots. + +The Logs tab separates collapsible Driver logs and Task logs, with copy controls for each loaded source. Task logs default to expanded. Available-log selectors default to the latest source and preserve explicit choices during task refresh. Numbered executor archives use the launcher’s zero-based `executor-logs-N` index, displayed as Attempt N+1; pod entries use names because the API exposes no pod-to-attempt mapping. Driver archives use timestamps or artifact IDs. Selecting an unavailable archive reports that source’s error rather than substituting another attempt. Automatic-retry history is available only when the backend retains its pods or publishes log artifacts. Manual Run Retry clears task artifact links, pods and status metadata, so earlier manual-retry history cannot be recovered through this task API. + +## Regression fixes required by the migration + +- The [table controller](../../src/components/CustomTable.tsx) keeps filter/sort/page requests coherent, resets a changed filter to the first cursor, and prevents superseded reads from replacing newer rows, errors or pagination. Read recovery clears stale error UI while retaining valid data and selection where the existing contract allows it. See [controller tests](../../src/components/CustomTable.test.tsx). +- Creation selectors retain current pipeline/version/experiment scope, reject stale asynchronous results and preserve latest-version mode across inline experiment creation. One-off and recurring payloads keep their distinct version-reference rules; pending actions do not issue duplicate mutations. See [NewRun](../../src/pages/CreateRunForm.test.tsx), [CreateRunPage](../../src/pages/CreateRunPage.test.tsx), and [workflow browser coverage](../../scripts/ui-modernization-workflows.smoke.mjs). +- Shared tabs use manual activation: a pointer gesture selects once; arrows/Home/End move focus and Enter/Space select, preserving route-controlled active/archive/private/shared navigation. See [inspection component tests](../../src/components/inspection/Inspection.test.tsx) and the pipeline/artifact browser scenarios. +- Resource links preserve encoded IDs, copied task query URLs and browser history. URL pipeline imports and version detail reads retain the complete identity through creation and navigation. YAML/JSON workers are bundled as real assets instead of relying on unresolved runtime worker paths. See [pipeline browser coverage](../../scripts/ui-modernization-pipelines.smoke.mjs) and [Editor tests](../../src/components/Editor.test.tsx). +- Controlled React Flow nodes retain actual measured dimensions through selection, inspector closure and runtime-node refreshes. Measurements are scoped by layer and node identity/type; equal measurements do not trigger updates, and selection/deletion/position events retain their existing owners. This fixes an intermittent graph that remained hidden after its inspector closed despite nonzero DOM dimensions. [Canvas tests](../../src/pages/v2/DagCanvas.test.tsx) and strict node/edge browser assertions cover the regression. +- The outer [ErrorBoundary](../../src/atoms/ErrorBoundary.tsx) uses native alert/details/summary diagnostics with fallback colors, so it can render outside a working theme provider. Navigation clears captured errors without remounting healthy child state. Its tests cover disclosure/focus, retained diagnostics and recovery; native Enter/Space activation was also checked in Chromium. + +## Historical checkpoint: Browser evidence + +The following records the source-specific retirement checkpoint. Its counts, pending items and outcomes do not describe the current PR head. See [retained evidence](evidence-archive.md) and the tracking issue for subsequent results. + +The production harnesses load emitted assets with deterministic local responses. They bound requests, fail unexpected endpoints and exercise real UI controls; mutation assertions validate payloads and exactly-once behavior. Fixture namespace/iframe tests verify client contracts, not live authentication or authorization. + +| Harness | Cases per engine | Chromium | Firefox | WebKit | +| ---------------------------------------------------------------------------------------------- | ---------------: | --------- | --------- | --------- | +| [Startup](../../scripts/production-bundle.smoke.mjs) | 1 | Pass | Pass | Pass | +| [Runs and command search](../../scripts/ui-modernization-runs.smoke.mjs) | 6 | Pass | Pass | Pass | +| [Run Details](../../scripts/ui-modernization-run-details.smoke.mjs) | 9 | Pass | Pass | Pass | +| [Pipelines](../../scripts/ui-modernization-pipelines.smoke.mjs) | 8 | Pass | Pass | Pass | +| [Experiments, recurring runs and creation](../../scripts/ui-modernization-workflows.smoke.mjs) | 7 | Pass | Pass | Pass | +| [Artifacts and rich viewers](../../scripts/ui-modernization-artifacts.smoke.mjs) | 8 | Pass | Pass | Pass | +| [Comparison](../../scripts/ui-modernization-comparison.smoke.mjs) | 3 | Pass | Pass | Pass | +| [Graph](../../scripts/ui-modernization-graph.smoke.mjs) | 2 | Pass | Pass | Pass | +| **Total** | **44** | **44/44** | **44/44** | **44/44** | + +The [132-case matrix](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/workflows/browser-matrix.json) records source commits, exact asset SHA-256 hashes, viewport coverage and the current Playwright engines: Chromium 145.0.7632.6, Firefox 146.0.1 and WebKit 26.0, using Playwright 1.58.0 and Node 24.14.0. The graph scenarios validate visible nodes and nonempty visible edges after repeated inspector open/close and history navigation. Secondary-route coverage includes tutorial lookup/fallback, keyboard navigation, draft/Save/Reset/reload behavior, dark presentation and narrow 404 recovery. + +Native multipart uploads reach a bounded same-origin loopback collector that validates the actual filename, MIME type and exact selected bytes before mutating the fixture. Native Blob workers are allowed only within the fixture origin/request bound; imported HTTP assets and unexpected endpoints remain checked. These transport details avoid treating Playwright WebKit interception limitations as application behavior. + +[`test:bundle`](../../package.json) includes all eight harnesses serially after building; `KFP_BROWSER` selects another installed engine. Screenshots below were captured from the same qualified production assets in Chromium and reviewed for layout; they are not historical images or minimum-browser evidence. + +| Pipelines and expanded versions | Run creation, dark theme | +| ------------------------------------------------------------------------------------ | --------------------------------------------------------------------- | +| ![Pipeline cards and expanded version table](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/workflows/pipelines-expanded-light.png) | ![New run form and summary in dark theme](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/workflows/new-run-dark.png) | + +| Artifact lineage, dark theme | Large graph, zoomed/panned dark view | +| --------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| ![Artifact lineage with related tasks](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/workflows/artifact-lineage-dark.png) | ![200-task graph after zoom and pan](https://github.com/kubeflow/pipelines/blob/d9aed93d6184be1f65d36066b40eb92028a2aa25/snapshots/54f78798fea94aecc64006a8296a9473f453d55a/frontend/docs/ui-modernization/workflows/large-graph-dark.png) | + +## Runs status-filter compatibility dependency + +The existing name/archive filters remain available. A new status selector is deferred until list filtering matches the statuses displayed for historical runs. The backend maps the `state` predicate to the raw database `State` column, while `Run.ToV2()` normalizes that value and can fall back to historical `Conditions`. An older run can therefore display Running but be omitted by a raw `state = RUNNING` query. The historical `status` field is not a substitute: it collapses Paused into Pending and Canceled into Failed. + +The backend already has an effective-state predicate for lifecycle operations, but generic list filtering needs separate compatibility coverage before this UI can expose the new filter. See [model conversion and filter mapping](../../../backend/src/apiserver/model/run.go), [list filtering](../../../backend/src/apiserver/storage/run_store.go), and the [wire states](../../../backend/api/v2beta1/run.proto). This remains an open dependency in #14572; the modernization does not rewrite historical data or change backend semantics. Global status counts and the proposed 48-hour failure summary remain omitted under the KEP's bounded-data rule. The current list requests `skip_count=true`; a page length is not a total. + +## Browser floor and accessibility + +The maintainer-approved [browser support policy](../browser-support.md) covers the latest two stable Chrome/Edge/Firefox majors, current Chrome/Edge Extended Stable, supported Firefox ESR overlap, and current/previous annual Safari and iOS/iPadOS releases with latest patches. Policy acceptance is complete. The [supported-browser checkpoint](supported-browser-qualification.md) records full Chrome 154 and Edge 154/153 production runs plus scoped Firefox stable/ESR smoke. Remaining supported-browser, device and OS qualification stays open. + +The [production Browserslist](../../package.json) and [Vite targets](../../vite.config.mts) retain conservative compiler settings. Their Chrome/Edge 111, Firefox 128 and Safari/iOS 16.4 targets no longer define product support or required qualification. Historical engine runs and the actual Firefox 128 smoke retain their recorded provenance; they do not qualify the newly declared channels. Record exact browser/OS versions and final asset identities for the new release matrix. + +Keyboard tests cover focus visibility, semantic table selection, tabs, modeless/modal inspection, rich help links, switch operation, dialog trapping/return and draft form behavior. Token tests check normal-text contrast pairs in both themes. The delivery plan now uses automated accessibility, keyboard/focus and screenshot regression checks. Uncovered assistive-technology behavior remains an explicit limitation; manual testing is not a planned release task. See the [automated qualification plan](../browser-support.md#automated-qualification). + +## Backend, deployment and KFP Local considerations + +This migration does not change backend schemas, API payload formats, runtime-task ownership, polling sources, artifact storage, or Python `kfp.local` execution/output artifacts. No KFP Local migration, local-run recompilation or artifact conversion is required. UI preferences use the existing keys plus `kfp.theme`; no destructive preference migration is introduced. + +The frontend image includes both the browser application and its Express server. A static bundle smoke alone cannot qualify deployment compatibility. Preserve root and `/pipeline/` asset entry, hash URLs, runtime deployment flags, namespace context, probes, service ports, security context, signing-key lifecycle and UI rollout strategy. Qualify standalone and embedded/multi-user authorization, shared pipelines and supported pipeline stores in their real deployment modes. + +The [baseline deployment contract](https://github.com/kubeflow/pipelines/blob/339670f5e/frontend/docs/ui-modernization/deployment-baseline.md) requires immutable previous/candidate UI identities against the same unchanged compatible backend, followed by an actual return to the previous UI. The planned rehearsal builds the previous UI and Express server from baseline source `02cbc725ac9ddcd950f4400d8355dd78bfcd6c57`; a release tag alone is not assumed compatible with the current task APIs. Rollback must preserve backend data, browser preferences and the shared TensorBoard signing key, then repeat representative list/create/task/log/artifact/viewer checks. At this checkpoint the live rehearsal remained pending; later source-specific rehearsal results are retained in the evidence archive. + +## Historical checkpoint: Legacy presentation retirement + +The following records the source-specific retirement checkpoint. Its counts, pending items and outcomes do not describe the current PR head. See [retained evidence](evidence-archive.md) and the tracking issue for subsequent results. + +The unused legacy `CustomTable` and `RunList` presentation branches are removed. Every production table caller supplies an explicit modern renderer; `CustomTable.renderTable` is required. The controller retains the same filter, sort, page-token, storage, selection and stale-request handling, and RunList retains the existing requests and enrichment. The retired `CustomTableRow`, `StatusV2`, CSS helpers and unused input/separator components are deleted, together with their renderer-specific tests and snapshots. MUI, Emotion and typestyle dependencies are removed. + +Controller tests now exercise the active ResourceTable controls, and RunList tests exercise the active RunsTable presentation. The retirement-focused checks pass 242 tests across 15 suites. The lower full-suite total reflects removal of tests for the deleted presentation paths; it does not remove request, mutation, selection, paging or recovery assertions from the retained components. + +## Historical checkpoint: Verification checkpoint + +The following records the source-specific retirement checkpoint. Its counts, pending items and outcomes do not describe the current PR head. See [retained evidence](evidence-archive.md) and the tracking issue for subsequent results. + +Application and harness source `2e52a3fa634fa0805511a37c982f8d13f690b868` includes legacy retirement, the graph measurement fix, current master dependencies, and the WebKit run-type fieldset fix. With Node 24.14.0 / npm 11.17.0, the full CI constituent checks pass: formatting, UI/server lint, application/mock TypeScript, React peer checks, 131 UI files / **1,721 tests**, and 29 server files / **1,052 tests**, both with coverage. Repository commit hooks, production build and Storybook build pass. All 132 browser cases were rerun on the retirement build and passed in the three recorded engines. + +The qualified entry JavaScript is `index-CsUTqcBF.js` (SHA-256 `408fa52d300a3bf2c76ea7d77f99037b5dff38d8701a2811a9bf949430d21e26`); CSS is `index-8lXTuxuW.css` (SHA-256 `f3dfd462464d4418f97f9d8719880c943ed52e17e2152168b3c16c51c79323c1`). The published matrix and four screenshots correspond to these emitted assets. + +UI coverage is 51.84% lines (6,099/11,765), 46.01% branches (4,720/10,258), 59.75% functions (1,682/2,815), and 52.28% statements (6,305/12,059). Corresponding baseline percentages are 50.96%, 43.50%, 58.14%, and 51.28%. Inclusion/exclusion rules are unchanged. Behavioral coverage and removal of obsolete presentation components change the denominator; coverage percentages alone do not establish workflow parity. Server coverage is 81.32% lines, 78.72% branches, 73.90% functions and 81.03% statements. + +The [performance comparison](performance-comparison.md) records repeated measurements on the retired application assets. Initial JS/CSS gzip is 4.0% below baseline; Runs/Run Details LCP remains 6.4%/14.5% slower, filtering readiness 4.6% slower, and layout shifts remain higher. The report links the pre-retirement checkpoint and retains individual trials and limits. Hosted checks are recorded against their source commits below. + +## Historical checkpoint: Final qualification checklist + +The following records the source-specific retirement checkpoint. Its counts, pending items and outcomes do not describe the current PR head. See [retained evidence](evidence-archive.md) and the tracking issue for subsequent results. + +- [x] Retire the unused legacy table/run-list presentation branches and their MUI/Emotion/typestyle dependencies; rerun source checks, builds and the complete browser matrix. +- [x] Record the application source commit and fresh production/Storybook build identities. +- [x] Run full formatting, lint, application/mock type checks, React peer checks, and UI/server coverage on that source; record exact totals. +- [x] Run all 44 production browser scenarios in three engines on the recorded emitted assets; retain the browser/version matrix and reviewed screenshots. +- [x] Adopt the [browser support policy](../browser-support.md), including enterprise channels and annual Safari releases. +- [ ] Complete actual supported-browser/OS/device qualification and automated accessibility qualification against the new policy. +- [x] Repeat nine native-fixture loads, three filtering trials and three run/task navigation trials on the retirement build against the [performance baseline](https://github.com/kubeflow/pipelines/blob/339670f5e/frontend/docs/ui-modernization/performance-baseline.md); retain the pre-retirement results and report measured regressions and scope limits. +- [ ] Resolve or explicitly accept measured regressions with agreed budgets, and extend repeated measurements to representative large-graph and populated-comparison workloads. +- [ ] Reconfirm hosted real-cluster frontend integration results for the final source and record any independent backend failures separately. +- [ ] Rehearse previous UI → candidate UI → previous UI against the same backend with immutable image identities and retained state; qualify the additional real deployment modes above. + +Use the repository-pinned Node/npm versions and run from `frontend`: + +```sh +npm run test:ci +npm run test:bundle +npm run build:storybook +``` + +For browser engines already installed in the test environment, rerun the emitted bundle without another source build: + +```sh +KFP_BROWSER=firefox node --test --test-concurrency=1 scripts/production-bundle.smoke.mjs scripts/ui-modernization-*.smoke.mjs +KFP_BROWSER=webkit node --test --test-concurrency=1 scripts/production-bundle.smoke.mjs scripts/ui-modernization-*.smoke.mjs +``` + +These commands do not establish minimum-version support on their own. Hosted checks, agreed performance budgets, supported-browser/accessibility qualification and live deployment/rollback evidence remain release gates. + +### Hosted and harness follow-up + +All four [real-cluster frontend integration lanes](https://github.com/kubeflow/pipelines/actions/runs/36437408457) pass at retirement source `2e52a3fa6` (Kubernetes 1.33/1.36, TLS on/off). The same source passes hosted UI/server coverage; its production artifact test exposed a readiness race: a visible TensorBoard Start control precedes the initial lookup response. Harness-only follow-up `409aa8a7a` awaits that response and usable control, and holds the response to verify disabled/loading behavior. All 24 affected checks pass across the three engines, with unchanged application asset hashes and exact request-count/namespace assertions. [Hosted frontend qualification](https://github.com/kubeflow/pipelines/actions/runs/36439321567) passes at that follow-up: 1,721 UI tests, 1,052 server tests, full frontend checks and 44/44 Chromium production scenarios. diff --git a/frontend/index.html b/frontend/index.html index 3938aa9fa97..59b64eb9823 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -20,8 +20,12 @@ - + Kubeflow Pipelines + + + + ', + ); + await writeFile(join(directory, 'app.js'), 'test'); + await writeFile(join(directory, 'app.css'), 'body {}'); + const sourceSha = 'a'.repeat(40); + const provenance = { sourceSha, assets: await inventory(directory) }; + assert.ok((await verifyBuild(directory, provenance, sourceSha)).entryGzipBytes > 0); + await assert.rejects(verifyBuild(directory, provenance, 'b'.repeat(40)), /source/); + await writeFile(join(directory, 'app.js'), 'changed'); + await assert.rejects(verifyBuild(directory, provenance, sourceSha), /assets/); + await writeFile( + join(directory, 'index.html'), + '', + ); + await assert.rejects( + verifyBuild(directory, { sourceSha, assets: await inventory(directory) }, sourceSha), + /Unknown entry/, + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test('readiness rejects missing timestamps, failed APIs and incomplete interactions', () => { + const base = { + kind: 'runs', + readiness: { readiness: { apis: [{ status: 200 }] } }, + observations: { + observedAtMs: 200, + paint: { shifts: [] }, + readiness: { contentReadyMs: 100, fontsReadyMs: 110 }, + }, + }; + validateSample(base); + for (const change of [ + (sample) => { + sample.observations.readiness.contentReadyMs = null; + }, + (sample) => { + sample.observations.readiness.fontsReadyMs = 201; + }, + (sample) => { + sample.readiness.readiness.apis = []; + }, + (sample) => { + sample.readiness.readiness.apis[0].status = 500; + }, + (sample) => { + sample.observations.paint = {}; + }, + (sample) => { + sample.kind = 'filter'; + }, + (sample) => { + sample.kind = 'navigation'; + }, + ]) { + const sample = structuredClone(base); + change(sample); + assert.throws(() => validateSample(sample)); + } + const navigation = { + ...base, + kind: 'navigation', + runOpen: { taskNodeVisible: true, measure: [{ startTime: 120, duration: 20 }] }, + taskOpen: { taskDetailsVisible: true, measure: [{ startTime: 150, duration: 20 }] }, + }; + validateSample(navigation); + navigation.taskOpen.measure[0].duration = 100; + assert.throws(() => validateSample(navigation), /beyond observation/); +}); + +test('editor endpoints retain common model readiness and require candidate worker readiness', () => { + const hash = 'a'.repeat(64); + const sample = { + kind: 'editor', + variant: 'candidate', + observations: { observedAtMs: 200, paint: { shifts: [] } }, + editor: { + duration: 100, + endpoint: 'complete-read-only-model-fonts-two-frames', + workerStatus: 'ready', + workerReadyMs: 110, + readOnly: true, + workerResponseStatus: 200, + expectedModelSha256: hash, + modelSha256: hash, + workerModelSha256: hash, + }, + }; + validateSample(sample); + for (const field of [ + 'expectedModelSha256', + 'modelSha256', + 'workerModelSha256', + 'workerResponseStatus', + 'readOnly', + 'duration', + 'endpoint', + 'workerReadyMs', + 'workerStatus', + ]) { + const corrupted = structuredClone(sample); + delete corrupted.editor[field]; + assert.throws(() => validateSample(corrupted)); + } + const previous = structuredClone(sample); + previous.variant = 'previous'; + validateSample(previous); + previous.editor.workerResponseStatus = 404; + assert.throws(() => validateSample(previous)); + assert.throws(() => validateSample({ ...sample, variant: 'unknown' })); + const legacy = structuredClone(sample); + legacy.variant = 'legacy'; + legacy.editor.workerStatus = 'unavailable-missing-baseline-asset'; + legacy.editor.workerResponseStatus = 404; + legacy.editor.workerPath = '/worker-yaml.js'; + delete legacy.editor.workerReadyMs; + delete legacy.editor.workerModelSha256; + validateSample(legacy); + for (const change of [ + (item) => { + item.variant = 'candidate'; + }, + (item) => { + item.editor.workerResponseStatus = 500; + }, + (item) => { + item.editor.workerPath = '/other.js'; + }, + (item) => { + item.editor.workerReadyMs = 100; + }, + (item) => { + item.editor.modelSha256 = 'b'.repeat(64); + }, + ]) { + const invalid = structuredClone(legacy); + change(invalid); + assert.throws(() => validateSample(invalid)); + } +}); + +test('cleanup rejection and timeout cannot skip later cleanup or final evidence', async () => { + const events = []; + const failures = []; + await cleanupWithEvidence( + [ + [ + 'browser', + async () => { + events.push('browser'); + throw new Error('close rejected'); + }, + ], + ['context', () => new Promise(() => {})], + [ + 'fixture', + async () => { + events.push('fixture'); + }, + ], + ], + (failure) => failures.push(failure), + async () => { + events.push('saved'); + }, + 10, + ); + assert.deepEqual(events, ['browser', 'fixture', 'saved']); + assert.deepEqual( + failures.map((failure) => failure.stage), + ['browser', 'context'], + ); + assert.match(failures[1].error, /timed out/); +}); + +test('later scaling failure preserves prior samples and the failing attempt on disk', async () => { + const directory = await mkdtemp(join(tmpdir(), 'kfp-scaling-progress-')); + const path = join(directory, 'progress.json'); + const progress = { expectedSamples: 7, status: 'running', samples: [], attempts: [] }; + try { + await recordScalingTrial(progress, path, 1, async () => { + progress.samples.push({ sample: 1, readyMs: 20 }); + }); + await assert.rejects( + recordScalingTrial(progress, path, 2, async () => { + throw new Error('second readiness failed'); + }), + /second readiness/, + ); + const retained = JSON.parse(await readFile(path)); + assert.equal(retained.status, 'failed'); + assert.deepEqual(retained.samples, [{ sample: 1, readyMs: 20 }]); + assert.deepEqual( + retained.attempts.map((trial) => trial.status), + ['measured', 'failed'], + ); + assert.match(retained.attempts[1].error, /second readiness failed/); + assert.ok(retained.attempts.every((trial) => trial.startedAt && trial.finishedAt)); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test('hosted readiness distinguishes initial font settling from loss after confirmation', async () => { + const original = JSON.parse( + await readFile( + new URL('./fixtures/performance-protocol.json', import.meta.url), + ), + ); + const retained = JSON.stringify(original); + const protocol = hostedReadinessProtocol(original); + assert.equal(JSON.stringify(original), retained, 'Historical protocol must remain unchanged'); + assert.throws( + () => hostedReadinessProtocol({ scripts: { preNavigation: '' } }), + /Historical readiness/, + ); + + const runId = 'e0115ac1-0479-4194-a22d-01e65e09a32b'; + let visible = true; + let now = 0; + let mutation; + let disconnects = 0; + const frames = []; + const timers = []; + const window = {}; + const context = { + window, + URL, + Intl, + Date, + setTimeout: (callback) => timers.push(callback), + location: { hash: '#/runs/details/' + runId }, + navigator: { userAgent: 'test', language: 'en-US' }, + innerWidth: 1280, + innerHeight: 720, + devicePixelRatio: 1, + matchMedia: () => ({ matches: false }), + requestAnimationFrame: (callback) => frames.push(callback), + getComputedStyle: () => ({ visibility: visible ? 'visible' : 'hidden' }), + document: { + body: { innerText: '' }, + fonts: { ready: Promise.resolve() }, + querySelectorAll: () => [], + querySelector: () => ({ + getAttribute: () => 'task.chicago-taxi-trips-dataset', + offsetWidth: 100, + offsetHeight: 30, + }), + }, + performance: { + now: () => now, + getEntriesByType: (type) => + type === 'resource' + ? ['', '/tasks'].map((suffix) => ({ + name: 'http://fixture/apis/v2beta1/runs/' + runId + suffix, + responseStatus: 200, + })) + : [], + }, + MutationObserver: class { + constructor(callback) { + mutation = callback; + } + observe() {} + disconnect() { + disconnects++; + } + }, + PerformanceObserver: class { + observe() {} + disconnect() { + disconnects++; + } + }, + }; + const frame = async () => { + now += 16; + assert.ok(frames.length, 'Expected a scheduled confirming frame'); + frames.shift()(); + await Promise.resolve(); + }; + runInNewContext(protocol.scripts.preNavigation, context); + mutation(); + await frame(); + await frame(); + const first = window.__kfpContentReady.firstContentReadyMs; + visible = false; + await frame(); + await frame(); + assert.equal(window.__kfpContentReady.contentReadyMs, null); + assert.equal(window.__kfpContentReady.fontsReadyMs, null); + assert.equal(window.__kfpContentReady.transientReadiness.length, 1); + assert.equal(window.__kfpContentReady.transientReadiness[0].state.node.visible, 'hidden'); + visible = true; + mutation(); + for (let index = 0; index < 4; index++) await frame(); + assert.ok(window.__kfpContentReady.contentReadyMs > first); + assert.equal(window.__kfpContentReady.fontsReadyMs, window.__kfpContentReady.contentReadyMs); + assert.equal(disconnects, 0, 'Observers remain active until capture'); + + const capture = runInNewContext('(' + protocol.scripts.perfReadinessFunction + ')', context); + visible = false; + mutation(); + visible = true; + mutation(); + await assert.rejects(capture('run-details'), /Readiness lost after confirmation/); + assert.equal(window.__kfpContentReady.postConfirmationLoss.state.node.visible, 'hidden'); + + // A clean capture freezes the readiness endpoint before later interaction changes. + delete window.__kfpContentReady.postConfirmationLoss; + assert.equal((await capture('run-details')).readiness.node.visible, 'visible'); + assert.equal(disconnects, 2); + + // Neither missing marks nor absent observations can be mistaken for confirmation. + delete window.__kfpContentReady; + const missing = capture('run-details'); + assert.equal(timers.length, 1); + now += 30001; + timers.shift()(); + await assert.rejects(missing, /Confirmed readiness not reached/); +}); + +test('legacy worker exception cannot hide other assets, modes or candidate errors', () => { + const origin = 'http://127.0.0.1:4181'; + const record = { + variant: 'legacy', + kind: 'editor', + editor: { + workerStatus: 'unavailable-missing-baseline-asset', + workerResponseStatus: 404, + workerPath: '/worker-yaml.js', + }, + }; + const error = + "Failed to execute 'importScripts' on 'WorkerGlobalScope': The script at '" + + origin + + "/worker-yaml.js' failed to load."; + assert.equal(isExpectedLegacyWorkerError(record, error, origin), true); + for (const change of [ + (item) => { + item.variant = 'candidate'; + }, + (item) => { + item.kind = 'runs'; + }, + (item) => { + item.editor.workerResponseStatus = 500; + }, + (item) => { + item.editor.workerPath = '/other.js'; + }, + (item) => { + item.editor.workerStatus = 'ready'; + }, + ]) { + const unexpected = structuredClone(record); + change(unexpected); + assert.equal(isExpectedLegacyWorkerError(unexpected, error, origin), false); + } + assert.equal( + isExpectedLegacyWorkerError(record, error.replace('worker-yaml.js', 'other.js'), origin), + false, + ); + assert.equal(isExpectedLegacyWorkerError(record, 'Unexpected application error', origin), false); +}); + +test('editor gates enforce both median boundaries and reject incomplete or invalid evidence', () => { + const editorBudgets = { + ...budgets, + firstEditorOpen: { + status: 'blocking-engineering-gate', + displayMedianMaximumMs: 4000, + workerReadyMedianMaximumMs: 5000, + }, + }; + const compare = (display, worker) => compareEditorTiming(display, worker, editorBudgets); + const boundary = compare(samples(4000), samples(5000)); + assert.equal(boundary['editor-display'].passesProposedBudget, true); + assert.equal(boundary['editor-worker-ready'].passesProposedBudget, true); + assert.equal(compare(samples(4001), samples(5000))['editor-display'].passesProposedBudget, false); + assert.equal( + compare(samples(4000), samples(5001))['editor-worker-ready'].passesProposedBudget, + false, + ); + assert.throws(() => compare([], samples(5000)), /Missing editor display/); + assert.throws(() => compare(samples(4000), [5000]), /Missing editor worker/); + for (const invalid of [NaN, Infinity, -1]) { + assert.throws(() => compare(samples(invalid), samples(5000))); + assert.throws(() => compare(samples(4000), samples(invalid))); + } + assert.throws( + () => + compareEditorTiming(samples(1), samples(1), { + ...editorBudgets, + firstEditorOpen: { ...editorBudgets.firstEditorOpen, displayMedianMaximumMs: NaN }, + }), + /Invalid editor timing budget/, + ); +}); + +test('static transfer proof requires actual compressed bytes and cold identity fallback', () => { + const assets = [ + { path: 'static/editor.js', bytes: 1000 }, + { path: 'static/editor.js.gz', bytes: 200 }, + { path: 'static/legacy.js', bytes: 1200 }, + ]; + const response = { + path: '/static/editor.js', + status: 200, + encoding: 'gzip', + bytes: 200, + vary: 'Accept-Encoding', + cacheControl: 'no-store', + }; + validateStaticTransfers([response], assets); + validateStaticTransfers( + [{ ...response, path: '/static/legacy.js', encoding: 'identity', bytes: 1200 }], + assets, + ); + for (const change of [ + { encoding: 'identity' }, + { bytes: 1000 }, + { status: 304 }, + { vary: '' }, + { cacheControl: 'public' }, + { path: '/static/unknown.js' }, + ]) + assert.throws(() => validateStaticTransfers([{ ...response, ...change }], assets)); + assert.throws(() => validateStaticTransfers([], assets)); +}); diff --git a/frontend/scripts/performance-evidence.mjs b/frontend/scripts/performance-evidence.mjs new file mode 100644 index 00000000000..e63bc62818d --- /dev/null +++ b/frontend/scripts/performance-evidence.mjs @@ -0,0 +1,323 @@ +/* + * Copyright 2026 The Kubeflow Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { readFile, readdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { gzipSync } from 'node:zlib'; + +export const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex'); + +export function median(values) { + assert.ok(values.length && values.every((value) => Number.isFinite(value) && value >= 0)); + const ordered = values.toSorted((a, b) => a - b); + const middle = Math.floor(ordered.length / 2); + return ordered.length % 2 ? ordered[middle] : (ordered[middle - 1] + ordered[middle]) / 2; +} + +export function cls(shifts, since = 0) { + let largest = 0; + let sum = 0; + let start = -Infinity; + let previous = -Infinity; + for (const entry of shifts.toSorted((a, b) => a.time - b.time)) { + assert.ok(Number.isFinite(entry.time) && Number.isFinite(entry.value) && entry.value >= 0); + if (entry.recentInput || entry.time < since) continue; + if (entry.time - previous >= 1000 || entry.time - start >= 5000) { + start = entry.time; + sum = 0; + } + sum += entry.value; + previous = entry.time; + largest = Math.max(largest, sum); + } + return largest; +} + +export function compareTiming(baseline, candidate, budgets) { + assert.equal(baseline.length, budgets.samples, 'Missing baseline samples'); + assert.equal(candidate.length, budgets.samples, 'Missing candidate samples'); + const reference = median(baseline); + const actual = median(candidate); + const limit = + reference + + Math.max(reference * budgets.relativeMedianAllowance, budgets.absoluteMedianAllowanceMs); + return { + baselineMedianMs: reference, + candidateMedianMs: actual, + limitMs: limit, + passesProposedBudget: actual <= limit, + }; +} + +export function compareEditorTiming(display, worker, budgets) { + assert.equal(display.length, budgets.samples, 'Missing editor display samples'); + assert.equal(worker.length, budgets.samples, 'Missing editor worker samples'); + const limits = budgets.firstEditorOpen; + assert.equal(limits?.status, 'blocking-engineering-gate'); + return Object.fromEntries( + [ + ['editor-display', display, limits.displayMedianMaximumMs], + ['editor-worker-ready', worker, limits.workerReadyMedianMaximumMs], + ].map(([name, values, limit]) => { + assert.ok(Number.isFinite(limit) && limit > 0, 'Invalid editor timing budget'); + const actual = median(values); + return [ + name, + { + candidateMedianMs: actual, + limitMs: limit, + decision: limits.decision, + passesProposedBudget: actual <= limit, + }, + ]; + }), + ); +} + +export async function inventory(directory) { + const files = (await readdir(directory, { recursive: true, withFileTypes: true })) + .filter((item) => item.isFile()) + .map((item) => resolve(item.parentPath, item.name)) + .sort(); + assert.ok(files.length, 'An empty build cannot be measured'); + return Promise.all( + files.map(async (file) => { + const bytes = await readFile(file); + return { + path: file.slice(resolve(directory).length + 1).replaceAll('\\', '/'), + bytes: bytes.length, + sha256: sha256(bytes), + }; + }), + ); +} + +export async function verifyBuild(directory, provenance, expectedSource) { + assert.match(expectedSource, /^[a-f0-9]{40}$/); + assert.equal( + provenance.sourceSha, + expectedSource, + 'Build source does not match requested source', + ); + const assets = await inventory(directory); + assert.deepEqual(assets, provenance.assets, 'Production assets do not match build-job manifest'); + const html = await readFile(resolve(directory, 'index.html'), 'utf8'); + const paths = [ + ...html.matchAll(/<(?:script|link)\b[^>]*(?:src|href)=["']([^"']+\.(?:js|css))["']/g), + ].map((match) => match[1].replace(/^\/?(?:\.\/)?/, '')); + assert.ok(paths.length >= 2, 'Expected emitted entry JS and CSS'); + const entries = await Promise.all( + [...new Set(paths)].map(async (path) => { + assert.ok( + assets.some((asset) => asset.path === path), + `Unknown entry asset ${path}`, + ); + const bytes = await readFile(resolve(directory, path)); + return { path, gzipBytes: gzipSync(bytes, { level: 9 }).length }; + }), + ); + return { + sourceSha: expectedSource, + assets, + entries, + entryGzipBytes: entries.reduce((sum, entry) => sum + entry.gzipBytes, 0), + }; +} + +export function isExpectedLegacyWorkerError(record, error, origin) { + return ( + record.variant === 'legacy' && + record.kind === 'editor' && + record.editor?.workerStatus === 'unavailable-missing-baseline-asset' && + record.editor.workerResponseStatus === 404 && + record.editor.workerPath === '/worker-yaml.js' && + error.includes("Failed to execute 'importScripts'") && + error.includes(`${origin}/worker-yaml.js`) && + error.includes('failed to load') + ); +} + +export function validateSample(record) { + const finite = (value, label) => + assert.ok(Number.isFinite(value) && value >= 0, `Missing or invalid ${label}`); + const observation = record.observations; + finite(observation?.observedAtMs, 'observation endpoint'); + assert.ok(Array.isArray(observation?.paint?.shifts), 'Missing layout observations'); + const measurement = (entries, name) => { + assert.equal(entries?.length, 1, `Expected one ${name} measure`); + finite(entries[0].startTime, `${name} start`); + finite(entries[0].duration, `${name} duration`); + assert.ok( + entries[0].startTime + entries[0].duration <= observation.observedAtMs, + `${name} ends beyond observation`, + ); + }; + if (record.kind === 'editor') { + finite(record.editor?.duration, 'editor duration'); + assert.equal(record.editor.readOnly, true); + assert.match(record.editor.expectedModelSha256, /^[a-f0-9]{64}$/); + assert.equal(record.editor.modelSha256, record.editor.expectedModelSha256); + assert.equal(record.editor.endpoint, 'complete-read-only-model-fonts-two-frames'); + if (record.variant === 'legacy') { + assert.equal(record.editor.workerStatus, 'unavailable-missing-baseline-asset'); + assert.equal(record.editor.workerResponseStatus, 404); + assert.equal(record.editor.workerPath, '/worker-yaml.js'); + assert.equal(record.editor.workerReadyMs, undefined); + assert.equal(record.editor.workerModelSha256, undefined); + } else { + assert.ok(['previous', 'candidate'].includes(record.variant), 'Unknown editor variant'); + assert.equal(record.editor.workerStatus, 'ready'); + assert.equal(record.editor.workerResponseStatus, 200); + assert.equal(record.editor.workerModelSha256, record.editor.expectedModelSha256); + finite(record.editor.workerReadyMs, 'editor worker readiness'); + assert.ok(record.editor.workerReadyMs >= record.editor.duration); + } + return; + } + const ready = observation.readiness; + finite(ready?.contentReadyMs, 'content readiness'); + finite(ready?.fontsReadyMs, 'font readiness'); + assert.ok( + ready.contentReadyMs <= ready.fontsReadyMs && ready.fontsReadyMs <= observation.observedAtMs, + 'Invalid readiness order', + ); + assert.ok(record.readiness?.readiness?.apis?.length, 'Missing API readiness'); + assert.ok( + record.readiness.readiness.apis.every((api) => api.status === 200), + 'Failed API readiness', + ); + if (record.kind === 'filter') { + finite(record.filterStart, 'filter input event'); + measurement( + record.filter?.measures?.filter((entry) => entry.name === 'filter-results'), + 'filter', + ); + assert.equal(record.filter?.after?.rows?.length, 1, 'Missing filtered result'); + assert.equal(record.filter.after.value, 'xgboost'); + } + if (record.kind === 'navigation') { + measurement(record.runOpen?.measure, 'run opening'); + measurement(record.taskOpen?.measure, 'task opening'); + assert.equal(record.runOpen?.taskNodeVisible, true); + assert.equal(record.taskOpen?.taskDetailsVisible, true); + } +} + +// Cleanup must preserve earlier evidence even when browser/process teardown stalls. +export async function cleanupWithEvidence(steps, onError, save, timeoutMs = 10000) { + const errors = []; + for (const [stage, operation] of steps) { + let timer; + try { + await Promise.race([ + Promise.resolve().then(operation), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${stage} cleanup timed out`)), timeoutMs); + }), + ]); + } catch (error) { + const failure = { stage, error: error.stack || String(error) }; + errors.push(failure); + onError(failure); + } finally { + clearTimeout(timer); + } + } + await save(); + return errors; +} + +export async function recordScalingTrial(progress, path, trial, operation) { + const attempt = { trial, status: 'running', startedAt: new Date().toISOString() }; + progress.attempts.push(attempt); + const save = () => writeFile(path, `${JSON.stringify(progress, null, 2)}\n`); + await save(); + try { + await operation(); + attempt.status = 'measured'; + if (trial === progress.expectedSamples) progress.status = 'measured'; + } catch (error) { + attempt.status = 'failed'; + attempt.error = error.stack || String(error); + progress.status = 'failed'; + throw error; + } finally { + attempt.finishedAt = new Date().toISOString(); + await save(); + } +} + +// Preserve the historical protocol; adapt only its readiness capture for hosted sampling. +export function hostedReadinessProtocol(original) { + const protocol = structuredClone(original); + { + const previous = + ' window.__kfpContentReady.contentReadyMs=performance.now();\n window.__kfpContentReady.readiness=read();\n mutations.disconnect();resources.disconnect();\n document.fonts.ready.then(()=>requestAnimationFrame(()=>requestAnimationFrame(()=>{\n window.__kfpContentReady.fontsReadyMs=performance.now();\n })));'; + const next = + ' window.__kfpContentReady.firstContentReadyMs ??= performance.now();\n document.fonts.ready.then(()=>requestAnimationFrame(()=>requestAnimationFrame(()=>{\n const state=read();\n if(!isReady(state)){\n (window.__kfpContentReady.transientReadiness ??= []).push({atMs:performance.now(),state});\n pending=false;check();return;\n }\n window.__kfpContentReady.contentReadyMs=performance.now();\n window.__kfpContentReady.fontsReadyMs=window.__kfpContentReady.contentReadyMs;\n window.__kfpContentReady.readiness=state;\n })));'; + assert.ok( + protocol.scripts.preNavigation.includes(previous), + 'Historical readiness protocol changed; review the hosted adapter', + ); + protocol.scripts.preNavigation = protocol.scripts.preNavigation.replace(previous, next); + const checkStart = 'function check(){\n if(pending||!document.body||!isReady(read()))return;'; + assert.ok(protocol.scripts.preNavigation.includes(checkStart)); + protocol.scripts.preNavigation = protocol.scripts.preNavigation.replace( + checkStart, + 'window.__kfpStopReadiness=()=>{mutations.disconnect();resources.disconnect();};\nfunction check(){\n if(Number.isFinite(window.__kfpContentReady.contentReadyMs)){\n const state=read();if(!isReady(state))window.__kfpContentReady.postConfirmationLoss ??= {atMs:performance.now(),state};\n return;\n }\n if(pending||!document.body||!isReady(read()))return;', + ); + } + { + const previous = + " await new Promise((resolve,reject)=>{const begin=performance.now();const poll=()=>{const state=read();if(isReady(state))return resolve();if(performance.now()-begin>30000)return reject(new Error('Readiness not reached: '+JSON.stringify(state)));setTimeout(poll,50);};poll();});\n await document.fonts.ready;await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));\n const result=read();if(!isReady(result))throw new Error('Readiness changed');"; + const next = + " const result=await new Promise((resolve,reject)=>{const begin=performance.now();const poll=()=>{const state=read();const marks=window.__kfpContentReady;if(marks?.postConfirmationLoss||(Number.isFinite(marks?.contentReadyMs)&&!isReady(state)))return reject(new Error('Readiness lost after confirmation: '+JSON.stringify({state,marks})));if(Number.isFinite(marks?.contentReadyMs)&&Number.isFinite(marks?.fontsReadyMs)&&isReady(state)){window.__kfpStopReadiness();return resolve(state);}if(performance.now()-begin>30000)return reject(new Error('Confirmed readiness not reached: '+JSON.stringify({state,marks:window.__kfpContentReady})));setTimeout(poll,50);};poll();});"; + assert.ok( + protocol.scripts.perfReadinessFunction.includes(previous), + 'Historical readiness protocol changed; review the hosted adapter', + ); + protocol.scripts.perfReadinessFunction = protocol.scripts.perfReadinessFunction.replace( + previous, + next, + ); + } + protocol.hostedReadiness = + 'Content readiness requires the unchanged route/API predicate, fonts and two confirming frames. Transient first readiness is retained separately; post-confirmation loss fails the trial even after recovery. Final capture has a 30-second deadline.'; + return protocol; +} + +export function validateStaticTransfers(transfers, assets) { + assert.ok(transfers.length, 'Missing static transfer evidence'); + const inventory = new Map(assets.map((asset) => [asset.path, asset])); + for (const transfer of transfers) { + const path = transfer.path.replace(/^\//, ''); + const original = inventory.get(path); + assert.ok(original, `Unknown transferred asset: ${path}`); + const compressed = inventory.get(`${path}.gz`); + assert.equal(transfer.status, 200, 'Cold static requests must complete successfully'); + assert.equal(transfer.encoding, compressed ? 'gzip' : 'identity'); + assert.equal(transfer.bytes, (compressed || original).bytes); + assert.ok( + transfer.vary + .toLowerCase() + .split(/\s*,\s*/) + .includes('accept-encoding'), + ); + assert.equal(transfer.cacheControl, 'no-store', 'Qualification must retain cold transfers'); + } +} diff --git a/frontend/scripts/precompress-assets.mjs b/frontend/scripts/precompress-assets.mjs new file mode 100644 index 00000000000..405cb84a5a1 --- /dev/null +++ b/frontend/scripts/precompress-assets.mjs @@ -0,0 +1,43 @@ +/* + * Copyright 2026 The Kubeflow Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { gzipSync } from 'node:zlib'; + +export async function precompressAssets(directory, filenames) { + for (const filename of filenames) { + // Only public, generated assets: never HTML, source maps, or dynamic responses. + if (!/^static\/[\w.-]+\.(?:js|css)$/.test(filename)) continue; + const path = resolve(directory, filename); + const original = await readFile(path); + const compressed = gzipSync(original, { level: 9 }); + if (compressed.length < original.length) await writeFile(`${path}.gz`, compressed); + } +} + +/** @returns {import('vite').Plugin} */ +export function precompressAssetsPlugin() { + return { + name: 'kfp-precompress-static-assets', + apply: 'build', + // Read final output bytes, including Rollup's sourceMappingURL comments. + async writeBundle(options, bundle) { + if (!options.dir) throw new Error('Static asset compression requires an output directory'); + await precompressAssets(options.dir, Object.keys(bundle)); + }, + }; +} diff --git a/frontend/scripts/precompress-assets.test.mjs b/frontend/scripts/precompress-assets.test.mjs new file mode 100644 index 00000000000..5d6c0a5da0b --- /dev/null +++ b/frontend/scripts/precompress-assets.test.mjs @@ -0,0 +1,57 @@ +// @vitest-environment node + +/* + * Copyright 2026 The Kubeflow Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { mkdtemp, mkdir, readFile, rm, writeFile, readdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { gunzipSync } from 'node:zlib'; +import { afterEach, expect, it } from 'vitest'; +import { precompressAssets } from './precompress-assets.mjs'; + +let directory; +afterEach(async () => { + if (directory) await rm(directory, { recursive: true, force: true }); +}); + +it('emits only beneficial public JS/CSS sidecars with exact final bytes', async () => { + directory = await mkdtemp(join(tmpdir(), 'kfp-precompressed-')); + await mkdir(join(directory, 'static')); + const assets = { + 'static/Editor-abcd.js': + 'const model = "pipeline";\n'.repeat(100) + '//# sourceMappingURL=Editor.js.map\n', + 'static/worker-yaml-abcd.js': 'function validate() {}\n'.repeat(100), + 'static/theme-abcd.css': '.editor { color: black; }\n'.repeat(100), + 'static/tiny.js': 'x', + 'static/Editor.js.map': '{}'.repeat(100), + 'index.html': ''.repeat(100), + }; + for (const [name, bytes] of Object.entries(assets)) await writeFile(join(directory, name), bytes); + await precompressAssets(directory, Object.keys(assets)); + expect( + (await readdir(join(directory, 'static'))).filter((name) => name.endsWith('.gz')).sort(), + ).toEqual(['Editor-abcd.js.gz', 'theme-abcd.css.gz', 'worker-yaml-abcd.js.gz']); + for (const name of [ + 'static/Editor-abcd.js', + 'static/worker-yaml-abcd.js', + 'static/theme-abcd.css', + ]) { + expect(gunzipSync(await readFile(join(directory, `${name}.gz`))).toString()).toBe(assets[name]); + expect((await readFile(join(directory, name))).toString()).toBe(assets[name]); + } + expect(await readdir(directory)).not.toContain('index.html.gz'); +}); diff --git a/frontend/scripts/production-bundle-diagnostics.check.mjs b/frontend/scripts/production-bundle-diagnostics.check.mjs new file mode 100644 index 00000000000..10f0ceb9528 --- /dev/null +++ b/frontend/scripts/production-bundle-diagnostics.check.mjs @@ -0,0 +1,34 @@ +/* + * Copyright 2026 The Kubeflow Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 + */ +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { startupDiagnostics } from './production-bundle-diagnostics.mjs'; +test('startup cancellation preserves the failing stage through cleanup and late completion', () => { + const dir = mkdtempSync(join(tmpdir(), 'kfp-startup-')); + try { + const recorder = startupDiagnostics(dir, { sourceSha: 'source', timeoutMs: 30000 }); + recorder.stage('navigation'); + recorder.pageError('load failed'); + recorder.fail('test deadline'); + recorder.stage('browser-close'); + recorder.fail('secondary cleanup failure'); + recorder.pass(); + const saved = JSON.parse(readFileSync(join(dir, 'production-startup.json'))); + assert.equal(saved.status, 'failed'); + assert.equal(saved.failure.stage, 'navigation'); + assert.equal(saved.failure.error, 'test deadline'); + assert.deepEqual(saved.pageErrors, ['load failed']); + assert.deepEqual( + saved.events.map((e) => e.stage), + ['navigation', 'browser-close'], + ); + } finally { + rmSync(dir, { recursive: true }); + } +}); diff --git a/frontend/scripts/production-bundle-diagnostics.mjs b/frontend/scripts/production-bundle-diagnostics.mjs new file mode 100644 index 00000000000..b2010283a47 --- /dev/null +++ b/frontend/scripts/production-bundle-diagnostics.mjs @@ -0,0 +1,50 @@ +/* + * Copyright 2026 The Kubeflow Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 + */ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +// Synchronous checkpoints survive node:test cancellation of an awaited browser command. +export function startupDiagnostics(directory, identity) { + mkdirSync(directory, { recursive: true }); + const report = { + ...identity, + status: 'running', + stage: 'initializing', + events: [], + pageErrors: [], + }; + const save = () => + writeFileSync( + resolve(directory, 'production-startup.json'), + `${JSON.stringify(report, null, 2)}\n`, + ); + return { + report, + stage(name) { + report.stage = name; + report.events.push({ stage: name, at: new Date().toISOString() }); + save(); + }, + pageError(error) { + report.pageErrors.push(String(error)); + save(); + }, + fail(error) { + if (!report.failure) + report.failure = { + stage: report.stage, + error: String(error), + at: new Date().toISOString(), + }; + report.status = 'failed'; + save(); + }, + pass() { + if (!report.failure) report.status = 'passed'; + save(); + }, + }; +} diff --git a/frontend/scripts/production-bundle.smoke.mjs b/frontend/scripts/production-bundle.smoke.mjs index db1e95cb487..a84edfaac52 100644 --- a/frontend/scripts/production-bundle.smoke.mjs +++ b/frontend/scripts/production-bundle.smoke.mjs @@ -17,8 +17,10 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import { test } from 'node:test'; -import { chromium } from 'playwright'; import { PNG } from 'pngjs'; +import { resolve } from 'node:path'; +import { startupDiagnostics } from './production-bundle-diagnostics.mjs'; +import { launchBrowser } from './browser-launch.mjs'; async function routeProductionBundle(page, responses = {}) { await page.route('**/*', async (route) => { @@ -49,17 +51,54 @@ async function routeProductionBundle(page, responses = {}) { // Vitest transforms imports differently from the production bundler. Load the // emitted bundle in a browser to catch startup failures such as Ace import order. -test('production bundle renders the pipeline upload control', { timeout: 30000 }, async () => { - const browser = await chromium.launch({ - channel: process.env.PLAYWRIGHT_CHANNEL || undefined, +test('production bundle renders the pipeline upload control', { timeout: 30000 }, async (t) => { + const directory = resolve(process.env.KFP_BROWSER_REPORT_DIR || 'browser-results'); + const diagnostic = startupDiagnostics(directory, { + sourceSha: process.env.GITHUB_SHA || null, + timeoutMs: 30000, }); + let page; + const capture = async () => { + if (page && !page.isClosed()) { + try { + await page.screenshot({ + path: resolve(directory, 'production-startup-failure.png'), + timeout: 2000, + }); + } catch (error) { + diagnostic.report.screenshotError = String(error); + diagnostic.fail(diagnostic.report.failure?.error || error); + } + } + }; + const aborted = () => { + diagnostic.fail(t.signal.reason); + void capture(); + }; + t.signal.addEventListener('abort', aborted, { once: true }); + diagnostic.stage('launch'); + let browser; try { - const page = await browser.newPage(); + browser = await launchBrowser(); + diagnostic.stage('version'); + if (process.env.KFP_EXPECTED_BROWSER_VERSION) { + assert.equal( + browser.version(), + process.env.KFP_EXPECTED_BROWSER_VERSION, + 'Browser version must match KFP_EXPECTED_BROWSER_VERSION; select the intended browser binary', + ); + } + diagnostic.stage('new-page'); + page = await browser.newPage(); const errors = []; - page.on('pageerror', (error) => errors.push(error.message)); - // Startup only: empty API responses, without a backend or external network. + page.on('pageerror', (error) => { + errors.push(error.message); + diagnostic.pageError(error.message); + }); await routeProductionBundle(page); + diagnostic.stage('navigation'); await page.goto('http://kfp.test/'); + diagnostic.stage('upload-control-ready'); try { await page.locator('#createPipelineVersionBtn').waitFor({ state: 'visible', timeout: 10000 }); } catch (error) { @@ -67,34 +106,77 @@ test('production bundle renders the pipeline upload control', { timeout: 30000 } throw error; } assert.deepEqual(errors, [], 'production bundle must initialize without uncaught errors'); + } catch (error) { + diagnostic.fail(error); + await capture(); + throw error; } finally { - await browser.close(); + diagnostic.stage('browser-close'); + try { + await browser?.close(); + } catch (error) { + diagnostic.fail(error); + throw error; + } } + t.signal.removeEventListener('abort', aborted); + diagnostic.pass(); }); test( 'expanded navigation footer remains accessible in short windows', { timeout: 30000 }, - async () => { - const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || undefined }); + async (t) => { + const diagnostic = startupDiagnostics( + resolve(process.env.KFP_BROWSER_REPORT_DIR || 'browser-results', 'production-footer'), + { sourceSha: process.env.GITHUB_SHA || null, timeoutMs: 30000 }, + ); + const aborted = () => diagnostic.fail(t.signal.reason); + t.signal.addEventListener('abort', aborted, { once: true }); + let browser; try { + diagnostic.stage('launch'); + browser = await launchBrowser(); + diagnostic.stage('new-page'); const page = await browser.newPage({ viewport: { width: 1600, height: 500 } }); + page.on('pageerror', (error) => diagnostic.pageError(error.message)); await routeProductionBundle(page); + diagnostic.stage('navigation'); await page.goto('http://kfp.test/'); + diagnostic.stage('upload-control-ready'); await page.locator('#createPipelineVersionBtn').waitFor(); - const navigation = page.getByTestId('sideNav'); - const reportIssue = navigation.getByRole('link', { name: 'Report an Issue', exact: true }); + const navigation = page.getByRole('complementary', { name: 'Pipelines sidebar' }); + const reportIssue = navigation.getByRole('link', { name: 'Report an issue', exact: true }); + diagnostic.stage('footer-scroll'); await reportIssue.scrollIntoViewIfNeeded(); + diagnostic.stage('footer-issue-actionable'); await reportIssue.click({ trial: true, timeout: 2000 }); - const version = navigation.getByText('Version:', { exact: true }); + diagnostic.stage('footer-bounds'); + const version = navigation.locator('.kfp-shell-footer'); const bounds = await version.boundingBox(); assert.ok(bounds && bounds.height > 0 && bounds.y >= 0 && bounds.y + bounds.height <= 500); - await navigation.getByTestId('chevron-toggle').click({ trial: true, timeout: 2000 }); + diagnostic.stage('footer-collapse-actionable'); + await navigation + .getByRole('button', { name: 'Collapse navigation' }) + .click({ trial: true, timeout: 2000 }); await reportIssue.click({ trial: true, timeout: 2000 }); + diagnostic.stage('pipelines-actionable'); await navigation.locator('#pipelinesBtn').click({ trial: true, timeout: 2000 }); + } catch (error) { + diagnostic.fail(error); + throw error; } finally { - await browser.close(); + diagnostic.stage('browser-close'); + try { + await browser?.close(); + } catch (error) { + diagnostic.fail(error); + throw error; + } finally { + t.signal.removeEventListener('abort', aborted); + } } + diagnostic.pass(); }, ); @@ -103,9 +185,7 @@ for (const kind of ['pipeline', 'run']) { `${kind} graph is flush with tabs and retains expansion choices across reloads`, { timeout: 30000 }, async () => { - const browser = await chromium.launch({ - channel: process.env.PLAYWRIGHT_CHANNEL || undefined, - }); + const browser = await launchBrowser(); try { const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); const pipelineSpec = { @@ -175,7 +255,7 @@ for (const kind of ['pipeline', 'run']) { await page.setViewportSize({ width: 1440, height }); const canvas = await page.getByTestId('DagCanvas').boundingBox(); const tabsBottom = await page - .getByRole('button', { name: 'Graph', exact: true }) + .getByRole('tab', { name: 'Graph', exact: true }) .evaluate((button) => button.parentElement.getBoundingClientRect().bottom); assert.equal(canvas.y - tabsBottom, 0, 'Graph starts directly below the tab divider'); const canvasTop = PNG.sync.read( @@ -192,10 +272,25 @@ for (const kind of ['pipeline', 'run']) { canvasTop.data.some((channel) => channel !== 255), 'The canvas starts without an opaque white strip', ); + assert.ok(canvas.height > 0, 'Canvas retains a usable height'); assert.ok( - canvas.height > 0 && canvas.y + canvas.height <= height, - 'Canvas still fits the viewport', + await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), + 'Graph does not introduce horizontal page overflow', ); + // The modern run summary intentionally scrolls on short viewports. + // The graph and its controls must remain reachable within that layout. + const collapseControl = page.getByRole('button', { name: 'Collapse all', exact: true }); + await collapseControl.scrollIntoViewIfNeeded(); + const controlBox = await collapseControl.boundingBox(); + assert.ok( + controlBox && controlBox.y >= 0 && controlBox.y + controlBox.height <= height, + 'Graph controls remain reachable by scrolling', + ); + await collapseControl.click(); + await page.getByRole('button', { name: 'Expand Group 0', exact: true }).waitFor(); + await page.getByRole('button', { name: 'Expand all', exact: true }).click(); + assert.equal(await page.getByText('Example task', { exact: true }).count(), 25); + await page.getByRole('tab', { name: 'Graph', exact: true }).scrollIntoViewIfNeeded(); } await page.getByRole('button', { name: 'Collapse all', exact: true }).click(); await page.reload(); @@ -220,7 +315,7 @@ test( 'Timeline keeps task details visible when scrolling long charts', { timeout: 60000 }, async () => { - const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || undefined }); + const browser = await launchBrowser(); try { const page = await browser.newPage({ viewport: { width: 1600, height: 900 } }); const errors = []; @@ -284,6 +379,54 @@ test( const chart = page.getByRole('table', { name: 'Component timeline timings' }); await chart.waitFor(); const inspector = page.getByRole('complementary', { name: 'Selected task' }); + await chart.getByRole('button', { name: 'Component 0', exact: true }).click(); + // Verify the real production CSS follows theme changes, including inline bar colors. + for (const colorScheme of ['dark', 'light']) { + await page.emulateMedia({ colorScheme }); + await page.waitForFunction( + (dark) => + document + .querySelector('.run-timeline') + ?.closest('.kfp-theme') + ?.classList.contains('dark') === dark, + colorScheme === 'dark', + ); + const colors = await timeline.evaluate((element) => { + const matchesToken = (target, property, token) => { + const probe = document.createElement('span'); + probe.style.color = `var(${token})`; + element.append(probe); + const expected = getComputedStyle(probe).color; + probe.remove(); + return getComputedStyle(target)[property] === expected; + }; + return { + background: matchesToken(element, 'backgroundColor', '--background'), + text: matchesToken(element, 'color', '--foreground'), + panel: matchesToken(element.querySelector('.rt-panel'), 'backgroundColor', '--card'), + selected: matchesToken( + element.querySelector('.rt-selected'), + 'backgroundColor', + '--primary-soft', + ), + status: matchesToken( + element.querySelector('.rt-status'), + 'color', + '--status-succeeded', + ), + bar: matchesToken( + element.querySelector('.rt-bar'), + 'backgroundColor', + '--status-succeeded', + ), + }; + }); + assert.ok( + Object.values(colors).every(Boolean), + `${colorScheme} Timeline uses theme tokens: ${JSON.stringify(colors)}`, + ); + } + for (const height of [900, 500]) { await page.setViewportSize({ width: 1600, height }); await timeline.evaluate((element) => { @@ -331,20 +474,29 @@ test( const nextBounds = await inspector.boundingBox(); assert.ok(nextBounds.y >= 0 && nextBounds.y + nextBounds.height <= height); await page - .getByTestId('sideNav') - .getByRole('link', { name: 'Report an Issue', exact: true }) + .getByRole('complementary', { name: 'Pipelines sidebar' }) + .getByRole('link', { name: 'Report an issue', exact: true }) .click({ trial: true, timeout: 2000 }); } await page.setViewportSize({ width: 1000, height: 900 }); - const chartBounds = await chart.boundingBox(); - const detailsBounds = await inspector.boundingBox(); - assert.equal( - await inspector.evaluate((element) => getComputedStyle(element).position), - 'static', - ); - assert.ok( - detailsBounds.y >= chartBounds.y + chartBounds.height, - 'Narrow layouts stack details below the chart without an overlay', + // Resizing also changes the scroll anchor. Read both rectangles together + // after the responsive layout settles, rather than across separate frames. + await page.waitForFunction( + () => { + const chart = document.querySelector( + '[role="table"][aria-label="Component timeline timings"]', + ); + const details = document.querySelector('[aria-label="Selected task"]'); + if (!chart || !details) return false; + const chartBounds = chart.getBoundingClientRect(); + const detailsBounds = details.getBoundingClientRect(); + return ( + getComputedStyle(details).position === 'static' && + detailsBounds.y >= chartBounds.y + chartBounds.height + ); + }, + undefined, + { timeout: 5000 }, ); assert.deepEqual(errors, []); } finally { @@ -352,3 +504,38 @@ test( } }, ); + +test('production creation and transfer routes load on demand', { timeout: 60000 }, async () => { + const browser = await launchBrowser(); + try { + const page = await browser.newPage(); + const requests = []; + const errors = []; + page.on('request', (request) => requests.push(new URL(request.url()).pathname)); + page.on('pageerror', (error) => errors.push(error.message)); + await routeProductionBundle(page); + await page.goto('http://kfp.test/'); + await page.locator('#createPipelineVersionBtn').waitFor(); + const deferred = + /\/(?:CreateRunPage|CreateRunForm|CreateExperimentPage|UploadPipelinePage|MetadataTransfer)-/; + assert.equal( + requests.some((path) => deferred.test(path)), + false, + ); + for (const [route, selector] of [ + ['/experiments/new', '#experimentName'], + ['/runs/new', 'input[required]'], + ['/pipeline_versions/new', '#newPipelineName'], + ['/export-import', 'text=Export / Import'], + ]) { + await page.goto(`http://kfp.test/#${route}`); + await page.locator(selector).first().waitFor(); + } + assert.ok(requests.some((path) => /\/MetadataTransfer-/.test(path))); + assert.ok(requests.some((path) => /\/CreateRunForm-/.test(path))); + assert.ok(requests.some((path) => /\/UploadPipelinePage-/.test(path))); + assert.deepEqual(errors, []); + } finally { + await browser.close(); + } +}); diff --git a/frontend/scripts/run-browser-suite.check.mjs b/frontend/scripts/run-browser-suite.check.mjs new file mode 100644 index 00000000000..586283b955d --- /dev/null +++ b/frontend/scripts/run-browser-suite.check.mjs @@ -0,0 +1,97 @@ +/* + * Copyright 2026 The Kubeflow Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import assert from 'node:assert/strict'; +import { browserLaunchConfiguration } from './browser-launch.mjs'; +import { spawnSync } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +test('browser preflight failures exit nonzero and retain machine-readable evidence', async () => { + const directory = await mkdtemp(join(tmpdir(), 'kfp-browser-evidence-')); + try { + for (const name of [ + 'results.tap', + 'results.xml', + 'production-startup.json', + 'production-startup-failure.png', + ]) { + await writeFile(join(directory, name), 'stale passing report'); + } + await writeFile(join(directory, 'workflow.json'), 'separate workflow evidence'); + const result = spawnSync( + process.execPath, + [fileURLToPath(new URL('./run-browser-suite.mjs', import.meta.url))], + { + env: { + ...process.env, + KFP_BROWSER: 'unsupported-engine', + KFP_BROWSER_REPORT_DIR: directory, + }, + encoding: 'utf8', + }, + ); + assert.equal(result.status, 1, result.stderr); + const report = JSON.parse(await readFile(join(directory, 'environment.json'), 'utf8')); + assert.equal(report.status, 'failed'); + assert.equal(report.engine, 'unsupported-engine'); + assert.match(report.error, /Unsupported KFP_BROWSER/); + assert.equal(report.browserVersion, undefined); + assert.ok(report.finishedAt); + for (const name of [ + 'results.tap', + 'results.xml', + 'production-startup.json', + 'production-startup-failure.png', + ]) { + await assert.rejects(readFile(join(directory, name)), { code: 'ENOENT' }); + } + assert.equal( + await readFile(join(directory, 'workflow.json'), 'utf8'), + 'separate workflow evidence', + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test('browser launch selection keeps executable precedence and Chromium-only channels', () => { + assert.deepEqual(browserLaunchConfiguration({}), { name: 'chromium', options: {} }); + for (const name of ['chromium', 'firefox', 'webkit']) { + assert.deepEqual( + browserLaunchConfiguration({ KFP_BROWSER: name, PLAYWRIGHT_CHANNEL: 'chrome' }), + { + name, + options: name === 'chromium' ? { channel: 'chrome' } : {}, + }, + ); + assert.deepEqual( + browserLaunchConfiguration({ + KFP_BROWSER: name, + PLAYWRIGHT_CHANNEL: 'chrome', + KFP_BROWSER_EXECUTABLE_PATH: '/browser', + }), + { name, options: { executablePath: '/browser' } }, + ); + } + assert.throws( + () => browserLaunchConfiguration({ KFP_BROWSER: 'invalid' }), + /Unsupported KFP_BROWSER/, + ); +}); diff --git a/frontend/scripts/run-browser-suite.mjs b/frontend/scripts/run-browser-suite.mjs new file mode 100644 index 00000000000..78ee2c39aff --- /dev/null +++ b/frontend/scripts/run-browser-suite.mjs @@ -0,0 +1,129 @@ +/* + * Copyright 2026 The Kubeflow Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { arch, platform, release } from 'node:os'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { launchBrowser } from './browser-launch.mjs'; + +const frontend = fileURLToPath(new URL('../', import.meta.url)); +const directory = resolve(frontend, process.env.KFP_BROWSER_REPORT_DIR || 'browser-results'); +const files = [ + 'production-bundle.smoke.mjs', + 'ui-modernization-runs.smoke.mjs', + 'ui-modernization-run-details.smoke.mjs', + 'ui-modernization-pipelines.smoke.mjs', + 'ui-modernization-workflows.smoke.mjs', + 'ui-modernization-artifacts.smoke.mjs', + 'ui-modernization-comparison.smoke.mjs', + 'ui-modernization-graph.smoke.mjs', +].map((name) => `scripts/${name}`); +const engineName = process.env.KFP_BROWSER || 'chromium'; +const report = { + sourceSha: process.env.GITHUB_SHA || null, + startedAt: new Date().toISOString(), + platform: platform(), + release: release(), + arch: arch(), + node: process.version, + engine: engineName, + channel: + engineName === 'chromium' && !process.env.KFP_BROWSER_EXECUTABLE_PATH + ? process.env.PLAYWRIGHT_CHANNEL || null + : null, + executablePath: process.env.KFP_BROWSER_EXECUTABLE_PATH || null, + files, + status: 'running', +}; +await mkdir(directory, { recursive: true }); +const save = () => + writeFile(resolve(directory, 'environment.json'), `${JSON.stringify(report, null, 2)}\n`); +await save(); +try { + // A failed preflight must never retain a previous run's successful test reports. + for (const name of [ + 'results.tap', + 'results.xml', + 'production-startup.json', + 'production-startup-failure.png', + ]) { + await rm(resolve(directory, name), { force: true }); + } + const browser = await launchBrowser(); + try { + report.browserVersion = browser.version(); + if (process.env.KFP_EXPECTED_BROWSER_VERSION) { + assert.equal(report.browserVersion, process.env.KFP_EXPECTED_BROWSER_VERSION); + } + } finally { + await browser.close(); + } + report.playwrightVersion = JSON.parse( + await readFile(new URL('../node_modules/playwright/package.json', import.meta.url), 'utf8'), + ).version; + // These hashes identify the exact shared build, regardless of the runner OS. + const build = resolve(frontend, 'build'); + const entries = (await readdir(build, { recursive: true, withFileTypes: true })) + .filter((entry) => entry.isFile()) + .map((entry) => resolve(entry.parentPath, entry.name)) + .sort(); + assert.ok(entries.length, 'Production bundle must not be empty'); + report.assets = await Promise.all( + entries.map(async (path) => ({ + path: path.slice(build.length + 1).replaceAll('\\', '/'), + sha256: createHash('sha256') + .update(await readFile(path)) + .digest('hex'), + })), + ); + await save(); + console.log(`Running ${files.length} suites in ${engineName} ${report.browserVersion}`); + const result = spawnSync( + process.execPath, + [ + '--test', + '--test-concurrency=1', + '--test-reporter=tap', + `--test-reporter-destination=${resolve(directory, 'results.tap')}`, + '--test-reporter=junit', + `--test-reporter-destination=${resolve(directory, 'results.xml')}`, + ...files, + ], + { + cwd: frontend, + env: { ...process.env, KFP_EXPECTED_BROWSER_VERSION: report.browserVersion }, + stdio: 'inherit', + }, + ); + if (result.error) throw result.error; + process.stdout.write(await readFile(resolve(directory, 'results.tap'), 'utf8')); + report.exitCode = result.status; + report.signal = result.signal; + report.status = result.status === 0 ? 'passed' : 'failed'; + process.exitCode = result.status === 0 ? 0 : 1; +} catch (error) { + report.status = 'failed'; + report.error = error.stack || String(error); + console.error(error); + process.exitCode = 1; +} finally { + report.finishedAt = new Date().toISOString(); + await save(); +} diff --git a/frontend/scripts/sample-run-tasks.test.ts b/frontend/scripts/sample-run-tasks.test.ts new file mode 100644 index 00000000000..9b66a2880ad --- /dev/null +++ b/frontend/scripts/sample-run-tasks.test.ts @@ -0,0 +1,146 @@ +// Copyright 2026 The Kubeflow Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + +import { describe, expect, it } from 'vitest'; +import { V2Run, V2RuntimeState } from '../src/apisv2/run'; +import { + sampleDriverLogs, + sampleRetryTaskLogs, + sampleRunPipelineSpec, + sampleRunTasks, +} from '../mock-backend/sample-run-tasks'; +import { PipelineSpec } from '../src/generated/pipeline_spec'; +import { convertFlowElements, NodeTypeNames } from '../src/lib/v2/StaticFlow'; +import { updateFlowElementsState } from '../src/lib/v2/DynamicFlow'; +import { getRunTaskTiming } from '../src/lib/v2/RunTaskTiming'; + +const samples: V2Run[] = Object.values(V2RuntimeState) + .filter((state) => + ['RUNNING', 'SUCCEEDED', 'FAILED', 'CANCELED', 'CANCELING', 'PENDING'].includes(state), + ) + .map((state) => ({ + run_id: `sample-run-${state.toLowerCase()}`, + display_name: state, + state, + created_at: new Date('2026-01-01T00:00:00Z'), + finished_at: ['SUCCEEDED', 'FAILED', 'CANCELED'].includes(state) + ? new Date('2026-01-01T00:12:00Z') + : undefined, + })); +describe('review sample timelines', () => { + it.each(samples)('$display_name has consistent graph nodes, states and timestamps', (run) => { + const tasks = sampleRunTasks(run); + if (run.state === 'PENDING') { + expect(tasks).toEqual([]); + return; + } + expect(tasks.length).toBeGreaterThan(3); + expect(new Set(tasks.map((task) => task.task_id)).size).toBe(tasks.length); + const root = tasks.find((task) => task.type === 'ROOT')!; + const children = tasks.filter((task) => task.type === 'RUNTIME'); + for (const task of children) { + expect(task.run_id).toBe(run.run_id); + if (task.state === 'SKIPPED') { + expect(task.status_metadata?.custom_properties?.driver_logs_uri).toBeUndefined(); + } else { + expect(task.status_metadata?.custom_properties?.driver_logs_uri).toBe( + `s3://mlpipeline/review-driver-logs/${task.task_id}`, + ); + expect(sampleDriverLogs(task, 'kubeflow')).toContain( + `Creating task ${task.name} in pod kubeflow`, + ); + expect(sampleDriverLogs(task, 'kubeflow')).toContain(task.create_time!.toISOString()); + } + expect(task.parent_task_id).toBe(root.task_id); + expect(sampleRunPipelineSpec.root.dag.tasks).toHaveProperty(task.name!); + expect(task.scope_path).toBe(`root.${task.name}`); + expect(task.create_time!.getTime()).toBeGreaterThanOrEqual(run.created_at!.getTime()); + if (task.end_time) { + expect(task.end_time.getTime()).toBeGreaterThanOrEqual(task.create_time!.getTime()); + if (run.finished_at) + expect(task.end_time.getTime()).toBeLessThanOrEqual(run.finished_at.getTime()); + } + } + const training = children.find((task) => task.name === 'train-model')!; + const validation = children.find((task) => task.name === 'validate-data')!; + expect(training.create_time).toEqual(validation.create_time); + if (run.state === 'RUNNING' || run.state === 'CANCELING') { + expect(training.state).toBe('RUNNING'); + expect(training.end_time).toBeUndefined(); + expect(children.some((task) => task.name === 'evaluate-model')).toBe(false); + } else if (run.state === 'SUCCEEDED') { + expect(children.every((task) => task.state === 'SUCCEEDED')).toBe(true); + const evaluation = children.find((task) => task.name === 'evaluate-model')!; + expect(evaluation.create_time!.getTime()).toBeGreaterThan(training.end_time!.getTime()); + } else { + expect(training.state).toBe('FAILED'); + expect(training.state_history?.at(-1)?.error?.code).toBe(run.state === 'CANCELED' ? 1 : 3); + expect(children.find((task) => task.name === 'evaluate-model')?.state).toBe('SKIPPED'); + } + const timing = getRunTaskTiming(run, tasks, Date.now()); + expect(timing.rows).toHaveLength(children.length); + expect(timing.rows.every((row) => row.elapsed !== undefined && row.elapsed >= 0)).toBe(true); + }); + it('does not invent tasks for other runs', () => { + expect(sampleRunTasks({ run_id: 'unknown' })).toEqual([]); + }); +}); + +describe('review sample artifacts', () => { + it.each(samples)( + '$display_name only exposes produced outputs, with matching consumer IDs', + (run) => { + const tasks = sampleRunTasks(run); + if (run.state === 'PENDING') { + expect(tasks).toEqual([]); + return; + } + const outputArtifacts = tasks.flatMap( + (task) => task.outputs?.artifacts?.flatMap((group) => group.artifacts || []) || [], + ); + expect(outputArtifacts).toHaveLength(run.state === 'SUCCEEDED' ? 3 : 1); + for (const task of tasks) { + for (const group of task.inputs?.artifacts || []) { + for (const artifact of group.artifacts || []) { + expect(outputArtifacts).toContainEqual(artifact); + expect(artifact.created_at!.getTime()).toBeLessThanOrEqual(task.create_time!.getTime()); + } + } + if (task.state !== 'SUCCEEDED') expect(task.outputs?.artifacts || []).toEqual([]); + } + const graph = updateFlowElementsState( + ['root'], + convertFlowElements(PipelineSpec.fromJSON(sampleRunPipelineSpec)), + tasks, + ); + const artifactNodes = graph.filter( + (node) => 'type' in node && node.type === NodeTypeNames.ARTIFACT, + ); + expect(artifactNodes).toHaveLength(3); + expect( + artifactNodes.filter((node) => (node.data as { hasArtifact?: boolean }).hasArtifact), + ).toHaveLength(outputArtifacts.length); + }, + ); +}); + +describe('review sample retry logs', () => { + it('exposes distinct archived output for both automatic attempts', () => { + const run: V2Run = { + ...samples.find((sample) => sample.state === 'FAILED')!, + run_id: 'sample-run-import-failed', + }; + const task = sampleRunTasks(run).find((task) => task.name === 'train-model')!; + expect(task.pods?.filter((pod) => pod.type === 'EXECUTOR')).toHaveLength(2); + const logs = task.outputs?.artifacts?.find((group) => group.artifact_key === 'executor-logs'); + expect(logs?.artifacts?.map((artifact) => artifact.uri)).toEqual([ + `s3://mlpipeline/review-executor-logs/${task.task_id}/executor-logs-0`, + `s3://mlpipeline/review-executor-logs/${task.task_id}/executor-logs-1`, + ]); + expect(sampleRetryTaskLogs(task, run, 0)).toContain('TimeoutError:'); + expect(sampleRetryTaskLogs(task, run, 1)).toContain('invalid feature'); + expect(sampleRetryTaskLogs(task, run, 1)).not.toContain('TimeoutError:'); + }); +}); diff --git a/frontend/scripts/standalone-viewer.smoke.mjs b/frontend/scripts/standalone-viewer.smoke.mjs index 93884fce24c..9a598cd0138 100644 --- a/frontend/scripts/standalone-viewer.smoke.mjs +++ b/frontend/scripts/standalone-viewer.smoke.mjs @@ -159,7 +159,7 @@ def pipeline(): /('); + await file(root, 'entry.js', 'import "included"; window.load = () => import("lazy");'); + for (const name of ['included', 'lazy', 'unused']) { + await file( + root, + `node_modules/${name}/package.json`, + JSON.stringify({ name, version: '1.0.0', main: 'index.js', license: 'MIT' }), + ); + await file(root, `node_modules/${name}/index.js`, `console.log('${name}');`); + await file( + root, + `node_modules/${name}/LICENSE`, + `Copyright ${name}\nPermission for ${name}.`, + ); + } + await file(root, 'node_modules/included/NOTICE', 'Additional attribution from included.'); + await file(root, 'public/fonts/example/OFL.txt', 'Font copyright and full OFL fixture.'); + await build({ + root, + configFile: false, + logLevel: 'silent', + plugins: [thirdPartyNoticesPlugin()], + build: { license: { fileName: '.vite/third-party-licenses.json' } }, + }); + const notices = await readFile(path.join(root, 'dist/THIRD_PARTY_NOTICES.txt'), 'utf8'); + expect(notices).toContain('included@1.0.0 (MIT)'); + expect(notices).toContain('Copyright included\nPermission for included.'); + expect(notices).toContain('lazy@1.0.0'); + expect(notices).toContain('Additional attribution from included.'); + expect(notices).toContain('Font copyright and full OFL fixture.'); + expect(notices).toContain('Tailwind copyright and MIT license.'); + expect(notices).not.toContain('unused'); + }); + + it('retains the pinned upstream Victory license and its bundled vendor licenses', async () => { + const root = await fixture(); + const vendor = path.join(root, 'node_modules/victory-vendor'); + await file( + root, + '.vite/third-party-licenses.json', + JSON.stringify([{ name: 'victory-vendor', version: '37.3.6', identifier: 'MIT AND ISC' }]), + ); + await file( + root, + 'third_party/victory-vendor/37.3.6/LICENSE', + 'Upstream Victory copyright and MIT license.', + ); + await file(vendor, 'lib-vendor/d3-array/LICENSE', 'D3 copyright and ISC license.'); + await file(root, 'fonts/example/OFL.txt', 'Font copyright and OFL.'); + await writeThirdPartyNotices(root, new Map([['victory-vendor@37.3.6', vendor]]), root); + const notices = await readFile(path.join(root, 'THIRD_PARTY_NOTICES.txt'), 'utf8'); + expect(notices).toContain('Upstream Victory copyright and MIT license.'); + expect(notices).toContain('D3 copyright and ISC license.'); + }); + + it('fails the build with an actionable error if a bundled license is missing', async () => { + const root = await fixture(); + await file( + root, + '.vite/third-party-licenses.json', + JSON.stringify([{ name: 'missing-license', version: '1.0.0', identifier: 'MIT' }]), + ); + await expect(writeThirdPartyNotices(root, new Map())).rejects.toThrow( + 'No license text for bundled dependency missing-license@1.0.0. Restore its license file', + ); + }); +}); diff --git a/frontend/scripts/typescript-libs.check.mjs b/frontend/scripts/typescript-libs.check.mjs new file mode 100644 index 00000000000..53a1a433bc9 --- /dev/null +++ b/frontend/scripts/typescript-libs.check.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); + +test('frontend libraries declare Array.at without incidental Node ambient types', () => { + const directory = mkdtempSync(path.join(tmpdir(), 'kfp-typescript-libs-')); + try { + writeFileSync( + path.join(directory, 'arrays.ts'), + `const pages: readonly { next_page_token?: string }[] = []; +const token: string | undefined = pages.at(-1)?.next_page_token; +const states: { state: string }[] = []; +const state: string | undefined = states.at(-1)?.state; +export { token, state }; +`, + ); + writeFileSync( + path.join(directory, 'tsconfig.json'), + JSON.stringify({ + extends: fileURLToPath(new URL('../tsconfig.json', import.meta.url)), + compilerOptions: { types: [], noEmit: true }, + files: ['arrays.ts'], + include: [], + exclude: [], + }), + ); + const output = execFileSync( + process.execPath, + [require.resolve('typescript/bin/tsc'), '--project', path.join(directory, 'tsconfig.json')], + { encoding: 'utf8', timeout: 30000 }, + ); + assert.equal(output, ''); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/frontend/scripts/ui-modernization-artifacts.smoke.mjs b/frontend/scripts/ui-modernization-artifacts.smoke.mjs new file mode 100644 index 00000000000..a0ebc877fd0 --- /dev/null +++ b/frontend/scripts/ui-modernization-artifacts.smoke.mjs @@ -0,0 +1,952 @@ +/* + * Copyright 2026 The Kubeflow Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Run after npm run build. All HTTP is bounded to local deterministic fixtures. +import assert from 'node:assert/strict'; +import { mkdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { after, before, test } from 'node:test'; +import { launchBrowser } from './browser-launch.mjs'; + +const origin = 'http://kfp.test'; +const build = new URL('../build/', import.meta.url); +const targetId = 'artifact/01 space'; +const detailPath = `/artifacts/${encodeURIComponent(targetId)}`; +const types = [ + 'Dataset', + 'Model', + 'Metric', + 'HTML', + 'ClassificationMetric', + 'SlicedClassificationMetric', +]; +const typeNumbers = { + Model: 2, + Dataset: 3, + HTML: 4, + Metric: 6, + ClassificationMetric: 7, + SlicedClassificationMetric: 8, +}; +const artifacts = Array.from({ length: 18 }, (_, index) => ({ + artifact_id: index === 0 ? targetId : `artifact-${String(index + 1).padStart(2, '0')}`, + name: `Artifact ${String(index + 1).padStart(2, '0')}`, + type: types[index % types.length], + uri: `s3://artifact-bucket/output-${index + 1}.csv?endpoint=https%3A%2F%2Fstorage.example`, + namespace: index < 16 ? 'team-a' : 'team-b', + description: 'Deterministic artifact fixture', + created_at: new Date(Date.UTC(2026, 8, 26 - index)).toISOString(), +})); +const inputArtifact = { + artifact_id: 'input', + name: 'Source dataset', + type: 'Dataset', + namespace: 'team-a', +}; +const outputArtifact = { + artifact_id: 'output', + name: 'Trained model', + type: 'Model', + namespace: 'team-a', +}; +const legacyArtifact = { + artifact_id: 'legacy', + name: 'mlpipeline-ui-metadata', + type: 'Artifact', + uri: 's3://artifact-bucket/ui.json', + namespace: 'team-a', +}; +const tensorboardLogdir = 'volume://logs-pvc/run/one'; +const tensorboardImage = 'registry.example/tensorboard:custom'; +const tensorboardPodTemplate = { + spec: { + serviceAccountName: 'viewer-account', + volumes: [{ name: 'logs', persistentVolumeClaim: { claimName: 'logs-pvc' } }], + }, +}; +const richOutputs = [ + { + type: 'table', + storage: 'inline', + format: 'csv', + header: ['Metric', 'Value'], + source: Array.from( + { length: 15 }, + (_, index) => `metric-${String(index).padStart(2, '0')},${index}`, + ).join('\n'), + }, + { + type: 'markdown', + storage: 'inline', + source: + '# Viewer report\n\n**Measured values**\n\nLiteral markup\n\n[External documentation](https://example.com/report)', + }, + { + type: 'web-app', + storage: 'inline', + source: + '

Isolated report

Rendered report content

', + }, +]; +const relationships = [ + { + id: 'produced', + artifact_id: targetId, + run_id: 'run/one', + task_id: 'task/producer', + type: 'OUTPUT', + key: 'dataset', + producer: { task_name: 'Declared producer' }, + }, + { + id: 'consumed', + artifact_id: targetId, + run_id: 'run/one', + task_id: 'task/consumer', + type: 'COMPONENT_INPUT', + key: 'training_data', + }, + { + id: 'root-output', + artifact_id: targetId, + run_id: 'run/one', + task_id: 'root', + type: 'OUTPUT', + key: 'run_output', + }, +]; +const tasks = [ + { task_id: 'task/producer', run_id: 'run/one', display_name: 'Prepare dataset', type: 'RUNTIME' }, + { task_id: 'task/consumer', run_id: 'run/one', display_name: 'Train model', type: 'RUNTIME' }, + { task_id: 'root', run_id: 'run/one', display_name: 'Root', type: 'ROOT' }, +]; +let browser; +before(async () => { + browser = await launchBrowser(); + if (process.env.KFP_EXPECTED_BROWSER_VERSION) { + assert.equal( + browser.version(), + process.env.KFP_EXPECTED_BROWSER_VERSION, + 'Browser version must match KFP_EXPECTED_BROWSER_VERSION; select the intended browser binary', + ); + } +}); +after(async () => browser?.close()); +const decodeFilter = (value) => + value ? JSON.parse(value.startsWith('%') ? decodeURIComponent(value) : value).predicates : []; + +async function withFixture(options, exercise) { + const context = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + locale: 'en-US', + timezoneId: 'UTC', + reducedMotion: 'reduce', + }); + const page = await context.newPage(); + page.setDefaultTimeout(10000); + const fixture = { + requests: [], + errors: [], + failList: false, + failArtifact: false, + failPreview: false, + failRelationships: false, + failTask: false, + failTensorboardStart: false, + failTensorboardStop: false, + tensorboardReady: false, + }; + const tensorboardCheck = new Promise((resolve) => { + fixture.releaseTensorboardCheck = resolve; + }); + await context.addInitScript(({ namespace }) => { + if (location.protocol === 'about:') return; + localStorage.setItem('kfp.theme', 'light'); + if (namespace) + window.centraldashboard = { + CentralDashboardEventHandler: { + init(callback) { + const handler = {}; + callback(handler); + handler.onNamespaceSelected(namespace); + window.selectFixtureNamespace = (value) => handler.onNamespaceSelected(value); + }, + }, + }; + }, options); + page.on('pageerror', (error) => fixture.errors.push(error.message)); + await context.route('**/*', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const path = url.pathname.replace(/^\/pipeline(?=\/)/, ''); + const query = Object.fromEntries(url.searchParams); + const json = (body, status = 200) => + route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }); + try { + assert.equal(url.origin, origin, 'no external request is permitted'); + const tensorboardMutation = + options.tensorboardMutations && + ((path === '/apps/tensorboard' && ['POST', 'DELETE'].includes(request.method())) || + (path === '/apps/tensorboard/proxy/fixture-token/' && request.method() === 'HEAD')); + assert.ok( + request.method() === 'GET' || tensorboardMutation, + 'only the explicit TensorBoard fixture may receive mutations', + ); + assert.ok(fixture.requests.length < 200, 'fixture traffic must remain bounded'); + fixture.requests.push({ path, pathname: url.pathname, query, method: request.method() }); + if (path === '/embed') + return route.fulfill({ + contentType: 'text/html', + body: 'Dashboard fixture', + }); + if (path === '/' || path.startsWith('/static/')) { + const name = path === '/' ? 'index.html' : path.slice(1); + let body = await readFile(new URL(name, build)); + if (path === '/') + body = body + .toString() + .replace( + /window\.KFP_FLAGS\.DEPLOYMENT\s*=\s*null;?/, + `window.KFP_FLAGS.DEPLOYMENT=${JSON.stringify(options.namespace ? 'KUBEFLOW' : null)};`, + ) + .replace( + /window\.KFP_FLAGS\.HIDE_SIDENAV\s*=\s*null;?/, + `window.KFP_FLAGS.HIDE_SIDENAV=${!!options.embedded};`, + ); + return route.fulfill({ + body, + contentType: name.endsWith('.js') + ? 'text/javascript' + : name.endsWith('.css') + ? 'text/css' + : name.endsWith('.html') + ? 'text/html' + : 'application/octet-stream', + }); + } + if (path === '/apis/v2/healthz') + return json({ apiServerTagName: 'fixture', apiServerMultiUser: !!options.namespace }); + if (path === '/system/cluster-name' || path === '/system/project-id') + return route.fulfill({ body: '' }); + if (path === '/apis/v2/artifacts') { + assert.equal(request.method(), 'GET'); + assert.ok([10, 20, 50, 100].includes(Number(query.page_size))); + assert.match( + query.sort_by, + /^(name|created_at|artifact_id|type|uri|namespace)( (asc|desc))?$/, + ); + if (fixture.failList) + return json({ message: 'Artifact list temporarily unavailable' }, 503); + const predicates = decodeFilter(query.filter); + let rows = artifacts.filter( + (artifact) => !query.namespace || artifact.namespace === query.namespace, + ); + for (const predicate of predicates) { + if (predicate.key === 'name') { + assert.equal(predicate.operation, 'IS_SUBSTRING'); + rows = rows.filter((artifact) => + artifact.name.toLowerCase().includes(predicate.string_value.toLowerCase()), + ); + } else { + assert.equal(predicate.key, 'type'); + assert.equal(predicate.operation, 'IN'); + assert.ok( + predicate.int_values.values.every((value) => [2, 3, 4, 6, 7, 8].includes(value)), + ); + rows = rows.filter((artifact) => + predicate.int_values.values.includes(typeNumbers[artifact.type]), + ); + } + } + const [sort, direction = 'asc'] = query.sort_by.split(' '); + rows.sort( + (a, b) => + String(a[sort]).localeCompare(String(b[sort])) * (direction === 'desc' ? -1 : 1), + ); + assert.ok(!query.page_token || /^offset-\d+$/.test(query.page_token)); + const offset = Number(query.page_token?.replace('offset-', '') || 0); + const end = offset + Number(query.page_size); + return json({ + artifacts: rows.slice(offset, end), + next_page_token: end < rows.length ? `offset-${end}` : '', + }); + } + if (path.startsWith('/apis/v2/artifacts/')) { + if (fixture.failArtifact) return json({ message: 'Artifact temporarily unavailable' }, 503); + const id = decodeURIComponent(path.slice('/apis/v2/artifacts/'.length)); + const artifact = [...artifacts, inputArtifact, outputArtifact, legacyArtifact].find( + (item) => item.artifact_id === id, + ); + assert.ok(artifact, `unknown artifact ${id}`); + return json(artifact); + } + if (path === '/apis/v2/artifact_tasks') { + assert.equal(request.method(), 'GET'); + assert.ok([5, 10, 20, 50, 100].includes(Number(query.page_size))); + if (fixture.failRelationships) + return json({ message: 'Relationship page temporarily unavailable' }, 503); + if (query.task_ids) + return json({ + artifact_tasks: + query.task_ids === 'task/producer' + ? [ + { + id: 'input-edge', + task_id: 'task/producer', + artifact_id: 'input', + type: 'COMPONENT_INPUT', + key: 'source', + }, + ] + : query.task_ids === 'task/consumer' + ? [ + { + id: 'output-edge', + task_id: 'task/consumer', + artifact_id: 'output', + type: 'OUTPUT', + key: 'model', + }, + ] + : [], + }); + if (query.artifact_ids !== targetId) return json({ artifact_tasks: [] }); + assert.ok(!query.page_token || query.page_token === 'related-next'); + return json({ + artifact_tasks: query.page_token ? [relationships[2]] : relationships.slice(0, 2), + next_page_token: query.page_token ? '' : 'related-next', + }); + } + if (path === '/apis/v2/runs/run%2Fone') + return json({ run_id: 'run/one', display_name: 'Artifact training run' }); + if (path === '/apis/v2/runs/run%2Fone/tasks') return json({ tasks }); + if (path.startsWith('/apis/v2/runs/run%2Fone/tasks/')) { + if (fixture.failTask) return json({ message: 'Task temporarily unavailable' }, 503); + const id = decodeURIComponent(path.slice('/apis/v2/runs/run%2Fone/tasks/'.length)); + const task = tasks.find((item) => item.task_id === id); + assert.ok(task, `unknown task ${id}`); + return json(task); + } + if (path === '/artifacts/get') { + assert.equal(query.namespace, 'team-a'); + assert.equal(query.source, 's3'); + assert.equal(query.bucket, 'artifact-bucket'); + if (query.key === 'ui.json') + return json({ + outputs: options.richViewers + ? richOutputs + : [ + { + type: 'tensorboard', + source: options.tensorboardMutations + ? tensorboardLogdir + : 's3://artifact-bucket/tensorboard', + ...(options.tensorboardMutations + ? { image: tensorboardImage, pod_template_spec: tensorboardPodTemplate } + : {}), + }, + ], + }); + assert.equal(query.key, 'output-1.csv'); + assert.equal(query.artifactUriQuery, 'endpoint=https%3A%2F%2Fstorage.example'); + assert.equal(query.peek, '256'); + if (fixture.failPreview) + return route.fulfill({ status: 503, body: 'Preview temporarily unavailable' }); + return route.fulfill({ contentType: 'text/plain', body: 'sample,value\nrow,42\n' }); + } + if (path === '/apps/tensorboard/proxy/fixture-token/') { + assert.ok(options.tensorboardMutations); + if (request.method() === 'HEAD') + return route.fulfill({ status: fixture.tensorboardReady ? 200 : 503 }); + return route.fulfill({ + contentType: 'text/html', + body: 'TensorBoard proxy fixture

TensorBoard fixture

', + }); + } + if (path === '/apps/tensorboard') { + assert.equal(query.namespace, 'team-a'); + assert.equal( + query.logdir, + options.tensorboardMutations ? tensorboardLogdir : 's3://artifact-bucket/tensorboard', + ); + if (request.method() === 'GET') { + if (options.holdTensorboardCheck) await tensorboardCheck; + return json({ proxyPath: '', image: '' }); + } + assert.ok(options.tensorboardMutations); + if (request.method() === 'POST') { + assert.equal(query.image, tensorboardImage); + assert.deepEqual(JSON.parse(query.podtemplatespec), tensorboardPodTemplate); + assert.equal(request.headers()['content-type'], 'application/json'); + return route.fulfill({ + status: fixture.failTensorboardStart ? 403 : 200, + body: fixture.failTensorboardStart + ? 'Start forbidden by fixture' + : 'apps/tensorboard/proxy/fixture-token/', + }); + } + assert.equal(request.method(), 'DELETE'); + return route.fulfill({ + status: fixture.failTensorboardStop ? 403 : 200, + body: fixture.failTensorboardStop ? 'Stop forbidden by fixture' : '', + }); + } + throw new Error(`Unexpected request: ${request.method()} ${path}`); + } catch (error) { + fixture.errors.push(error.message); + await json({ message: error.message }, 500); + } + }); + try { + await exercise(page, fixture); + assert.deepEqual(fixture.errors, [], 'application and HTTP contracts must remain valid'); + } catch (error) { + if (fixture.errors.length) console.error('Fixture errors:', fixture.errors); + throw error; + } finally { + fixture.releaseTensorboardCheck(); + await context.close(); + } +} + +async function ready(page, count) { + await page.getByRole('table', { name: 'Artifacts', exact: true }).waitFor(); + await page.waitForFunction((expected) => { + const table = document.querySelector('table[aria-label="Artifacts"]'); + return ( + table?.getAttribute('aria-busy') === 'false' && + table.querySelectorAll('[data-row-id]').length === expected + ); + }, count); +} +async function screenshot(page, name) { + if (!process.env.KFP_ARTIFACTS_SCREENSHOT_DIR) return; + await mkdir(process.env.KFP_ARTIFACTS_SCREENSHOT_DIR, { recursive: true }); + await page.screenshot({ + path: join(process.env.KFP_ARTIFACTS_SCREENSHOT_DIR, `${name}.png`), + animations: 'disabled', + }); +} + +test('Artifacts combines scoped type/name filters with token paging, sorting, and refresh recovery', async () => { + await withFixture({ namespace: 'team-a' }, async (page, fixture) => { + fixture.failList = true; + await page.goto(`${origin}/#/artifacts`); + await page.getByRole('alert').waitFor(); + fixture.failList = false; + await page.getByRole('button', { name: 'Refresh', exact: true }).click(); + await ready(page, 16); + assert.equal(await page.getByRole('combobox', { name: 'Rows per page' }).inputValue(), '20'); + // Select a smaller page explicitly so this fixture exercises token pagination. + await page.getByRole('combobox', { name: 'Rows per page' }).selectOption('10'); + await ready(page, 10); + await page.getByRole('alert').waitFor({ state: 'hidden' }); + assert.equal( + fixture.requests.filter((item) => item.path.startsWith('/apis/v2/artifacts/')).length, + 0, + 'list rows must not fetch per-artifact details', + ); + await screenshot(page, 'artifacts-light'); + await page.getByRole('button', { name: 'Next page', exact: true }).click(); + await ready(page, 6); + await page.getByRole('searchbox', { name: 'Search artifacts' }).fill('Artifact 1'); + await ready(page, 7); + assert.ok( + !fixture.requests.filter((item) => item.path === '/apis/v2/artifacts').at(-1).query + .page_token, + ); + await page.getByRole('button', { name: 'Name', exact: true }).click(); + await page.getByRole('columnheader', { name: 'Name', exact: true }).waitFor(); + await page.getByRole('button', { name: 'Filter type', exact: true }).click(); + await page.getByRole('menuitemradio', { name: 'Metrics', exact: true }).click(); + await ready(page, 3); + const query = fixture.requests + .filter((item) => item.path === '/apis/v2/artifacts') + .at(-1).query; + assert.equal(query.namespace, 'team-a'); + assert.equal(query.sort_by, 'name'); + assert.deepEqual( + decodeFilter(query.filter).find((predicate) => predicate.key === 'type').int_values.values, + [6, 7, 8], + ); + await page.getByRole('button', { name: 'Clear Type: Metrics filter', exact: true }).click(); + await ready(page, 7); + await page.getByRole('button', { name: 'Clear filter', exact: true }).click(); + await ready(page, 10); + await page.getByRole('combobox', { name: 'Rows per page' }).selectOption('20'); + await ready(page, 16); + await page.evaluate(() => window.selectFixtureNamespace('team-b')); + await ready(page, 2); + assert.equal( + fixture.requests.filter((item) => item.path === '/apis/v2/artifacts').at(-1).query + .namespace, + 'team-b', + ); + }); +}); + +test('Artifact details preserves encoded identity, bounded preview consent, provider-aware download, and read recovery', async () => { + await withFixture({}, async (page, fixture) => { + fixture.failArtifact = true; + await page.goto(`${origin}/#${detailPath}`); + await page.getByRole('alert').waitFor(); + fixture.failArtifact = false; + await page.getByRole('button', { name: 'Refresh', exact: true }).click(); + await page.getByRole('region', { name: 'Artifact details', exact: true }).waitFor(); + await page.getByRole('alert').waitFor({ state: 'hidden' }); + assert.equal(fixture.requests.filter((item) => item.path === '/artifacts/get').length, 0); + const download = page.locator('a[download]'); + const url = new URL(await download.getAttribute('href'), origin); + assert.equal(url.searchParams.get('download'), 'true'); + assert.equal(url.searchParams.get('namespace'), 'team-a'); + assert.equal( + url.searchParams.get('artifactUriQuery'), + 'endpoint=https%3A%2F%2Fstorage.example', + ); + await page.getByText('Full URI', { exact: true }).click(); + await page.getByText(artifacts[0].uri, { exact: true }).waitFor(); + fixture.failPreview = true; + await page.getByRole('button', { name: 'Preview file contents', exact: true }).click(); + await page.getByText('Error in retrieving artifact preview.', { exact: true }).waitFor(); + fixture.failPreview = false; + await page.getByRole('button', { name: 'Retry preview', exact: true }).click(); + await page.getByText('sample,value\nrow,42', { exact: true }).waitFor(); + assert.equal(fixture.requests.filter((item) => item.path === '/artifacts/get').length, 2); + await page.getByRole('switch', { name: 'Dark mode', exact: true }).setChecked(true); + await screenshot(page, 'artifact-details-dark'); + }); +}); + +test('Related artifact tasks preserve producer/root semantics, encoded task destinations, and pagination', async () => { + await withFixture({}, async (page, fixture) => { + await page.goto(`${origin}/#${detailPath}/lineage`); + const table = page.getByRole('table', { name: 'Related tasks', exact: true }); + const produced = table.getByRole('link', { + name: 'Artifact training run · Prepare dataset', + exact: true, + }); + await produced.waitFor(); + assert.equal( + await produced.getAttribute('href'), + '#/runs/details/run%2Fone?task=task%2Fproducer', + ); + await table + .getByRole('link', { name: 'Artifact training run · Train model', exact: true }) + .waitFor(); + assert.equal( + fixture.requests.filter((item) => item.path === '/apis/v2/runs/run%2Fone/tasks').length, + 1, + 'related rows share task-name reads', + ); + await page.getByRole('button', { name: 'Next page', exact: true }).click(); + await table + .getByRole('link', { name: 'Artifact training run · Run output (root)', exact: true }) + .waitFor(); + assert.equal( + await page.getByRole('button', { name: 'Next page', exact: true }).isEnabled(), + false, + ); + await page.getByRole('tab', { name: 'Overview', exact: true }).click(); + await page.waitForURL((url) => url.hash === `#${detailPath}`); + await page.goBack(); + await table.waitFor(); + }); +}); + +test('Artifact lineage bounds neighborhoods, preserves directed identity and history, and recovers reads', async () => { + await withFixture({}, async (page, fixture) => { + await page.goto(`${origin}/#${detailPath}/explorer`); + const graph = page.getByRole('region', { name: 'Lineage graph' }); + const input = graph.getByRole('button', { name: 'Source dataset', exact: true }); + await input.waitFor(); + await graph.getByRole('button', { name: 'Trained model', exact: true }).waitFor(); + await page.waitForFunction( + () => document.querySelectorAll('svg path[data-from][data-to]').length === 4, + ); + assert.deepEqual( + await graph + .locator('svg path[data-from][data-to]') + .evaluateAll((edges) => edges.map((edge) => [edge.dataset.from, edge.dataset.to]).sort()), + [ + ['task:produced', `target:${targetId}`], + ['task:produced:artifact:input-edge', 'task:produced'], + [`target:${targetId}`, 'task:consumed'], + ['task:consumed', 'task:consumed:artifact:output-edge'], + ].sort(), + 'rendered arrows retain producer, consumer, and adjacent artifact identities', + ); + assert.ok( + fixture.requests + .filter((item) => item.path === '/apis/v2/artifact_tasks') + .every((item) => item.query.page_size === '5' && !item.query.page_token), + ); + assert.equal( + await graph.getByRole('link', { name: 'Prepare dataset', exact: true }).getAttribute('href'), + '#/runs/details/run%2Fone?task=task%2Fproducer', + ); + await input.click(); + await page.getByText('Neighborhood 2', { exact: true }).waitFor({ state: 'attached' }); + await graph.getByText('No recorded relationships.', { exact: true }).waitFor(); + await page + .getByRole('navigation', { name: 'Lineage history', exact: true }) + .getByRole('button', { name: 'Back', exact: true }) + .click(); + await input.waitFor(); + fixture.failRelationships = true; + await page.getByRole('button', { name: 'Load more relationships', exact: true }).click(); + await page.getByText('Some relationships could not be loaded.', { exact: false }).waitFor(); + assert.equal(await input.isVisible(), true, 'loaded neighborhood survives a next-page failure'); + fixture.failRelationships = false; + await page.getByRole('button', { name: 'Retry relationships', exact: true }).click(); + await page + .getByText('Some relationships could not be loaded.', { exact: false }) + .waitFor({ state: 'hidden' }); + await page.getByRole('switch', { name: 'Dark mode', exact: true }).setChecked(true); + await page.waitForFunction(() => { + const canvas = document.querySelector('.kfp-lineage-canvas'); + const artifact = document.querySelector('.kfp-lineage-artifact'); + const brand = document.querySelector('.kfp-shell-brand-title'); + return ( + getComputedStyle(canvas).backgroundColor === 'rgb(14, 16, 21)' && + getComputedStyle(artifact).backgroundColor === 'rgb(22, 25, 33)' && + getComputedStyle(brand).color === 'rgb(236, 238, 244)' + ); + }); + await screenshot(page, 'artifact-lineage-dark'); + await page.setViewportSize({ width: 600, height: 900 }); + assert.equal( + await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), + true, + ); + assert.equal( + await graph.evaluate((element) => element.scrollWidth > element.clientWidth), + true, + ); + await screenshot(page, 'artifact-lineage-narrow'); + }); +}); + +test('Legacy artifact metadata retains its namespace-scoped TensorBoard viewer', async () => { + await withFixture({ holdTensorboardCheck: true }, async (page, fixture) => { + const isTensorboardCheck = (request) => + new URL(request.url()).pathname === '/apps/tensorboard' && request.method() === 'GET'; + const checkRequested = page.waitForRequest(isTensorboardCheck); + const checkCompleted = page.waitForResponse((response) => + isTensorboardCheck(response.request()), + ); + await page.goto(`${origin}/#/artifacts/legacy`); + const start = page.getByRole('button', { name: 'Start Tensorboard', exact: true }); + await start.waitFor(); + await checkRequested; + // Visibility precedes the app lookup; a held response must keep Start unavailable. + assert.equal(await start.isEnabled(), false); + assert.equal(await start.getAttribute('aria-busy'), 'true'); + fixture.releaseTensorboardCheck(); + assert.equal((await checkCompleted).status(), 200); + await page + .getByRole('button', { name: 'Start Tensorboard', exact: true, disabled: false }) + .waitFor(); + assert.equal(await start.getAttribute('aria-busy'), null); + assert.equal(fixture.requests.filter((item) => item.path === '/apps/tensorboard').length, 1); + assert.equal( + fixture.requests.filter((item) => item.path === '/apps/tensorboard')[0].query.namespace, + 'team-a', + ); + }); +}); + +test('Embedded artifacts retain their prefix and active namespace', async () => { + await withFixture({ namespace: 'team-a', embedded: true }, async (page, fixture) => { + await page.goto(`${origin}/embed`); + const app = page.frameLocator('iframe'); + await app.getByRole('link', { name: 'Artifact 01', exact: true }).waitFor(); + assert.equal(await app.getByRole('complementary').count(), 0); + await app.getByRole('link', { name: 'Artifact 01', exact: true }).click(); + await app.getByRole('region', { name: 'Artifact details', exact: true }).waitFor(); + await app.getByRole('button', { name: 'Preview file contents', exact: true }).click(); + await app.getByText('sample,value\nrow,42', { exact: true }).waitFor(); + const preview = fixture.requests.find((item) => item.path === '/artifacts/get'); + assert.equal(preview.pathname, '/pipeline/artifacts/get'); + assert.equal(preview.query.namespace, 'team-a'); + }); +}); + +test('Rich artifact viewers preserve table paging/sort, Markdown content, and isolated HTML', async () => { + await withFixture({ richViewers: true }, async (page, fixture) => { + await page.goto(`${origin}/#/artifacts/legacy`); + const table = page.getByRole('table', { name: 'Table output', exact: true }); + await table.getByRole('cell', { name: 'metric-00', exact: true }).waitFor(); + assert.equal(await table.locator('tbody tr.kfp-viewer-table-data').count(), 10); + await table.getByRole('button', { name: 'Metric', exact: true }).click(); + assert.equal( + await table.locator('tbody tr').first().locator('td').first().textContent(), + 'metric-14', + ); + assert.equal( + await table + .getByRole('columnheader', { name: 'Metric', exact: true }) + .getAttribute('aria-sort'), + 'descending', + ); + await page.getByRole('button', { name: 'Go to next page', exact: true }).click(); + await page.getByText('11–15 of 15', { exact: true }).waitFor(); + await table.getByRole('cell', { name: 'metric-04', exact: true }).waitFor(); + await page.getByRole('combobox', { name: 'Rows per page', exact: true }).selectOption('25'); + await page.getByText('1–15 of 15', { exact: true }).waitFor(); + assert.equal(await table.locator('tbody tr.kfp-viewer-table-data').count(), 15); + assert.equal( + await page.getByRole('button', { name: 'Go to previous page', exact: true }).isEnabled(), + false, + ); + const markdown = page.locator('.kfp-markdown-viewer'); + await markdown.getByRole('heading', { name: 'Viewer report', exact: true }).waitFor(); + assert.equal( + await markdown + .locator('strong') + .allTextContents() + .then((text) => text.join()), + 'Measured values', + ); + await markdown.getByText('Literal markup', { exact: true }).waitFor(); + const externalLink = markdown.getByRole('link', { name: 'External documentation' }); + assert.equal(await externalLink.getAttribute('href'), 'https://example.com/report'); + assert.equal(await externalLink.getAttribute('target'), '_blank'); + assert.match(await externalLink.getAttribute('rel'), /noopener/); + const report = page.locator('iframe[title="HTML report"]'); + await report.contentFrame().getByRole('heading', { name: 'Isolated report' }).waitFor(); + assert.equal(await report.getAttribute('sandbox'), 'allow-scripts'); + assert.equal(await report.getAttribute('src'), 'about:blank'); + assert.equal( + fixture.requests.filter((item) => item.path === '/artifacts/get').length, + 1, + 'inline metadata content needs no extra source fetch', + ); + const lightText = await table.evaluate((element) => getComputedStyle(element).color); + await page.getByRole('switch', { name: 'Dark mode', exact: true }).setChecked(true); + await page.waitForFunction((lightColor) => { + const table = document.querySelector('.kfp-theme.dark table[aria-label="Table output"]'); + const theme = table?.closest('.kfp-theme'); + const cell = table?.querySelector('tbody td'); + return ( + cell && + theme && + getComputedStyle(cell).color !== lightColor && + getComputedStyle(cell).color === getComputedStyle(theme).color + ); + }, lightText); + const border = await table + .locator('tbody tr') + .first() + .locator('td') + .first() + .evaluate((cell) => { + const style = getComputedStyle(cell); + return { + left: style.borderLeftWidth, + right: style.borderRightWidth, + color: style.color, + background: getComputedStyle(cell.closest('.kfp-theme')).backgroundColor, + themeText: getComputedStyle(cell.closest('.kfp-theme')).color, + }; + }); + assert.deepEqual([border.left, border.right], ['1px', '1px']); + assert.notEqual(border.color, lightText, 'table foreground follows the theme change'); + assert.equal(border.color, border.themeText, 'table inherits the active theme foreground'); + assert.notEqual(border.color, border.background, 'table text remains distinct in dark mode'); + await table.scrollIntoViewIfNeeded(); + await screenshot(page, 'artifact-rich-viewers-dark'); + await markdown.scrollIntoViewIfNeeded(); + await screenshot(page, 'artifact-markdown-dark'); + await report.scrollIntoViewIfNeeded(); + await screenshot(page, 'artifact-html-dark'); + await page.setViewportSize({ width: 600, height: 900 }); + assert.equal( + await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), + true, + ); + }); +}); + +test('TensorBoard preserves PVC/image/pod-template contracts, proxy readiness, cancellation, and mutation recovery', async () => { + await withFixture({ namespace: 'team-a', tensorboardMutations: true }, async (page, fixture) => { + fixture.failTensorboardStart = true; + await page.goto(`${origin}/#/artifacts/legacy`); + const image = page.getByRole('combobox', { name: 'TF Image' }); + await image.waitFor(); + assert.equal(await image.inputValue(), tensorboardImage); + await page.getByRole('button', { name: 'Start Tensorboard', exact: true }).click(); + await page.getByRole('alert').filter({ hasText: 'Start forbidden by fixture' }).waitFor(); + fixture.failTensorboardStart = false; + const notReady = page.waitForResponse( + (response) => + new URL(response.url()).pathname === '/apps/tensorboard/proxy/fixture-token/' && + response.request().method() === 'HEAD' && + response.status() === 503, + ); + await page.getByRole('button', { name: 'Start Tensorboard', exact: true }).click(); + await notReady; + const open = page.getByRole('link', { name: 'Open Tensorboard', exact: true }); + await open.waitFor(); + await page + .getByRole('alert') + .filter({ hasText: 'Start forbidden by fixture' }) + .waitFor({ state: 'hidden' }); + assert.equal(await open.getAttribute('href'), 'apps/tensorboard/proxy/fixture-token/'); + assert.equal(await open.getAttribute('target'), '_blank'); + await page + .getByText('Tensorboard is starting, and you may need to wait for a few minutes.', { + exact: true, + }) + .waitFor(); + const ready = page.waitForResponse( + (response) => + new URL(response.url()).pathname === '/apps/tensorboard/proxy/fixture-token/' && + response.request().method() === 'HEAD' && + response.status() === 200, + ); + fixture.tensorboardReady = true; + await ready; + await page + .getByText('Tensorboard is starting, and you may need to wait for a few minutes.', { + exact: true, + }) + .waitFor({ state: 'hidden' }); + const popupPromise = page.waitForEvent('popup'); + await open.click(); + const popup = await popupPromise; + await popup.getByRole('heading', { name: 'TensorBoard fixture' }).waitFor(); + assert.equal(new URL(popup.url()).pathname, '/apps/tensorboard/proxy/fixture-token/'); + await popup.close(); + await page.getByRole('switch', { name: 'Dark mode', exact: true }).setChecked(true); + const stop = page.getByRole('button', { name: 'Stop Tensorboard', exact: true }); + await stop.focus(); + await stop.press('Enter'); + const dialog = page.getByRole('dialog', { name: 'Stop Tensorboard?' }); + const cancel = dialog.getByRole('button', { name: 'Cancel', exact: true }); + await cancel.waitFor(); + await page.waitForFunction(() => document.activeElement?.textContent === 'Cancel'); + assert.equal(await dialog.evaluate((element) => !!element.closest('.kfp-theme.dark')), true); + await screenshot(page, 'artifact-tensorboard-stop-dark'); + await cancel.press('Shift+Tab'); + await page.waitForFunction(() => document.activeElement?.textContent === 'Stop'); + await page.keyboard.press('Tab'); + await page.waitForFunction(() => document.activeElement?.textContent === 'Cancel'); + await cancel.click(); + await dialog.waitFor({ state: 'hidden' }); + await page.waitForFunction(() => document.activeElement?.textContent === 'Stop Tensorboard'); + assert.equal(fixture.requests.filter((item) => item.method === 'DELETE').length, 0); + fixture.failTensorboardStop = true; + await stop.click(); + await dialog.getByRole('button', { name: 'Stop', exact: true }).click(); + await dialog.getByRole('alert').filter({ hasText: 'Stop forbidden by fixture' }).waitFor(); + fixture.failTensorboardStop = false; + await dialog.getByRole('button', { name: 'Stop', exact: true }).click(); + await dialog.waitFor({ state: 'hidden' }); + await page.getByRole('button', { name: 'Start Tensorboard', exact: true }).waitFor(); + assert.equal(await image.inputValue(), tensorboardImage); + assert.equal(await page.getByText('Stop forbidden by fixture', { exact: true }).count(), 0); + assert.equal(fixture.requests.filter((item) => item.method === 'POST').length, 2); + assert.equal(fixture.requests.filter((item) => item.method === 'DELETE').length, 2); + assert.ok(fixture.requests.some((item) => item.method === 'HEAD')); + }); +}); + +test('artifact columns resize by pointer, persist after reload, and reset on ultrawide screens', async () => { + await withFixture({ namespace: 'team-a' }, async (page) => { + await page.setViewportSize({ width: 3440, height: 1263 }); + await page.goto(`${origin}/#/artifacts`); + await ready(page, 16); + const table = page.getByRole('table', { name: 'Artifacts', exact: true }); + const name = table.getByRole('columnheader', { name: 'Name', exact: true }); + const before = await name.boundingBox(); + const tableBounds = await table.boundingBox(); + assert.ok(Math.abs(before.width / tableBounds.width - 0.28) < 0.01); + const handle = await table + .getByRole('separator', { name: 'Resize Name column', exact: true }) + .boundingBox(); + const x = handle.x + handle.width / 2; + const y = handle.y + handle.height / 2; + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.move(x + 120, y, { steps: 6 }); + await page.mouse.up(); + await page.waitForFunction((expected) => { + const heading = document.querySelector('table[aria-label="Artifacts"] th[aria-label="Name"]'); + return Math.abs(heading.getBoundingClientRect().width - expected) <= 2; + }, before.width + 120); + const resized = await name.boundingBox(); + assert.ok( + Math.abs(resized.width - before.width - 120) <= 2, + `Name width before=${before.width}, after=${resized.width}`, + ); + const shrinkHandle = await table + .getByRole('separator', { name: 'Resize Name column', exact: true }) + .boundingBox(); + const shrinkX = shrinkHandle.x + shrinkHandle.width / 2; + await page.mouse.move(shrinkX, y); + await page.mouse.down(); + await page.mouse.move(shrinkX + 160 - resized.width, y, { steps: 8 }); + await page.mouse.up(); + await page.waitForFunction( + () => + Math.abs( + document + .querySelector('table[aria-label="Artifacts"] th[aria-label="Name"]') + .getBoundingClientRect().width - 160, + ) <= 1, + ); + const region = page.getByRole('region', { name: 'Artifacts table', exact: true }); + assert.ok( + Math.abs((await table.boundingBox()).width - (await region.boundingBox()).width) <= 1, + 'shrinking a column must keep the table filled', + ); + assert.ok( + Math.abs((await name.boundingBox()).width - 160) <= 1, + 'surplus must not stretch the resized column', + ); + await page.reload(); + await ready(page, 16); + assert.ok(Math.abs((await name.boundingBox()).width - 160) <= 1); + assert.ok( + Math.abs((await table.boundingBox()).width - (await region.boundingBox()).width) <= 1, + ); + await page.setViewportSize({ width: 375, height: 812 }); + assert.ok(Math.abs((await name.boundingBox()).width - 160) <= 1); + assert.ok( + await region.evaluate((element) => element.scrollWidth > element.clientWidth), + 'narrow layouts should scroll rather than shrink user widths', + ); + await page.setViewportSize({ width: 3440, height: 1263 }); + await page.getByRole('button', { name: 'Reset column widths', exact: true }).click(); + await page + .getByRole('button', { name: 'Reset column widths', exact: true }) + .waitFor({ state: 'hidden' }); + await page.waitForFunction((expected) => { + const heading = document.querySelector('table[aria-label="Artifacts"] th[aria-label="Name"]'); + return Math.abs(heading.getBoundingClientRect().width - expected) <= 1; + }, before.width); + assert.ok( + Math.abs((await name.boundingBox()).width - before.width) <= 1, + `reset width=${(await name.boundingBox()).width}; before=${before.width}; table=${(await table.boundingBox()).width}`, + ); + await page.reload(); + await ready(page, 16); + assert.ok( + Math.abs((await name.boundingBox()).width - before.width) <= 1, + `reset width=${(await name.boundingBox()).width}; before=${before.width}; table=${(await table.boundingBox()).width}`, + ); + }); +}); diff --git a/frontend/scripts/ui-modernization-comparison.smoke.mjs b/frontend/scripts/ui-modernization-comparison.smoke.mjs new file mode 100644 index 00000000000..8961a412dbb --- /dev/null +++ b/frontend/scripts/ui-modernization-comparison.smoke.mjs @@ -0,0 +1,837 @@ +/* + * Copyright 2026 The Kubeflow Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Run after npm run build. Fixtures exercise real native-run comparison and viewers. +// Optional candidate-only timings: KFP_SCALING_SAMPLES=3 KFP_SCALING_OUTPUT_DIR=/tmp/... +// KFP_SOURCE_COMMIT= node --test --test-name-pattern="candidate-only scaling" +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { before, after, test } from 'node:test'; +import { launchBrowser } from './browser-launch.mjs'; +import { recordScalingTrial } from './performance-evidence.mjs'; + +const origin = 'http://kfp.test'; +if (process.env.KFP_PERFORMANCE_BUILD_DIR) { + assert.equal(process.env.CI, 'true'); + assert.equal(process.env.RUNNER_ENVIRONMENT, 'github-hosted'); +} +const build = process.env.KFP_PERFORMANCE_BUILD_DIR + ? pathToFileURL(resolve(process.env.KFP_PERFORMANCE_BUILD_DIR) + '/') + : new URL('../build/', import.meta.url); +const alphaId = 'run/alpha space'; +const betaId = 'run/beta space'; +const comparisonHash = `#/compare?runlist=${encodeURIComponent(`${betaId},${alphaId}`)}`; +const runs = [ + { + run_id: alphaId, + display_name: 'Alpha run', + runtime_config: { parameters: { epochs: 0, enabled: false, empty: '' } }, + }, + { + run_id: betaId, + display_name: 'Beta run', + runtime_config: { parameters: { optimizer: 'adam' } }, + }, +].map((run) => ({ + ...run, + state: 'SUCCEEDED', + storage_state: 'AVAILABLE', + created_at: '2026-09-26T12:00:00Z', + finished_at: '2026-09-26T12:01:00Z', + pipeline_spec: { + pipelineInfo: { name: 'comparison-fixture' }, + root: { dag: { tasks: {} } }, + schemaVersion: '2.1.0', + }, +})); +const confidenceMetrics = [ + { confidenceThreshold: 1, falsePositiveRate: 0, recall: 0 }, + { confidenceThreshold: 0.5, falsePositiveRate: 0.1, recall: 0.9 }, + { confidenceThreshold: 0, falsePositiveRate: 1, recall: 1 }, +]; +const confusionMatrix = { + annotationSpecs: [{ displayName: 'cat' }, { displayName: 'dog' }], + rows: [{ row: [2, 0] }, { row: [1, 3] }], +}; +function tasksFor(runId) { + const alpha = runId === alphaId; + const prefix = alpha ? 'alpha' : 'beta'; + return [ + { + task_id: `${prefix}/evaluate`, + run_id: runId, + name: 'evaluate', + display_name: 'Evaluate', + type: 'RUNTIME', + state: 'SUCCEEDED', + outputs: { + artifacts: [ + { + artifact_key: 'metrics', + artifacts: [ + { + artifact_id: `${prefix}-metric`, + name: alpha ? 'accuracy' : 'loss', + type: 'Metric', + number_value: alpha ? 0 : 0.25, + }, + ], + }, + { + artifact_key: 'classification', + artifacts: Array.from({ length: alpha ? 110 : 1 }, (_, index) => ({ + artifact_id: `${prefix}-curve-${index}`, + name: `Curve ${String(index).padStart(3, '0')}`, + type: 'ClassificationMetric', + metadata: { confidenceMetrics, ...(index === 0 ? { confusionMatrix } : {}) }, + })), + }, + { + artifact_key: 'report', + artifacts: [ + { + artifact_id: `${prefix}-html`, + name: 'HTML report', + type: 'HTML', + namespace: 'team-a', + uri: `s3://comparison/${prefix}.html`, + }, + ], + }, + ], + }, + }, + ]; +} +let browser; +before(async () => { + browser = await launchBrowser(); + if (process.env.KFP_EXPECTED_BROWSER_VERSION) { + assert.equal( + browser.version(), + process.env.KFP_EXPECTED_BROWSER_VERSION, + 'Browser version must match KFP_EXPECTED_BROWSER_VERSION; select the intended browser binary', + ); + } +}); +after(async () => browser?.close()); + +async function withFixture(exercise, { checkHeaderLayout = false, checkEmptyLayout = false } = {}) { + const context = await browser.newContext({ + viewport: { width: 1440, height: 1000 }, + locale: 'en-US', + timezoneId: 'UTC', + reducedMotion: 'reduce', + }); + const page = await context.newPage(); + page.setDefaultTimeout(10000); + await context.addInitScript(() => { + // Sandboxed report frames intentionally have no storage access. + if (window === window.top) localStorage.setItem('kfp.theme', 'light'); + }); + let releaseRuns; + const runsReady = + checkHeaderLayout || checkEmptyLayout + ? new Promise((resolve) => { + releaseRuns = resolve; + }) + : Promise.resolve(); + if (checkHeaderLayout) { + await context.addInitScript(() => { + window.__kfpComparisonContentY = []; + new MutationObserver(() => { + const content = document.querySelector('.kfp-modern-page-content'); + if (!content) return; + const top = content.getBoundingClientRect().top; + const tops = window.__kfpComparisonContentY; + if (tops.length < 100 && tops[tops.length - 1] !== top) tops.push(top); + }).observe(document, { childList: true, subtree: true }); + }); + } + const fixture = { + requests: [], + errors: [], + runs: structuredClone(runs).map((run) => + checkEmptyLayout ? { ...run, runtime_config: { parameters: {} } } : run, + ), + runGate: undefined, + }; + page.on('pageerror', (error) => fixture.errors.push(error.message)); + await context.route('**/*', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const path = url.pathname; + const query = Object.fromEntries(url.searchParams); + const json = (value, status = 200) => + route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(value) }); + try { + assert.equal(url.origin, origin, 'comparison must not fetch outside its local fixture'); + assert.ok(fixture.requests.length < 100, 'comparison traffic must remain bounded'); + assert.equal(request.method(), 'GET'); + fixture.requests.push({ path, query }); + if (path === '/' || path.startsWith('/static/')) { + const name = path === '/' ? 'index.html' : path.slice(1); + return route.fulfill({ + body: await readFile(new URL(name, build)), + contentType: name.endsWith('.js') + ? 'text/javascript' + : name.endsWith('.css') + ? 'text/css' + : name.endsWith('.html') + ? 'text/html' + : 'application/octet-stream', + }); + } + if (path === '/apis/v2/healthz') return json({ apiServerTagName: 'fixture' }); + if (path === '/apis/v2/experiments') return json({ experiments: [] }); + if (path === '/system/cluster-name' || path === '/system/project-id') + return route.fulfill({ body: '' }); + const runPath = /^\/apis\/v2\/runs\/([^/]+)(\/tasks)?$/.exec(path); + if (runPath) { + const id = decodeURIComponent(runPath[1]); + assert.ok([alphaId, betaId].includes(id), `unexpected run id ${id}`); + if (runPath[2]) { + assert.equal(query.page_size, '200'); + assert.equal(query.order_by, 'create_time asc'); + assert.equal(query.page_token, undefined); + return json({ tasks: checkEmptyLayout ? [] : tasksFor(id) }); + } + const snapshot = structuredClone(fixture.runs.find((run) => run.run_id === id)); + await runsReady; + if (fixture.runGate) await fixture.runGate.promise; + return json(snapshot); + } + if (path === '/artifacts/get') { + assert.equal(query.source, 's3'); + assert.equal(query.namespace, 'team-a'); + assert.equal(query.bucket, 'comparison'); + assert.ok(['alpha.html', 'beta.html'].includes(query.key)); + return route.fulfill({ + contentType: 'text/html', + body: `

${query.key === 'alpha.html' ? 'Alpha' : 'Beta'} report

Deterministic report content.

`, + }); + } + throw new Error(`Unexpected comparison request: ${path}`); + } catch (error) { + fixture.errors.push(error.message); + await json({ message: error.message }, 500); + } + }); + try { + fixture.navigationStartedAt = performance.now(); + await page.goto(`${origin}/${comparisonHash}`); + let loadingContentTop; + let loadingGeometry; + if (checkEmptyLayout) { + await page.getByText('Loading parameters…', { exact: true }).waitFor(); + await page.getByText('Loading scalar metrics artifacts…', { exact: true }).waitFor(); + await page.getByRole('table', { name: 'Runs', exact: true }).waitFor(); + await page.evaluate(() => document.fonts.ready); + loadingGeometry = await comparisonGeometry(page); + releaseRuns(); + } + if (checkHeaderLayout) { + await page.getByRole('heading', { name: 'Compare 2 runs', exact: true }).waitFor(); + loadingContentTop = await page + .locator('.kfp-modern-page-content') + .evaluate((content) => content.getBoundingClientRect().top); + assert.ok( + loadingContentTop > 0, + 'comparison reserves its breadcrumb header before run data resolves', + ); + assert.deepEqual(await page.locator('.kfp-page-breadcrumbs a').allTextContents(), ['Runs']); + releaseRuns(); + } + if (checkEmptyLayout) { + await emptyComparisonReady(page); + sameComparisonGeometry(loadingGeometry, await comparisonGeometry(page), 'initial empty data'); + } else { + await page.getByRole('table', { name: 'parameters comparison', exact: true }).waitFor(); + } + if (checkHeaderLayout) { + const readyTop = await page + .locator('.kfp-modern-page-content') + .evaluate((content) => content.getBoundingClientRect().top); + assert.equal( + readyTop, + loadingContentTop, + 'comparison data arrival must not shift the content wrapper', + ); + const observedTops = await page.evaluate(() => window.__kfpComparisonContentY); + assert.ok(observedTops.length > 0); + assert.ok( + observedTops.every((top) => top === loadingContentTop), + `comparison mount must reserve the header from its first DOM commit: ${observedTops}`, + ); + } + await exercise(page, fixture); + assert.deepEqual(fixture.errors, [], 'comparison bundle and HTTP contracts must remain valid'); + } catch (error) { + if (fixture.errors.length) console.error('Fixture errors:', fixture.errors); + throw error; + } finally { + releaseRuns?.(); + fixture.runGate?.release(); + await context.close(); + } +} +async function emptyComparisonReady(page) { + await page + .getByText('There are no parameters available on the selected runs.', { exact: true }) + .waitFor(); + await page + .getByText('There are no scalar metrics artifacts available on the selected runs.', { + exact: true, + }) + .waitFor(); + await page.waitForFunction(() => { + const table = document.querySelector('table[aria-label="Runs"]'); + return ( + table?.getAttribute('aria-busy') === 'false' && + table.querySelectorAll('[data-testid="run-name-link"]').length === 2 + ); + }); +} +async function comparisonGeometry(page) { + return page.evaluate(() => { + const bounds = (element) => { + const { x, y, width, height } = element.getBoundingClientRect(); + return { x, y, width, height }; + }; + const selectors = { + overview: '.kfp-comparison-overview', + footer: '.kfp-comparison-overview .kfp-runs-table-footer', + metricTabs: '[role="tablist"][aria-label="Metric types"]', + }; + const geometry = Object.fromEntries( + Object.entries(selectors).map(([name, selector]) => [ + name, + bounds(document.querySelector(selector)), + ]), + ); + for (const [index, element] of [ + ...document.querySelectorAll('.kfp-comparison-section-heading'), + ].entries()) + geometry[`heading${index}`] = bounds(element); + for (const [index, element] of [...document.querySelectorAll('.kfp-comparison-card')].entries()) + geometry[`card${index}`] = bounds(element); + return geometry; + }); +} +function sameComparisonGeometry(before, after, phase) { + assert.deepEqual(Object.keys(after), Object.keys(before)); + for (const name of Object.keys(before)) { + for (const dimension of ['x', 'y', 'width', 'height']) { + assert.ok( + Math.abs(after[name][dimension] - before[name][dimension]) < 1, + `${phase}: ${name}.${dimension} shifted from ${before[name][dimension]} to ${after[name][dimension]}`, + ); + } + } +} + +async function screenshot(page, name) { + if (!process.env.KFP_COMPARISON_SCREENSHOT_DIR) return; + await mkdir(process.env.KFP_COMPARISON_SCREENSHOT_DIR, { recursive: true }); + await page.screenshot({ + path: join(process.env.KFP_COMPARISON_SCREENSHOT_DIR, `${name}.png`), + animations: 'disabled', + }); +} +async function waitFocused(page, locator) { + const handle = await locator.elementHandle(); + try { + await page.waitForFunction((element) => document.activeElement === element, handle); + } finally { + await handle.dispose(); + } +} + +async function renderedCurves(page, count) { + await page.waitForFunction((expected) => { + const paths = [...document.querySelectorAll('.kfp-roc-section .recharts-line-curve')]; + return paths.length === expected && paths.every((path) => !!path.getAttribute('d')); + }, count); +} +async function matrixContent(container) { + await container.locator('.kfp-confusion-cell').first().waitFor(); + assert.deepEqual(await container.locator('.kfp-confusion-cell').allTextContents(), [ + '0', + '3', + '2', + '1', + ]); + assert.deepEqual(await container.locator('.kfp-confusion-xlabel').allTextContents(), [ + 'cat', + 'dog', + ]); + assert.deepEqual(await container.locator('.kfp-confusion-ylabel').allTextContents(), [ + 'dog', + 'cat', + ]); +} + +test('Comparison preserves selected URL order, encoded links, and absent versus zero, false, and empty values', async () => { + await withFixture( + async (page, fixture) => { + const parameters = page.getByRole('table', { name: 'parameters comparison', exact: true }); + assert.deepEqual(await parameters.locator('thead a').allTextContents(), [ + 'Beta run', + 'Alpha run', + ]); + assert.deepEqual( + await parameters + .locator('thead a') + .evaluateAll((links) => links.map((link) => link.getAttribute('href'))), + [ + `#/runs/details/${encodeURIComponent(betaId)}`, + `#/runs/details/${encodeURIComponent(alphaId)}`, + ], + ); + const epochs = parameters.getByRole('row').filter({ + has: page.getByRole('rowheader', { name: 'epochs (values differ)', exact: true }), + }); + assert.deepEqual(await epochs.getByRole('cell').allTextContents(), ['—', '0']); + assert.equal(await epochs.getByLabel('Not provided', { exact: true }).count(), 1); + assert.deepEqual( + await parameters + .getByRole('row') + .filter({ hasText: 'enabled' }) + .getByRole('cell') + .allTextContents(), + ['—', 'false'], + ); + assert.equal(await parameters.getByLabel('Empty string', { exact: true }).count(), 1); + const metrics = page.getByRole('table', { + name: 'scalar metrics artifacts comparison', + exact: true, + }); + await metrics.waitFor(); + assert.deepEqual( + await metrics + .getByRole('row') + .filter({ hasText: 'Evaluate / accuracy' }) + .getByRole('cell') + .allTextContents(), + ['—', '0'], + ); + assert.ok(fixture.requests.filter((item) => item.path.endsWith('/tasks')).length === 2); + await screenshot(page, 'comparison-light'); + const alphaCheckbox = page.getByRole('checkbox', { + name: 'Select run Alpha run', + exact: true, + }); + await alphaCheckbox.uncheck(); + await page.waitForFunction( + () => + document.querySelectorAll('table[aria-label="parameters comparison"] thead a').length === + 1, + ); + assert.deepEqual(await parameters.locator('thead a').allTextContents(), ['Beta run']); + await alphaCheckbox.check(); + await page.waitForFunction( + () => + document.querySelectorAll('table[aria-label="parameters comparison"] thead a').length === + 2, + ); + assert.deepEqual(await parameters.locator('thead a').allTextContents(), [ + 'Beta run', + 'Alpha run', + ]); + assert.equal(new URL(page.url()).hash, comparisonHash); + }, + { checkHeaderLayout: true }, + ); +}); + +test('Comparison keeps empty section geometry stable during slow initial data and refresh', async () => { + await withFixture( + async (page, fixture) => { + const before = await comparisonGeometry(page); + let release; + fixture.runGate = { + promise: new Promise((resolve) => { + release = resolve; + }), + release: () => release(), + }; + fixture.runs = fixture.runs.map((run) => ({ + ...run, + display_name: `${run.display_name} updated`, + })); + const requested = page.waitForRequest((request) => + /\/apis\/v2\/runs\/[^/]+$/.test(new URL(request.url()).pathname), + ); + await page.getByRole('button', { name: 'Refresh', exact: true }).click(); + await requested; + await page.getByText('Refreshing runs…', { exact: true }).waitFor(); + assert.equal( + await page.getByRole('table', { name: 'Runs', exact: true }).getAttribute('aria-busy'), + 'true', + ); + assert.equal(await page.getByText('Loading parameters…', { exact: true }).count(), 0); + assert.equal( + await page.getByText('Loading scalar metrics artifacts…', { exact: true }).count(), + 0, + ); + assert.ok( + await page + .getByText('There are no parameters available on the selected runs.', { exact: true }) + .isVisible(), + ); + assert.ok( + await page + .getByText('There are no scalar metrics artifacts available on the selected runs.', { + exact: true, + }) + .isVisible(), + ); + sameComparisonGeometry(before, await comparisonGeometry(page), 'pending refresh'); + fixture.runGate.release(); + await emptyComparisonReady(page); + const table = page.getByRole('table', { name: 'Runs', exact: true }); + await table.getByRole('link', { name: 'Alpha run updated', exact: true }).waitFor(); + await table.getByRole('link', { name: 'Beta run updated', exact: true }).waitFor(); + assert.equal(await table.getByRole('link', { name: 'Alpha run', exact: true }).count(), 0); + assert.equal(await table.getByRole('link', { name: 'Beta run', exact: true }).count(), 0); + assert.deepEqual( + await table + .locator('[data-testid="run-name-link"]') + .evaluateAll((links) => links.map((link) => link.dataset.runId)), + [betaId, alphaId], + ); + assert.equal(await table.locator('[data-row-id][data-selected="true"]').count(), 2); + sameComparisonGeometry(before, await comparisonGeometry(page), 'completed refresh'); + assert.equal(fixture.requests.filter((request) => request.path.endsWith('/tasks')).length, 4); + assert.equal(new URL(page.url()).hash, comparisonHash); + }, + { checkEmptyLayout: true }, + ); +}); + +test('Comparison ROC pagination preserves off-page selection, provenance, search, and explicit deselection', async () => { + await withFixture(async (page) => { + await page.getByRole('tab', { name: 'Classification Metrics', exact: true }).click(); + const trigger = page.getByRole('combobox', { name: 'ROC curves', exact: true }); + await trigger.waitFor(); + assert.match(await trigger.textContent(), /3 curves selected/); + const provenance = page.getByRole('list', { + name: 'Selected ROC curve provenance', + exact: true, + }); + const original = await provenance.getByRole('listitem').allTextContents(); + assert.equal(original.length, 3); + await renderedCurves(page, 3); + await trigger.click(); + const options = page.getByRole('listbox', { name: 'ROC curves', exact: true }); + await options.waitFor(); + assert.equal(await options.getByRole('option').count(), 100); + await page.keyboard.press('Escape'); + await waitFocused(page, trigger); + await page.getByRole('button', { name: 'Next ROC curves', exact: true }).click(); + assert.match(await trigger.textContent(), /3 curves selected/); + await trigger.click(); + await options.waitFor(); + assert.equal(await options.getByRole('option').count(), 11); + const last = options.getByRole('option').last(); + const lastName = (await last.textContent()).trim(); + await last.click(); + assert.match(await trigger.textContent(), /4 curves selected/); + await renderedCurves(page, 4); + await page.keyboard.press('Escape'); + const selected = await provenance.getByRole('listitem').allTextContents(); + assert.ok( + original.every((name) => selected.includes(name)), + 'first-page selection must survive adding a second-page curve', + ); + assert.ok(selected.some((name) => name.trim() === lastName)); + const search = page.getByRole('textbox', { name: 'Search ROC curves', exact: true }); + await search.fill(lastName); + await trigger.click(); + const selectedOption = options.getByRole('option', { name: lastName, exact: true }); + assert.equal(await selectedOption.getAttribute('aria-selected'), 'true'); + await selectedOption.click(); + await page.keyboard.press('Escape'); + assert.match(await trigger.textContent(), /3 curves selected/); + assert.deepEqual(await provenance.getByRole('listitem').allTextContents(), original); + await renderedCurves(page, 3); + await search.fill('no matching curve'); + await page.getByText('No ROC curves match this search.', { exact: true }).waitFor(); + assert.match(await trigger.textContent(), /3 curves selected/); + await search.fill(''); + await page.getByRole('button', { name: 'Expand ROC chart', exact: true }).click(); + await page.getByRole('button', { name: 'Compact ROC chart', exact: true }).waitFor(); + await page.getByRole('switch', { name: 'Dark mode', exact: true }).setChecked(true); + await screenshot(page, 'comparison-roc-dark'); + }); +}); + +test('Comparison selected viewers preserve provenance and fullscreen keyboard focus and theme', async () => { + await withFixture(async (page, fixture) => { + await page.getByRole('tab', { name: 'Classification Metrics', exact: true }).click(); + await page.getByRole('tab', { name: 'Confusion matrix', exact: true }).click(); + const alphaLabel = 'Alpha run / Evaluate / Curve 000'; + const betaLabel = 'Beta run / Evaluate / Curve 000'; + await page + .getByRole('combobox', { name: 'First comparison artifact', exact: true }) + .selectOption({ label: alphaLabel }); + await page + .getByRole('combobox', { name: 'Second comparison artifact', exact: true }) + .selectOption({ label: betaLabel }); + await page.getByRole('switch', { name: 'Dark mode', exact: true }).setChecked(true); + const expand = page.getByRole('button', { name: `Expand ${alphaLabel}`, exact: true }); + await matrixContent(page.locator('.kfp-plot-card').filter({ has: expand })); + await expand.click(); + const dialog = page.getByRole('dialog'); + await dialog.waitFor(); + assert.equal( + await dialog.evaluate((element) => element.closest('.kfp-theme')?.classList.contains('dark')), + true, + ); + await matrixContent(dialog); + const close = dialog.getByRole('button', { name: 'Close', exact: true }); + await waitFocused(page, close); + await page.keyboard.press('Tab'); + await waitFocused(page, close); + await page.keyboard.press('Shift+Tab'); + await waitFocused(page, close); + await screenshot(page, 'comparison-fullscreen-dark'); + await page.keyboard.press('Escape'); + await dialog.waitFor({ state: 'hidden' }); + await waitFocused(page, expand); + await page.getByRole('tab', { name: 'HTML', exact: true }).click(); + assert.equal(fixture.requests.filter((item) => item.path === '/artifacts/get').length, 0); + await page + .getByRole('combobox', { name: 'First comparison artifact', exact: true }) + .selectOption({ label: 'Alpha run / Evaluate / HTML report' }); + await page + .frameLocator('iframe') + .getByRole('heading', { name: 'Alpha report', exact: true }) + .waitFor(); + assert.equal(fixture.requests.filter((item) => item.path === '/artifacts/get').length, 1); + assert.equal( + fixture.requests.filter((item) => item.path === '/artifacts/get')[0].query.key, + 'alpha.html', + ); + }); +}); + +// These repeated laboratory samples are automation readiness timings, not field INP or a baseline comparison. +if (process.env.KFP_SCALING_SAMPLES) { + test('candidate-only scaling: populated two-run comparison with 111 classifications', async () => { + const count = Number(process.env.KFP_SCALING_SAMPLES); + assert.ok(Number.isInteger(count) && count >= 3 && count <= 20, 'use 3–20 scaling samples'); + assert.match( + process.env.KFP_SOURCE_COMMIT || '', + /^[a-f0-9]{40}$/, + 'identify the built source', + ); + assert.ok(process.env.KFP_SCALING_OUTPUT_DIR, 'provide a directory for retained raw samples'); + const data = { runs, tasks: runs.map(({ run_id }) => tasksFor(run_id)) }; + const classificationCount = data.tasks + .flat() + .flatMap((task) => task.outputs.artifacts) + .filter((output) => output.artifact_key === 'classification') + .reduce((count, output) => count + output.artifacts.length, 0); + assert.equal(classificationCount, 111); + const samples = []; + const assetPaths = new Set(); + await mkdir(process.env.KFP_SCALING_OUTPUT_DIR, { recursive: true }); + const progressPath = join( + process.env.KFP_SCALING_OUTPUT_DIR, + 'comparison-' + (process.env.KFP_BROWSER || 'chromium') + '-progress.json', + ); + const progress = { + status: 'running', + expectedSamples: count, + applicationSource: process.env.KFP_SOURCE_COMMIT, + browserVersion: browser.version(), + harnessSha256: createHash('sha256') + .update(await readFile(new URL(import.meta.url))) + .digest('hex'), + fixtureSha256: createHash('sha256').update(JSON.stringify(data)).digest('hex'), + indexSha256: createHash('sha256') + .update(await readFile(new URL('index.html', build))) + .digest('hex'), + samples, + attempts: [], + }; + for (let sample = 1; sample <= count; sample++) { + await recordScalingTrial(progress, progressPath, sample, async () => { + await withFixture(async (page, fixture) => { + const parameters = page.getByRole('table', { + name: 'parameters comparison', + exact: true, + }); + const metrics = page.getByRole('table', { + name: 'scalar metrics artifacts comparison', + exact: true, + }); + await metrics.waitFor(); + assert.deepEqual(await parameters.locator('thead a').allTextContents(), [ + 'Beta run', + 'Alpha run', + ]); + assert.deepEqual( + await metrics + .getByRole('row') + .filter({ hasText: 'Evaluate / accuracy' }) + .getByRole('cell') + .allTextContents(), + ['—', '0'], + ); + assert.equal(fixture.requests.filter(({ path }) => path.endsWith('/tasks')).length, 2); + await page.evaluate(() => + document.fonts.ready.then( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ), + ); + const contentReadyMs = performance.now() - fixture.navigationStartedAt; + + const chartStarted = performance.now(); + await page.getByRole('tab', { name: 'Classification Metrics', exact: true }).click(); + const trigger = page.getByRole('combobox', { name: 'ROC curves', exact: true }); + await renderedCurves(page, 3); + const provenance = page.getByRole('list', { + name: 'Selected ROC curve provenance', + exact: true, + }); + const original = await provenance.getByRole('listitem').allTextContents(); + assert.equal(original.length, 3); + await page.evaluate( + () => + new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))), + ); + const chartReadyMs = performance.now() - chartStarted; + await trigger.click(); + const options = page.getByRole('listbox', { name: 'ROC curves', exact: true }); + await options.waitFor(); + assert.equal(await options.getByRole('option').count(), 100); + await page.keyboard.press('Escape'); + await page.getByRole('button', { name: 'Next ROC curves', exact: true }).click(); + await trigger.click(); + await options.waitFor(); + assert.equal(await options.getByRole('option').count(), 11); + const last = options.getByRole('option').last(); + const lastName = (await last.textContent()).trim(); + const selectStarted = performance.now(); + await last.click(); + await renderedCurves(page, 4); + const selected = await provenance.getByRole('listitem').allTextContents(); + assert.ok(original.every((name) => selected.includes(name))); + assert.ok(selected.some((name) => name.trim() === lastName)); + await page.evaluate( + () => + new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))), + ); + const addCurveReadyMs = performance.now() - selectStarted; + await page.keyboard.press('Escape'); + const chart = page.locator('.kfp-roc-section .recharts-wrapper'); + const compactHeight = await chart.evaluate( + (element) => element.getBoundingClientRect().height, + ); + const expandStarted = performance.now(); + await page.getByRole('button', { name: 'Expand ROC chart', exact: true }).click(); + await page.getByRole('button', { name: 'Compact ROC chart', exact: true }).waitFor(); + await page.waitForFunction( + (before) => + document.querySelector('.kfp-roc-section .recharts-wrapper').getBoundingClientRect() + .height > before, + compactHeight, + ); + await renderedCurves(page, 4); + await page.evaluate( + () => + new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))), + ); + const expandChartReadyMs = performance.now() - expandStarted; + samples.push({ + sample, + contentReadyMs, + chartReadyMs, + addCurveReadyMs, + expandChartReadyMs, + }); + for (const { path } of fixture.requests) + if (path.startsWith('/static/')) assetPaths.add(path); + }); + }); + } + const assets = await Promise.all( + [...assetPaths].sort().map(async (path) => { + const bytes = await readFile(new URL(path.slice(1), build)); + return { + path, + bytes: bytes.length, + sha256: createHash('sha256').update(bytes).digest('hex'), + }; + }), + ); + const report = { + applicationSource: process.env.KFP_SOURCE_COMMIT, + harnessSha256: createHash('sha256') + .update(await readFile(new URL(import.meta.url))) + .digest('hex'), + indexSha256: createHash('sha256') + .update(await readFile(new URL('index.html', build))) + .digest('hex'), + fixtureSha256: createHash('sha256').update(JSON.stringify(data)).digest('hex'), + browser: { + engine: process.env.KFP_BROWSER || 'chromium', + version: browser.version(), + channel: process.env.KFP_BROWSER_EXECUTABLE_PATH + ? null + : process.env.PLAYWRIGHT_CHANNEL || null, + }, + runtime: { node: process.version, platform: process.platform, arch: process.arch }, + settings: { + viewport: { width: 1440, height: 1000 }, + deviceScaleFactor: 1, + locale: 'en-US', + timezoneId: 'UTC', + theme: 'light', + reducedMotion: 'reduce', + cpuThrottle: 'none', + networkThrottle: 'none', + }, + method: + 'Fresh context per sample in one warm browser process; local route fixtures and file/OS caches stay warm. Automation timings include Playwright overhead. Initial readiness requires populated parameter/scalar tables and fonts; chart and selection readiness require actual curve paths/provenance; expansion requires increased rendered chart height. Each endpoint also waits two frames. No field INP, backend load, or baseline speedup claim.', + fixture: { + runs: 2, + classificationArtifacts: 111, + initialVisibleCurves: 3, + selectedVisibleCurves: 4, + pageSizes: [100, 11], + }, + assets, + samples, + }; + await mkdir(process.env.KFP_SCALING_OUTPUT_DIR, { recursive: true }); + await writeFile( + join( + process.env.KFP_SCALING_OUTPUT_DIR, + `comparison-${process.env.KFP_BROWSER || 'chromium'}.json`, + ), + `${JSON.stringify(report, null, 2)}\n`, + ); + }); +} diff --git a/frontend/scripts/ui-modernization-graph.smoke.mjs b/frontend/scripts/ui-modernization-graph.smoke.mjs new file mode 100644 index 00000000000..e4b6bb789f6 --- /dev/null +++ b/frontend/scripts/ui-modernization-graph.smoke.mjs @@ -0,0 +1,850 @@ +/* + * Copyright 2026 The Kubeflow Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// npm run build && node --test scripts/ui-modernization-graph.smoke.mjs +// KFP_GRAPH_CAPTURE_DIR retains geometry, screenshots and timing samples for same-machine review. +// KFP_GRAPH_REFERENCE_DIR compares identities/connectivity to an earlier capture, not intentional sizing. +// Optional candidate-only timings: KFP_SCALING_SAMPLES=3 KFP_SCALING_OUTPUT_DIR=/tmp/... +// KFP_SOURCE_COMMIT= node --test --test-name-pattern="candidate-only scaling" +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { after, before, test } from 'node:test'; +import { launchBrowser } from './browser-launch.mjs'; +import { transform } from 'esbuild'; +import { recordScalingTrial } from './performance-evidence.mjs'; + +const origin = 'http://kfp.test'; +if (process.env.KFP_PERFORMANCE_BUILD_DIR) { + assert.equal(process.env.CI, 'true'); + assert.equal(process.env.RUNNER_ENVIRONMENT, 'github-hosted'); +} +const build = process.env.KFP_PERFORMANCE_BUILD_DIR + ? pathToFileURL(resolve(process.env.KFP_PERFORMANCE_BUILD_DIR) + '/') + : new URL('../build/', import.meta.url); +const runId = 'graph-fixture'; +const createdAt = '2026-09-26T12:00:00.000Z'; +const finishedAt = '2026-09-26T12:01:00.000Z'; +let browser; +before(async () => { + browser = await launchBrowser(); + if (process.env.KFP_EXPECTED_BROWSER_VERSION) { + assert.equal( + browser.version(), + process.env.KFP_EXPECTED_BROWSER_VERSION, + 'Browser version must match KFP_EXPECTED_BROWSER_VERSION; select the intended browser binary', + ); + } +}); +after(async () => browser?.close()); + +async function reviewParallelFixture() { + const { code } = await transform( + await readFile(new URL('../mock-backend/complex-run-samples.ts', import.meta.url), 'utf8'), + { loader: 'ts', format: 'esm' }, + ); + const samples = await import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`); + const sample = samples.complexRunSamples.find((entry) => entry.id === 'parallel-folds'); + return { + spec: samples.complexPipelineSpec(sample), + tasks: samples.complexRunTasks({ run_id: 'sample-run-parallel-folds', created_at: new Date(createdAt) }), + tasksDelayMs: 700, + }; +} + +function runtimeTask(id, name, parent, type = 'RUNTIME', extra = {}) { + return { + task_id: id, + run_id: runId, + name, + display_name: name, + parent_task_id: parent, + type, + state: 'SUCCEEDED', + create_time: createdAt, + end_time: finishedAt, + state_history: [{ state: 'SUCCEEDED', update_time: finishedAt }], + ...extra, + }; +} +function leaf(name, component = 'leaf') { + return { taskInfo: { name }, componentRef: { name: component } }; +} +function largeFixture() { + const graphTasks = {}; + const tasks = [runtimeTask('root-task', 'root', undefined, 'ROOT')]; + const expectedEdges = []; + const name = (i) => `step-${String(i).padStart(3, '0')}`; + for (let i = 0; i < 200; i++) { + const dependencies = + i < 10 ? [] : [...new Set([name(i - 10), name(Math.floor(i / 10) * 10 - 10)])]; + graphTasks[name(i)] = { + ...leaf(name(i), i === 0 ? 'producer' : 'leaf'), + dependentTasks: dependencies, + }; + expectedEdges.push( + ...dependencies.map((dependency) => ({ + id: `paramedge.${dependency}.${name(i)}`, + connection: `Edge from task.${dependency} to task.${name(i)}`, + })), + ); + tasks.push(runtimeTask(`runtime-${i}`, name(i), 'root-task')); + } + graphTasks[name(10)].inputs = { + artifacts: { + dataset: { taskOutputArtifact: { producerTask: name(0), outputArtifactKey: 'dataset' } }, + }, + }; + expectedEdges.push( + { + id: `outedge.${name(0)}.dataset`, + connection: `Edge from task.${name(0)} to artifact.${name(0)}.dataset`, + }, + { + id: `inedge.dataset.${name(10)}`, + connection: `Edge from artifact.${name(0)}.dataset to task.${name(10)}`, + }, + ); + return { + spec: { + pipelineInfo: { name: 'large-200-task-browser-fixture' }, + root: { dag: { tasks: graphTasks } }, + components: { + leaf: { executorLabel: 'executor' }, + producer: { + executorLabel: 'executor', + outputDefinitions: { + artifacts: { + dataset: { artifactType: { schemaTitle: 'system.Dataset', schemaVersion: '0.0.1' } }, + }, + }, + }, + }, + deploymentSpec: { + executors: { + executor: { container: { image: 'fixture.invalid/image', command: ['true'] } }, + }, + }, + }, + tasks, + expectedNodes: [ + ...Object.keys(graphTasks).map((key) => `task.${key}`), + 'artifact.step-000.dataset', + ].sort(), + expectedEdges: expectedEdges.sort((a, b) => a.id.localeCompare(b.id)), + }; +} +function nestedFixture() { + const tasks = [ + runtimeTask('root-task', 'root', undefined, 'ROOT'), + runtimeTask('group-task', 'group', 'root-task', 'DAG', { scope_path: 'root.group' }), + runtimeTask('loop-task', 'loop', 'group-task', 'LOOP', { + scope_path: 'root.group.loop', + type_attributes: { iteration_count: '2' }, + }), + ]; + for (const iteration of [0, 1]) { + tasks.push( + runtimeTask(`body-${iteration}`, 'body', 'loop-task', 'DAG', { + scope_path: 'root.group.loop.body', + type_attributes: { iteration_index: String(iteration) }, + }), + ); + for (const name of ['train', 'validate']) + tasks.push( + runtimeTask(`${name}-${iteration}`, name, `body-${iteration}`, 'RUNTIME', { + scope_path: `root.group.loop.body.${name}`, + type_attributes: { iteration_index: String(iteration) }, + }), + ); + } + return { + spec: { + pipelineInfo: { name: 'nested-loop-browser-fixture' }, + root: { dag: { tasks: { group: leaf('group', 'group') } } }, + components: { + group: { dag: { tasks: { loop: leaf('loop', 'loop') } } }, + loop: { dag: { tasks: { body: leaf('body', 'body') } } }, + body: { + dag: { + tasks: { + train: leaf('train'), + validate: { ...leaf('validate'), dependentTasks: ['train'] }, + }, + }, + }, + leaf: { executorLabel: 'executor' }, + }, + deploymentSpec: { + executors: { + executor: { container: { image: 'fixture.invalid/image', command: ['true'] } }, + }, + }, + }, + tasks, + }; +} + +async function withFixture(data, exercise) { + const context = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + locale: 'en-US', + timezoneId: 'UTC', + reducedMotion: 'reduce', + }); + const page = await context.newPage(); + page.setDefaultTimeout(15000); + const requests = [], + errors = []; + await context.addInitScript(() => localStorage.setItem('kfp.theme', 'light')); + page.on('pageerror', (error) => errors.push(error.message)); + await context.route('**/*', async (route) => { + const request = route.request(), + url = new URL(request.url()), + path = url.pathname; + const json = (value, status = 200) => + route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(value) }); + try { + assert.equal(url.origin, origin, 'graph fixture must not contact external services'); + assert.ok(requests.length < 200, 'graph fixture requests remain bounded'); + requests.push({ + path, + query: Object.fromEntries(url.searchParams), + method: request.method(), + }); + assert.equal(request.method(), 'GET', 'graph inspection must not mutate data'); + if (path === '/' || path.startsWith('/static/')) { + const name = path === '/' ? 'index.html' : path.slice(1); + return route.fulfill({ + body: await readFile(new URL(name, build)), + contentType: name.endsWith('.js') + ? 'text/javascript' + : name.endsWith('.css') + ? 'text/css' + : name.endsWith('.html') + ? 'text/html' + : 'application/octet-stream', + }); + } + if (path === '/apis/v2/healthz') return json({ apiServerTagName: 'fixture' }); + if (path === '/system/cluster-name' || path === '/system/project-id') + return route.fulfill({ body: '' }); + if (path === `/apis/v2/runs/${runId}`) + return json({ + run_id: runId, + display_name: data.spec.pipelineInfo.name, + pipeline_spec: data.spec, + state: 'SUCCEEDED', + storage_state: 'AVAILABLE', + created_at: createdAt, + finished_at: finishedAt, + }); + if (path === `/apis/v2/runs/${runId}/tasks`) { + if (data.waitForTasks) await data.waitForTasks(); + if (data.tasksDelayMs) await new Promise((resolve) => setTimeout(resolve, data.tasksDelayMs)); + assert.equal(url.searchParams.get('page_size'), '200'); + assert.equal(url.searchParams.get('order_by'), 'create_time asc'); + const token = url.searchParams.get('page_token') || ''; + assert.ok(['', 'remaining-tasks'].includes(token), 'unexpected runtime task page token'); + return json( + token + ? { tasks: data.tasks.slice(200) } + : { + tasks: data.tasks.slice(0, 200), + ...(data.tasks.length > 200 ? { next_page_token: 'remaining-tasks' } : {}), + }, + ); + } + throw new Error(`Unexpected graph request ${path}`); + } catch (error) { + errors.push(error.message); + await json({ message: error.message }, 500); + } + }); + try { + await exercise(page, requests); + assert.deepEqual( + errors, + [], + 'production graph and fixture contracts have no unexpected errors', + ); + } catch (error) { + if (errors.length) console.error('Graph fixture errors:', errors); + throw error; + } finally { + await context.close(); + } +} + +async function geometry(page, expectedCount) { + await page.getByTestId('DagCanvas').waitFor(); + return page.evaluate(async (count) => { + await document.fonts.ready; + const measure = () => ({ + nodes: [...document.querySelectorAll('.react-flow__node')] + .map((element) => { + const matrix = new DOMMatrixReadOnly(element.style.transform); + return { + id: element.dataset.id, + type: [...element.classList].find((name) => + /^react-flow__node-(EXECUTION|ARTIFACT|SUB_DAG|subDagGroup)$/.test(name), + ), + x: matrix.m41, + y: matrix.m42, + width: element.offsetWidth, + height: element.offsetHeight, + }; + }) + .sort((a, b) => a.id.localeCompare(b.id)), + edges: [...document.querySelectorAll('.react-flow__edge')] + .map((element) => ({ + id: element.dataset.id, + connection: element.getAttribute('aria-label'), + path: element.querySelector('.react-flow__edge-path')?.getAttribute('d') || '', + })) + .sort((a, b) => a.id.localeCompare(b.id)), + }); + let previous, + stable = 0; + for (let frame = 0; frame < 180; frame++) { + await new Promise(requestAnimationFrame); + const current = measure(), + key = JSON.stringify(current); + stable = key === previous ? stable + 1 : 0; + previous = key; + if ( + current.nodes.length === count && + current.nodes.every((node) => node.width > 0 && node.height > 0) && + current.edges.every((edge) => edge.path) && + stable >= 3 + ) + return current; + } + throw new Error(`Graph did not settle at ${count} nodes: ${JSON.stringify(measure().nodes.map((node) => node.id))}`); + }, expectedCount); +} +function sameGeometry(a, b) { + assert.deepEqual( + a.nodes.map(({ id, type }) => ({ id, type })), + b.nodes.map(({ id, type }) => ({ id, type })), + ); + assert.deepEqual( + a.edges.map(({ id, connection }) => ({ id, connection })), + b.edges.map(({ id, connection }) => ({ id, connection })), + ); + for (let i = 0; i < a.nodes.length; i++) + for (const field of ['x', 'y', 'width', 'height']) + assert.ok( + Math.abs(a.nodes[i][field] - b.nodes[i][field]) < 0.05, + `${a.nodes[i].id} ${field} changed`, + ); + for (let i = 0; i < a.edges.length; i++) { + const numbers = (path) => path.match(/-?\d+(?:\.\d+)?(?:e[-+]?\d+)?/gi).map(Number); + const before = numbers(a.edges[i].path), + after = numbers(b.edges[i].path); + assert.equal(before.length, after.length); + assert.ok( + before.every((n, j) => Math.abs(n - after[j]) < 0.05), + `${a.edges[i].id} geometry changed`, + ); + } +} +async function graphControlTargets(page) { + const targets = await page.evaluate(() => ({ + attributionCount: document.querySelectorAll('.react-flow__attribution').length, + controls: [...document.querySelectorAll('.react-flow__controls-button')].map((button) => ({ + label: button.getAttribute('aria-label'), + box: button.getBoundingClientRect().toJSON(), + })), + })); + assert.equal(targets.attributionCount, 0, 'attribution is covered by shared third-party notices'); + assert.deepEqual( + targets.controls.map(({ label }) => label), + ['Zoom in', 'Zoom out', 'Fit view', 'Unlock node positions'], + 'flat graphs do not expose inapplicable group controls', + ); + for (const { label, box } of targets.controls) { + assert.ok(box.height >= 30 && box.width >= 30, `${label} retains its 30px target`); + } +} + +async function capture(page, name, graph, timings) { + if (process.env.KFP_GRAPH_REFERENCE_DIR) { + const reference = JSON.parse( + await readFile(join(process.env.KFP_GRAPH_REFERENCE_DIR, `${name}.json`), 'utf8'), + ); + assert.deepEqual( + graph.nodes.map(({ id, type }) => ({ id, type })), + reference.graph.nodes.map(({ id, type }) => ({ id, type })), + 'redesign preserves node identities and types', + ); + assert.deepEqual( + graph.edges.map(({ id, connection }) => ({ id, connection })), + reference.graph.edges.map(({ id, connection }) => ({ id, connection })), + 'redesign preserves graph connectivity', + ); + } + if (!process.env.KFP_GRAPH_CAPTURE_DIR) return; + const index = await readFile(new URL('index.html', build), 'utf8'); + const script = index.match(/src="([^"]+\.js)"/)?.[1]; + assert.ok(script, 'production entry script must be identifiable'); + const entry = await readFile(new URL(script.replace(/^\//, ''), build)); + await mkdir(process.env.KFP_GRAPH_CAPTURE_DIR, { recursive: true }); + await writeFile( + join(process.env.KFP_GRAPH_CAPTURE_DIR, `${name}.json`), + `${JSON.stringify({ browser: browser.version(), viewport: page.viewportSize(), entryScript: script, entrySha256: createHash('sha256').update(entry).digest('hex'), timings, graph }, null, 2)}\n`, + ); + await page.screenshot({ + path: join(process.env.KFP_GRAPH_CAPTURE_DIR, `${name}.png`), + animations: 'disabled', + }); +} +async function selectTask(page, taskId, nodeId) { + await page.evaluate((id) => { + const url = new URL(location.href), + query = new URLSearchParams(url.hash.split('?')[1]); + query.set('task', id); + location.hash = `${url.hash.split('?')[0]}?${query}`; + }, taskId); + await page.locator(`.react-flow__node[data-id="${nodeId}"].selected`).waitFor(); +} +async function viewport(page) { + return page.locator('.react-flow__viewport').evaluate((element) => { + const matrix = new DOMMatrixReadOnly(element.style.transform); + return { x: matrix.m41, y: matrix.m42, zoom: matrix.a }; + }); +} +async function changedViewport(page, previous) { + await page.waitForFunction((before) => { + const matrix = new DOMMatrixReadOnly( + document.querySelector('.react-flow__viewport').style.transform, + ); + return ( + Math.abs(matrix.m41 - before.x) > 1 || + Math.abs(matrix.m42 - before.y) > 1 || + Math.abs(matrix.a - before.zoom) > 0.01 + ); + }, previous); +} + +async function settledViewport(page) { + await page.evaluate(async () => { + let previous, + stable = 0; + for (let frame = 0; frame < 90; frame++) { + await new Promise(requestAnimationFrame); + const transform = document.querySelector('.react-flow__viewport').style.transform; + stable = transform === previous ? stable + 1 : 0; + previous = transform; + if (stable >= 3) return; + } + throw new Error('Viewport animation did not settle'); + }); +} + +test('200-task graph preserves full paginated data, geometry and zoom/pan/selection', async () => { + const data = largeFixture(); + await withFixture(data, async (page, requests) => { + const started = performance.now(); + await page.goto(`${origin}/#/runs/details/${runId}`); + const original = await geometry(page, 201); + // These automation timings end at the first observed change, not animation completion. + const timings = { + graphReadyMs: performance.now() - started, + selectMs: [], + zoomMs: [], + panMs: [], + }; + assert.deepEqual( + original.nodes.map(({ id }) => id), + data.expectedNodes, + ); + assert.ok( + original.nodes.every(({ width, height }) => width === 200 && height === 56), + 'leaf and artifact boxes must match the explicit200×56 layout dimensions', + ); + assert.deepEqual( + original.edges.map(({ id, connection }) => ({ id, connection })), + data.expectedEdges + .map(({ connection }) => { + const [, source, target] = /^Edge from (.+) to (.+)$/.exec(connection); + return { id: JSON.stringify(['root', source, target]), connection }; + }) + .sort((a, b) => a.id.localeCompare(b.id)), + ); + assert.deepEqual( + requests + .filter(({ path }) => path.endsWith('/tasks')) + .map(({ query }) => query.page_token || ''), + ['', 'remaining-tasks'], + ); + sameGeometry(original, await geometry(page, 201)); + await graphControlTargets(page); + await capture(page, 'large-graph-initial', original, timings); + for (const i of [199, 101, 0]) { + const start = performance.now(); + await selectTask(page, `runtime-${i}`, `task.step-${String(i).padStart(3, '0')}`); + timings.selectMs.push(performance.now() - start); + sameGeometry(original, await geometry(page, 201)); + } + await page.getByRole('button', { name: 'close', exact: true }).click(); + const inspect = page.getByRole('button', { name: 'step-000', exact: true }); + await inspect.focus(); + await inspect.press('Enter'); + await page.locator('.react-flow__node[data-id="task.step-000"].selected').waitFor(); + await page.getByRole('tab', { name: 'Task Details', exact: true }).click(); + await page.getByText('runtime-0', { exact: true }).waitFor(); + assert.equal( + new URLSearchParams(new URL(page.url()).hash.split('?')[1]).get('task'), + null, + 'direct node inspection clears the previous linked-task query', + ); + await page.getByRole('button', { name: 'close', exact: true }).click(); + for (let i = 0; i < 3; i++) { + const previous = await viewport(page), + start = performance.now(); + await page.getByRole('button', { name: 'Zoom out', exact: true }).click(); + await changedViewport(page, previous); + timings.zoomMs.push(performance.now() - start); + } + for (let i = 0; i < 3; i++) { + const point = await page.locator('.react-flow__pane').evaluate((pane) => { + const box = pane.getBoundingClientRect(); + for (const vertical of [0.15, 0.85, 0.5]) { + for (const horizontal of [0.25, 0.5, 0.75]) { + const x = box.x + box.width * horizontal; + const y = box.y + box.height * vertical; + if ( + document.elementFromPoint(x, y) === pane && + document.elementFromPoint(x + 80, y + 30) === pane + ) + return { x, y }; + } + } + return null; + }); + assert.ok(point, 'pan starts on exposed canvas, away from graph nodes and overlays'); + const previous = await viewport(page), + start = performance.now(); + await page.mouse.move(point.x, point.y); + await page.mouse.down(); + await page.mouse.move(point.x + 80, point.y + 30, { steps: 6 }); + await page.mouse.up(); + await changedViewport(page, previous); + timings.panMs.push(performance.now() - start); + } + sameGeometry(original, await geometry(page, 201)); + await page.getByRole('button', { name: 'Fit view', exact: true }).click(); + await page.getByRole('switch', { name: 'Dark mode', exact: true }).setChecked(true); + await page.waitForFunction(() => { + const shell = document.querySelector('.kfp-shell'); + const brand = document.querySelector('.kfp-shell-brand-title'); + const node = document.querySelector('.kfp-graph-node'); + return ( + getComputedStyle(shell).backgroundColor === 'rgb(14, 16, 21)' && + getComputedStyle(brand).color === 'rgb(236, 238, 244)' && + getComputedStyle(node).backgroundColor === 'rgb(22, 25, 33)' + ); + }); + await settledViewport(page); + assert.equal( + await page + .locator('.kfp-graph-node') + .first() + .evaluate((node) => getComputedStyle(node).backgroundColor), + 'rgb(22, 25, 33)', + ); + sameGeometry(original, await geometry(page, 201)); + await graphControlTargets(page); + await capture(page, 'large-graph-dark', original, timings); + await page.reload(); + sameGeometry(original, await geometry(page, 201)); + assert.deepEqual( + requests + .filter(({ path }) => path.endsWith('/tasks')) + .map(({ query }) => query.page_token || ''), + ['', 'remaining-tasks', '', 'remaining-tasks'], + ); + }); +}); + +test('nested loop links preserve iteration identity within expanded inline groups', async () => { + await withFixture(nestedFixture(), async (page, requests) => { + const scoped = (iteration, task) => + JSON.stringify(['root', 'group', 'loop', `loop.${iteration}`, 'body', `task.${task}`]); + const node = (id) => page.locator(`.react-flow__node[data-id=${JSON.stringify(id)}]`); + await page.goto(`${origin}/#/runs/details/${runId}?task=train-1&view=graph`); + await node(scoped(1, 'train')).and(page.locator('.selected')).waitFor(); + await page.getByRole('tab', { name: 'Task Details', exact: true }).click(); + await page.getByText('train-1', { exact: true }).waitFor(); + const original = await geometry(page, 10); + for (const iteration of [0, 1]) { + for (const task of ['train', 'validate']) + assert.ok(original.nodes.some(({ id }) => id === scoped(iteration, task))); + } + await capture(page, 'nested-loop-body', original, {}); + await page.evaluate(() => { + location.hash = location.hash.replace('train-1', 'train-0'); + }); + await node(scoped(0, 'train')).and(page.locator('.selected')).waitFor(); + await page.getByText('train-0', { exact: true }).waitFor(); + sameGeometry(original, await geometry(page, 10)); + await page.goBack(); + await page.getByText('train-1', { exact: true }).waitFor(); + await page.getByRole('button', { name: 'close', exact: true }).click(); + const iteration = node(JSON.stringify(['root', 'group', 'loop', 'task.loop.1'])); + const collapse = iteration.getByRole('button', { name: /^Collapse/ }); + await collapse.focus(); + await collapse.press('Enter'); + assert.equal(await node(scoped(1, 'train')).count(), 0); + assert.equal(await node(scoped(0, 'train')).count(), 1); + const expand = iteration.getByRole('button', { name: /^Expand/ }); + await expand.focus(); + assert.equal(await expand.evaluate((element) => element === document.activeElement), true); + await expand.press('Enter'); + sameGeometry(original, await geometry(page, 10)); + await page.getByRole('button', { name: 'Collapse all', exact: true }).click(); + assert.deepEqual( + (await geometry(page, 1)).nodes.map(({ id }) => id), + ['task.group'], + ); + await page.getByRole('button', { name: 'Expand all', exact: true }).click(); + sameGeometry(original, await geometry(page, 10)); + assert.equal(requests.filter(({ path }) => path.endsWith('/tasks')).length, 1); + }); +}); + +// These repeated laboratory samples are automation readiness timings, not field INP or a baseline comparison. +if (process.env.KFP_SCALING_SAMPLES) { + test('candidate-only scaling: 200 tasks and 363 edges in fresh contexts', async () => { + const count = Number(process.env.KFP_SCALING_SAMPLES); + assert.ok(Number.isInteger(count) && count >= 3 && count <= 20, 'use 3–20 scaling samples'); + assert.match( + process.env.KFP_SOURCE_COMMIT || '', + /^[a-f0-9]{40}$/, + 'identify the built source', + ); + assert.ok(process.env.KFP_SCALING_OUTPUT_DIR, 'provide a directory for retained raw samples'); + const data = largeFixture(); + assert.equal(data.tasks.length, 201, '200 executable tasks plus the structural root'); + assert.equal(data.expectedNodes.length, 201, '200 task nodes plus one artifact'); + assert.equal(data.expectedEdges.length, 363); + const samples = []; + const assetPaths = new Set(); + await mkdir(process.env.KFP_SCALING_OUTPUT_DIR, { recursive: true }); + const progressPath = join( + process.env.KFP_SCALING_OUTPUT_DIR, + 'graph-' + (process.env.KFP_BROWSER || 'chromium') + '-progress.json', + ); + const progress = { + status: 'running', + expectedSamples: count, + applicationSource: process.env.KFP_SOURCE_COMMIT, + browserVersion: browser.version(), + harnessSha256: createHash('sha256') + .update(await readFile(new URL(import.meta.url))) + .digest('hex'), + fixtureSha256: createHash('sha256').update(JSON.stringify(data)).digest('hex'), + indexSha256: createHash('sha256') + .update(await readFile(new URL('index.html', build))) + .digest('hex'), + samples, + attempts: [], + }; + for (let sample = 1; sample <= count; sample++) { + await recordScalingTrial(progress, progressPath, sample, async () => { + await withFixture(data, async (page, requests) => { + const started = performance.now(); + await page.goto(`${origin}/#/runs/details/${runId}`); + const initial = await geometry(page, 201); + assert.deepEqual( + initial.nodes.map(({ id }) => id), + data.expectedNodes, + ); + assert.deepEqual( + initial.edges.map(({ id, connection }) => ({ id, connection })), + data.expectedEdges + .map(({ connection }) => { + const [, source, target] = /^Edge from (.+) to (.+)$/.exec(connection); + return { id: JSON.stringify(['root', source, target]), connection }; + }) + .sort((a, b) => a.id.localeCompare(b.id)), + ); + assert.ok(initial.nodes.every(({ width, height }) => width === 200 && height === 56)); + const graphReadyMs = performance.now() - started; + assert.deepEqual( + requests + .filter(({ path }) => path.endsWith('/tasks')) + .map(({ query }) => query.page_token || ''), + ['', 'remaining-tasks'], + ); + + // Native keyboard activation exercises the actual rendered task button and inspector. + const task = page.getByRole('button', { name: 'step-000', exact: true }); + await task.focus(); + const selectStarted = performance.now(); + await task.press('Enter'); + await page.locator('.react-flow__node[data-id="task.step-000"].selected').waitFor(); + await page.getByRole('dialog', { name: 'step-000', exact: true }).waitFor(); + await page.getByRole('tab', { name: 'Task Details', exact: true }).waitFor(); + await page.evaluate( + () => + new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))), + ); + const selectInspectorReadyMs = performance.now() - selectStarted; + await page.getByRole('button', { name: 'close', exact: true }).click(); + await page.getByRole('dialog').waitFor({ state: 'hidden' }); + sameGeometry(initial, await geometry(page, 201)); + + // The large graph starts at minimum zoom; zoom in so Fit View must update it. + const beforeZoom = await viewport(page); + await page.getByRole('button', { name: 'Zoom In', exact: true }).click(); + await changedViewport(page, beforeZoom); + await settledViewport(page); + const beforeFit = await viewport(page); + const fitStarted = performance.now(); + await page.getByRole('button', { name: 'Fit view', exact: true }).click(); + await changedViewport(page, beforeFit); + await settledViewport(page); + const fitSettledMs = performance.now() - fitStarted; + sameGeometry(initial, await geometry(page, 201)); + samples.push({ sample, graphReadyMs, selectInspectorReadyMs, fitSettledMs }); + for (const { path } of requests) if (path.startsWith('/static/')) assetPaths.add(path); + }); + }); + } + const assets = await Promise.all( + [...assetPaths].sort().map(async (path) => { + const bytes = await readFile(new URL(path.slice(1), build)); + return { + path, + bytes: bytes.length, + sha256: createHash('sha256').update(bytes).digest('hex'), + }; + }), + ); + const report = { + applicationSource: process.env.KFP_SOURCE_COMMIT, + harnessSha256: createHash('sha256') + .update(await readFile(new URL(import.meta.url))) + .digest('hex'), + indexSha256: createHash('sha256') + .update(await readFile(new URL('index.html', build))) + .digest('hex'), + fixtureSha256: createHash('sha256').update(JSON.stringify(data)).digest('hex'), + browser: { + engine: process.env.KFP_BROWSER || 'chromium', + version: browser.version(), + channel: process.env.KFP_BROWSER_EXECUTABLE_PATH + ? null + : process.env.PLAYWRIGHT_CHANNEL || null, + }, + runtime: { node: process.version, platform: process.platform, arch: process.arch }, + settings: { + viewport: { width: 1440, height: 900 }, + deviceScaleFactor: 1, + locale: 'en-US', + timezoneId: 'UTC', + theme: 'light', + reducedMotion: 'reduce', + cpuThrottle: 'none', + networkThrottle: 'none', + }, + method: + 'Fresh context per sample in one warm browser process; local route fixtures and file/OS caches stay warm. Automation timings include Playwright overhead. Graph readiness includes complete paginated data and stable geometry/fonts; selection ends at the real inspector controls plus two frames; Fit View ends at a changed viewport stable for three frames. No field INP, backend load, or baseline speedup claim.', + fixture: { + executableTasks: 200, + runtimeRecords: 201, + renderedNodes: 201, + edges: 363, + nodeWidth: 200, + nodeHeight: 56, + }, + assets, + samples, + }; + await mkdir(process.env.KFP_SCALING_OUTPUT_DIR, { recursive: true }); + await writeFile( + join( + process.env.KFP_SCALING_OUTPUT_DIR, + `graph-${process.env.KFP_BROWSER || 'chromium'}.json`, + ), + `${JSON.stringify(report, null, 2)}\n`, + ); + }); +} + +async function assertGraphFits(page) { + await page.waitForFunction(() => { + const canvas = document.querySelector('.react-flow'); + const viewport = document.querySelector('.react-flow__viewport'); + if (!canvas || !viewport) return false; + const bounds = canvas.getBoundingClientRect(); + const zoom = new DOMMatrixReadOnly(getComputedStyle(viewport).transform).a; + return zoom <= 1 && [...document.querySelectorAll('.react-flow__node')].every((node) => { + const box = node.getBoundingClientRect(); + return box.left >= bounds.left && box.right <= bounds.right && + box.top >= bounds.top && box.bottom <= bounds.bottom; + }); + }, undefined, { timeout: 3000 }); +} + +test('initial graph fit contains every node of a large DAG after direct load and reload', async () => { + await withFixture(largeFixture(), async (page) => { + await page.goto(`${origin}/#/runs/details/${runId}`); + for (let load = 0; load < 2; load++) { + if (load) await page.reload(); + await geometry(page, 201); + await assertGraphFits(page); + } + }); +}); + +test('initial graph fit follows late runtime loop iterations after load and reload', async () => { + await withFixture(await reviewParallelFixture(), async (page) => { + await page.goto(`${origin}/#/runs/details/${runId}`); + for (let load = 0; load < 2; load++) { + if (load) await page.reload(); + await geometry(page, 15); + await assertGraphFits(page); + } + }); +}); + +test('late runtime loop layout preserves manual zoom and pan', async () => { + let releaseTasks; + const tasksReady = new Promise((resolve) => { releaseTasks = resolve; }); + const fixture = { ...await reviewParallelFixture(), tasksDelayMs: 0, waitForTasks: () => tasksReady }; + await withFixture(fixture, async (page) => { + try { + await page.goto(`${origin}/#/runs/details/${runId}`); + await page.locator('.react-flow__node').first().waitFor(); + await settledViewport(page); + await page.getByRole('button', { name: 'Zoom out', exact: true }).click(); + await page.getByRole('region', { name: 'Pipeline graph' }).press('ArrowRight'); + await settledViewport(page); + const before = await viewport(page); + releaseTasks(); + await geometry(page, 15); + await settledViewport(page); + assert.deepEqual(await viewport(page), before); + } finally { + releaseTasks(); + } + }); +}); diff --git a/frontend/scripts/ui-modernization-pipelines.smoke.mjs b/frontend/scripts/ui-modernization-pipelines.smoke.mjs new file mode 100644 index 00000000000..51ba3d080e8 --- /dev/null +++ b/frontend/scripts/ui-modernization-pipelines.smoke.mjs @@ -0,0 +1,722 @@ +/* + * Copyright 2026 The Kubeflow Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Run after npm run build: node --test scripts/ui-modernization-pipelines.smoke.mjs +// Local bounded fixtures verify UI/request parity; live cluster authorization is separate. +import assert from 'node:assert/strict'; +import { mkdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { createServer } from 'node:http'; +import { before, after, test } from 'node:test'; +import { launchBrowser } from './browser-launch.mjs'; + +const build = new URL('../build/', import.meta.url); +const specialPipelineId = 'pipeline/01%fixture'; +const specialVersionId = 'version/01%fixture'; +const pipelineSpec = JSON.parse( + await readFile( + new URL( + '../mock-backend/data/v2/pipeline/lightweight_python_functions_v2_pipeline.json', + import.meta.url, + ), + 'utf8', + ), +); +const pipelinePath = (id, version) => + `/pipelines/details/${encodeURIComponent(id)}${version ? `/version/${encodeURIComponent(version)}` : ''}`; +let browser; +before(async () => { + browser = await launchBrowser(); + if (process.env.KFP_EXPECTED_BROWSER_VERSION) { + assert.equal( + browser.version(), + process.env.KFP_EXPECTED_BROWSER_VERSION, + 'Browser version must match KFP_EXPECTED_BROWSER_VERSION; select the intended browser binary', + ); + } +}); +after(async () => browser?.close()); + +function makePipelines() { + return Array.from({ length: 15 }, (_, index) => ({ + pipeline_id: index ? `pipeline-${index + 1}` : specialPipelineId, + name: `pipeline-${String(index + 1).padStart(2, '0')}`, + display_name: `Pipeline ${String(index + 1).padStart(2, '0')}`, + description: `Description ${index + 1}`, + created_at: new Date(Date.UTC(2026, 8, 26 - index)).toISOString(), + })); +} +function versions(pipelineId) { + return Array.from({ length: 12 }, (_, index) => ({ + pipeline_id: pipelineId, + pipeline_version_id: index ? `version-${index + 1}` : specialVersionId, + name: `version-${String(index + 1).padStart(2, '0')}`, + display_name: `Version ${String(index + 1).padStart(2, '0')}`, + description: `Version description ${index + 1}`, + created_at: new Date(Date.UTC(2026, 8, 26 - index)).toISOString(), + pipeline_spec: pipelineSpec, + })); +} +function listPage(resources, query, resource) { + const predicates = query.filter + ? JSON.parse(query.filter.startsWith('%') ? decodeURIComponent(query.filter) : query.filter) + .predicates + : []; + assert.ok( + predicates.every(({ key, operation }) => key === 'name' && operation === 'IS_SUBSTRING'), + ); + let rows = resources.filter((row) => + predicates.every(({ string_value }) => + row.display_name.toLowerCase().includes(string_value.toLowerCase()), + ), + ); + const [sort, direction = 'asc'] = (query.sort_by || 'created_at desc').split(' '); + assert.ok(['created_at', 'display_name', 'name'].includes(sort)); + assert.ok(['asc', 'desc'].includes(direction)); + rows.sort( + (a, b) => String(a[sort]).localeCompare(String(b[sort])) * (direction === 'desc' ? -1 : 1), + ); + const size = Number(query.page_size || 10); + assert.ok([1, 5, 10, 20, 50, 100].includes(size)); + const key = JSON.stringify({ + resource, + filter: query.filter || '', + sort: query.sort_by || '', + namespace: query.namespace || '', + size, + }); + const token = query.page_token + ? JSON.parse(Buffer.from(query.page_token, 'base64url').toString()) + : { key, offset: 0 }; + assert.equal( + token.key, + key, + 'filter/sort/namespace/page-size changes must reset the paging token', + ); + const end = token.offset + size; + return { + rows: rows.slice(token.offset, end), + next_page_token: + end < rows.length + ? Buffer.from(JSON.stringify({ key, offset: end })).toString('base64url') + : '', + }; +} + +async function withFixture(options, exercise) { + const context = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + locale: 'en-US', + timezoneId: 'UTC', + reducedMotion: 'reduce', + }); + const page = await context.newPage(); + page.setDefaultTimeout(10000); + const fixture = { + pipelines: makePipelines(), + versionMap: new Map(), + lists: [], + mutations: [], + reads: [], + errors: [], + requests: 0, + workers: [], + origin: undefined, + }; + for (const pipeline of fixture.pipelines) + fixture.versionMap.set(pipeline.pipeline_id, versions(pipeline.pipeline_id)); + + // WebKit's request.postData() omits file bytes. Let native uploads reach this + // same-origin loopback server, then validate their bytes before mutating the fixture. + const uploadResponses = new Map(); + let receivedUploads = 0; + const uploadServer = createServer(async (request, response) => { + const json = (value, status = 200) => { + response.writeHead(status, { 'content-type': 'application/json' }); + response.end(JSON.stringify(value)); + }; + try { + assert.ok(++receivedUploads <= 2, 'fixture uploads must remain bounded'); + assert.equal(request.method, 'POST'); + const createResource = uploadResponses.get(request.url); + assert.ok(createResource, 'only the exact routed upload path and query may reach the server'); + uploadResponses.delete(request.url); + const contentType = request.headers['content-type'] || ''; + const boundary = contentType.match(/multipart\/form-data; boundary=(?:"([^"]+)"|([^;]+))/); + assert.ok(boundary, 'native upload must retain its multipart boundary'); + const chunks = []; + let size = 0; + for await (const chunk of request) { + size += chunk.length; + assert.ok(size <= 1024 * 1024, 'fixture upload body must remain below 1 MiB'); + chunks.push(chunk); + } + const body = Buffer.concat(chunks); + const marker = boundary[1] || boundary[2]; + const start = Buffer.from(`--${marker}\r\n`); + const end = Buffer.from(`\r\n--${marker}--\r\n`); + assert.ok(body.subarray(0, start.length).equals(start)); + assert.ok(body.subarray(-end.length).equals(end)); + const headerEnd = body.indexOf('\r\n\r\n'); + assert.ok(headerEnd > start.length); + const headers = body.subarray(start.length, headerEnd).toString(); + assert.match( + headers, + /Content-Disposition: form-data; name="uploadfile"; filename="fixture\.yaml"/i, + ); + assert.match(headers, /Content-Type: application\/yaml/i); + assert.deepEqual( + body.subarray(headerEnd + 4, -end.length), + Buffer.from(JSON.stringify(pipelineSpec)), + 'the actual upload must contain the exact selected package bytes', + ); + json(createResource()); + } catch (error) { + fixture.errors.push(error.message); + json({ message: error.message }, 500); + } + }); + await new Promise((resolve, reject) => { + uploadServer.once('error', reject); + uploadServer.listen(0, '127.0.0.1', resolve); + }); + fixture.origin = `http://127.0.0.1:${uploadServer.address().port}`; + + async function uploadResponse(route, createResource) { + const original = new URL(route.request().url()); + assert.ok( + ['/apis/v2/pipelines/upload', '/apis/v2/pipelines/upload_version'].includes( + original.pathname, + ), + ); + const key = `${original.pathname}${original.search}`; + assert.equal(uploadResponses.has(key), false, 'each upload may be routed only once'); + uploadResponses.set(key, createResource); + await route.continue(); + } + page.on('pageerror', (error) => fixture.errors.push(error.message)); + await context.addInitScript(({ namespace }) => { + localStorage.setItem('kfp.theme', 'light'); + if (namespace) + window.centraldashboard = { + CentralDashboardEventHandler: { + init(callback) { + const handler = {}; + callback(handler); + handler.onNamespaceSelected(namespace); + window.selectFixtureNamespace = (value) => handler.onNamespaceSelected(value); + }, + }, + }; + }, options); + await context.route('**/*', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const path = url.pathname; + const query = Object.fromEntries(url.searchParams); + const method = request.method(); + const json = (value, status = 200) => + route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(value) }); + try { + assert.equal(url.origin, fixture.origin, 'fixture must not contact an external service'); + assert.ok(++fixture.requests <= 250, 'requests must remain bounded'); + // WebKit exposes native Blob workers to routing; their imported HTTP assets remain mocked. + if (url.protocol === 'blob:') return route.continue(); + if (path === '/' || path.startsWith('/static/')) { + const name = path === '/' ? 'index.html' : path.slice(1); + if (/worker-(yaml|json).*\.js$/.test(name)) fixture.workers.push(name); + let body = await readFile(new URL(name, build)); + if (path === '/') + body = body + .toString() + .replace( + /window\.KFP_FLAGS\.DEPLOYMENT\s*=\s*null;?/, + `window.KFP_FLAGS.DEPLOYMENT=${JSON.stringify(options.namespace ? 'KUBEFLOW' : null)};`, + ); + return route.fulfill({ + body, + contentType: name.endsWith('.js') + ? 'text/javascript' + : name.endsWith('.css') + ? 'text/css' + : name.endsWith('.html') + ? 'text/html' + : 'application/octet-stream', + }); + } + if (path === '/apis/v2/healthz') + return json({ + apiServerTagName: 'fixture', + apiServerMultiUser: !!options.namespace, + pipelineStore: options.pipelineStore || 'database', + }); + if (path === '/system/cluster-name' || path === '/system/project-id') + return route.fulfill({ body: '' }); + if (path === '/apis/v2/pipelines' && method === 'GET') { + fixture.lists.push({ resource: 'pipelines', ...query }); + let rows = fixture.pipelines; + if (query.namespace === 'team-b') rows = rows.slice(0, 2); + if (options.namespace && !query.namespace) rows = rows.slice(0, 3); + const result = listPage(rows, query, 'pipelines'); + return json({ pipelines: result.rows, next_page_token: result.next_page_token }); + } + if ( + (path === '/apis/v2/pipelines' || path === '/apis/v2/pipelines/upload') && + method === 'POST' + ) { + const upload = path.endsWith('/upload'); + const data = upload ? query : request.postDataJSON(); + const createPipeline = () => { + fixture.mutations.push({ kind: upload ? 'upload-pipeline' : 'create-pipeline', data }); + const pipeline = { + ...data, + pipeline_id: 'created-pipeline', + created_at: '2026-09-27T12:00:00Z', + }; + fixture.pipelines.push(pipeline); + fixture.versionMap.set( + pipeline.pipeline_id, + upload ? versions(pipeline.pipeline_id).slice(0, 1) : [], + ); + return pipeline; + }; + return upload ? await uploadResponse(route, createPipeline) : json(createPipeline()); + } + if (path === '/apis/v2/pipelines/upload_version' && method === 'POST') { + return await uploadResponse(route, () => { + const version = { + ...versions(query.pipelineid)[0], + name: query.name, + display_name: query.display_name || query.name, + pipeline_version_id: 'created-version', + }; + fixture.mutations.push({ kind: 'upload-version', data: query }); + fixture.versionMap.get(query.pipelineid).unshift(version); + return version; + }); + } + const versionList = path.match(/^\/apis\/v2\/pipelines\/([^/]+)\/versions$/); + if (versionList) { + const pipelineId = decodeURIComponent(versionList[1]); + assert.ok( + fixture.versionMap.has(pipelineId), + 'version list must reference its actual pipeline', + ); + if (method === 'POST') { + const data = request.postDataJSON(); + assert.equal(data.pipeline_id, pipelineId); + const version = { + ...versions(pipelineId)[0], + ...data, + pipeline_version_id: 'created-version', + }; + fixture.mutations.push({ kind: 'create-version', data }); + fixture.versionMap.get(pipelineId).unshift(version); + return json(version); + } + assert.equal(method, 'GET'); + fixture.lists.push({ resource: 'versions', pipelineId, ...query }); + const result = listPage(fixture.versionMap.get(pipelineId), query, pipelineId); + return json({ pipeline_versions: result.rows, next_page_token: result.next_page_token }); + } + const detail = path.match(/^\/apis\/v2\/pipelines\/([^/]+)(?:\/versions\/([^/]+))?$/); + if (detail) { + const pipelineId = decodeURIComponent(detail[1]); + const versionId = detail[2] && decodeURIComponent(detail[2]); + fixture.reads.push({ pipelineId, versionId, method }); + assert.equal(method, 'GET'); + const value = versionId + ? fixture.versionMap + .get(pipelineId) + ?.find((version) => version.pipeline_version_id === versionId) + : fixture.pipelines.find((pipeline) => pipeline.pipeline_id === pipelineId); + assert.ok(value, 'detail request must preserve encoded resource identity'); + return json(value); + } + throw new Error(`Unexpected request: ${method} ${path}`); + } catch (error) { + fixture.errors.push(error.message); + return json({ message: error.message }, 500); + } + }); + try { + await exercise(page, fixture); + assert.equal(uploadResponses.size, 0, 'every routed upload must reach the native collector'); + assert.deepEqual( + fixture.errors, + [], + 'production bundle and fixture request contracts must have no unexpected errors', + ); + } catch (error) { + console.error( + 'Pipeline fixture diagnostic:', + JSON.stringify({ + url: page.url(), + errors: fixture.errors, + lists: fixture.lists.slice(-6), + reads: fixture.reads, + mutations: fixture.mutations, + dialog: await page.getByRole('dialog').allTextContents(), + }), + ); + throw error; + } finally { + try { + await context.close(); + } finally { + await new Promise((resolve) => uploadServer.close(resolve)); + } + } +} + +async function screenshot(page, name) { + if (!process.env.KFP_PIPELINES_SCREENSHOT_DIR) return; + await mkdir(process.env.KFP_PIPELINES_SCREENSHOT_DIR, { recursive: true }); + await page.screenshot({ + path: join(process.env.KFP_PIPELINES_SCREENSHOT_DIR, `${name}.png`), + animations: 'disabled', + }); +} + +async function darkTheme(page) { + await page.getByRole('switch', { name: 'Dark mode', exact: true }).setChecked(true); + await page.waitForFunction(() => { + const shell = document.querySelector('.kfp-shell'); + const brand = document.querySelector('.kfp-shell-brand-title'); + return ( + getComputedStyle(shell).backgroundColor === 'rgb(14, 16, 21)' && + getComputedStyle(brand).color === 'rgb(236, 238, 244)' && + [...document.querySelectorAll('.kfp-pipeline-card')].every( + (element) => + getComputedStyle(element).backgroundColor === + (element.dataset.selected === 'true' ? 'rgb(26, 39, 64)' : 'rgb(22, 25, 33)'), + ) + ); + }); +} + +const card = (page, name) => + page.getByRole('row').filter({ has: page.getByRole('link', { name, exact: true }) }); +async function openList(page, fixture) { + await page.goto(`${fixture.origin}/#/pipelines`); + await page.getByRole('link', { name: 'Pipeline 01', exact: true }).waitFor(); + await page.getByRole('combobox', { name: 'Rows per page' }).selectOption('10'); + await waitForRows(page, 10); +} +async function waitForRows(page, count) { + await page.waitForFunction( + (expected) => + document.querySelectorAll('table[aria-label="Pipelines"] tbody tr[data-row-id]').length === + expected, + count, + ); +} + +test('pipelines use comfortable rows with latest version links and ignore retired density preferences', async () => { + await withFixture({}, async (page, fixture) => { + await page.addInitScript(() => localStorage.setItem('collectionDensity', 'compact')); + await page.goto(`${fixture.origin}/#/pipelines`); + await page.getByRole('table', { name: 'Pipelines', exact: true }).waitFor(); + await card(page, 'Pipeline 01') + .getByRole('link', { name: 'Version 01', exact: true }) + .waitFor(); + assert.equal(await page.getByRole('button', { name: 'Cards', exact: true }).count(), 0); + assert.equal(await page.getByRole('button', { name: 'Display options' }).count(), 0); + assert.equal(await page.getByRole('button', { name: 'Table options' }).count(), 0); + const firstRow = page.locator('.kfp-runs-table tbody tr[data-row-id]').first(); + const rowHeight = (await firstRow.boundingBox()).height; + assert.ok(rowHeight >= 40 && rowHeight <= 41, 'comfortable rows include a 1px separator'); + assert.equal( + await card(page, 'Pipeline 01') + .getByRole('link', { name: 'Version 01', exact: true }) + .getAttribute('href'), + `#${pipelinePath(specialPipelineId, specialVersionId)}`, + ); + assert.ok( + fixture.lists + .filter(({ resource }) => resource === 'versions') + .every(({ page_size }) => page_size === '1'), + ); + await screenshot(page, 'pipelines-table-light'); + }); +}); + +test('pipeline table retains filtering, sorting, paging and selection', async () => { + await withFixture({}, async (page, fixture) => { + await openList(page, fixture); + await page.getByRole('button', { name: 'Next page', exact: true }).click(); + await page.getByRole('link', { name: 'Pipeline 11', exact: true }).waitFor(); + await page.getByRole('searchbox', { name: 'Search pipelines' }).fill('Pipeline 01'); + await waitForRows(page, 1); + await page.getByRole('button', { name: 'Clear filter', exact: true }).click(); + await page.getByRole('link', { name: 'Pipeline 10', exact: true }).waitFor(); + await page + .getByRole('columnheader', { name: /Pipeline name/ }) + .getByRole('button') + .first() + .click(); + await card(page, 'Pipeline 01').getByRole('checkbox').check(); + assert.equal(await page.getByRole('button', { name: 'Delete', exact: true }).isEnabled(), true); + await card(page, 'Pipeline 01').getByRole('link', { name: 'Pipeline 01', exact: true }).click(); + await page.getByRole('tab', { name: 'Versions', exact: true }).click(); + await page.waitForURL('**?tab=versions'); + const versionsTable = page.getByRole('table', { name: 'Pipeline versions', exact: true }); + await versionsTable.getByRole('link', { name: 'Version 01', exact: true }).waitFor(); + await page.getByRole('combobox', { name: 'Rows per page' }).selectOption('10'); + await versionsTable.getByRole('checkbox', { name: 'Select version Version 01' }).check(); + await page.getByRole('button', { name: 'Next page', exact: true }).click(); + await versionsTable.getByRole('link', { name: 'Version 11', exact: true }).waitFor(); + }); +}); + +test('private/shared tabs and namespace changes preserve scoped pipeline lists', async () => { + await withFixture({ namespace: 'team-a' }, async (page, fixture) => { + await openList(page, fixture); + assert.equal( + fixture.lists.filter(({ resource }) => resource === 'pipelines').at(-1).namespace, + 'team-a', + ); + await page.getByRole('tab', { name: 'Shared', exact: true }).click(); + await page.waitForURL('**/#/shared/pipelines'); + await page.getByRole('link', { name: 'Pipeline 03', exact: true }).waitFor(); + await waitForRows(page, 3); + assert.equal( + fixture.lists.filter(({ resource }) => resource === 'pipelines').at(-1).namespace, + undefined, + ); + await page.getByRole('tab', { name: 'Private', exact: true }).click(); + await page.getByRole('link', { name: 'Pipeline 10', exact: true }).waitFor(); + await Promise.all([ + page.waitForResponse( + (response) => new URL(response.url()).searchParams.get('namespace') === 'team-b', + ), + page.evaluate(() => window.selectFixtureNamespace('team-b')), + ]); + await page.getByRole('link', { name: 'Pipeline 02', exact: true }).waitFor(); + await waitForRows(page, 2); + assert.equal( + fixture.lists.filter(({ resource }) => resource === 'pipelines').at(-1).namespace, + 'team-b', + ); + }); +}); + +test('pipeline details preserve encoded IDs, rendered IR, version switching and browser reload', async () => { + await withFixture({}, async (page, fixture) => { + await page.goto(`${fixture.origin}/#${pipelinePath(specialPipelineId, specialVersionId)}`); + await page.getByTestId('DagCanvas').waitFor(); + await page.locator('.react-flow__node').first().waitFor(); + const actionBounds = await Promise.all( + ['Create run', 'Upload version', 'More actions'].map((name) => + page.getByRole('button', { name, exact: true }).boundingBox(), + ), + ); + const [create, upload, more] = actionBounds; + assert.ok(create && upload && more, 'pipeline actions are visible'); + assert.ok(Math.abs(create.y + create.height / 2 - (upload.y + upload.height / 2)) <= 1); + assert.ok( + Math.abs(create.y + create.height / 2 - (more.y + more.height / 2)) <= 1, + 'overflow stays on the primary action row', + ); + assert.ok(more.width <= 40, `overflow remains an icon button: ${more.width}`); + + await page.getByRole('tab', { name: 'Details', exact: true }).click(); + const version = page.getByRole('combobox', { name: 'Version', exact: true }); + assert.equal(await version.inputValue(), specialVersionId); + await version.selectOption('version-2'); + await page.waitForURL(`**/#${pipelinePath(specialPipelineId, 'version-2')}`); + const workerResponse = page.waitForResponse((response) => + /\/worker-yaml[^/]*\.js$/.test(new URL(response.url()).pathname), + ); + await page.getByRole('tab', { name: 'Pipeline spec', exact: true }).click(); + assert.equal((await workerResponse).status(), 200); + await page.getByTestId('spec-ir').waitFor(); + assert.ok((await page.getByTestId('spec-ir').textContent()).includes('comp-preprocess')); + await page.waitForFunction(() => document.querySelector('.ace_editor') !== null); + assert.ok( + fixture.workers.some((name) => /worker-yaml.*\.js$/.test(name)), + 'read-only YAML must load its bundled worker without a 404', + ); + await darkTheme(page); + const editor = page.locator('.ace_editor'); + await page.waitForFunction(() => { + const element = document.querySelector('.ace_editor'); + return element && getComputedStyle(element).backgroundColor === 'rgb(22, 25, 33)'; + }); + assert.equal( + await editor.evaluate((element) => getComputedStyle(element).backgroundColor), + 'rgb(22, 25, 33)', + ); + assert.match( + await editor.evaluate((element) => getComputedStyle(element).fontFamily), + /JetBrains Mono/, + ); + // Focus can scroll Ace's virtualized lines; compare its complete model. + const readEditor = (element) => { + const instance = element.env?.editor; + if (!instance) throw new Error('The rendered pipeline spec has no initialized Ace editor'); + return { value: instance.getValue(), readOnly: instance.getReadOnly() }; + }; + const beforeTyping = await editor.evaluate(readEditor); + assert.equal(beforeTyping.readOnly, true); + assert.ok(beforeTyping.value.includes('comp-preprocess')); + await editor.locator('.ace_text-input').focus(); + assert.equal( + await editor.locator('.ace_text-input').evaluate((input) => document.activeElement === input), + true, + ); + await page.keyboard.type('read-only-check'); + assert.deepEqual( + await editor.evaluate(readEditor), + beforeTyping, + 'pipeline specs remain read-only', + ); + await screenshot(page, 'pipeline-spec-dark'); + await page.reload(); + await page.getByTestId('DagCanvas').waitFor(); + await page.getByRole('tab', { name: 'Details', exact: true }).click(); + assert.equal( + await page.getByRole('combobox', { name: 'Version', exact: true }).inputValue(), + 'version-2', + ); + assert.ok( + fixture.reads.some( + ({ pipelineId, versionId }) => + pipelineId === specialPipelineId && versionId === specialVersionId, + ), + ); + }); +}); + +for (const visibility of ['standalone', 'private', 'shared']) { + test(`URL import creates a ${visibility} pipeline and its version once`, async () => { + await withFixture( + { namespace: visibility === 'standalone' ? undefined : 'team-a' }, + async (page, fixture) => { + await page.goto(`${fixture.origin}/#/pipeline_versions/new`); + await page + .getByRole('textbox', { name: 'Pipeline Name', exact: true }) + .fill('browser-created'); + if (visibility !== 'standalone') + await page + .getByRole('radio', { + name: visibility === 'private' ? 'Private' : 'Shared', + exact: true, + }) + .check(); + await page + .getByRole('textbox', { name: 'Package Url', exact: true }) + .fill('https://example.test/fixture.yaml'); + await page + .getByRole('textbox', { name: 'Code Source', exact: true }) + .fill('https://example.test/source'); + await page.getByRole('button', { name: 'Create', exact: true }).click(); + await page.waitForURL(`**/#${pipelinePath('created-pipeline', 'created-version')}`); + await page.getByTestId('DagCanvas').waitFor(); + assert.deepEqual( + fixture.mutations.map(({ kind }) => kind), + ['create-pipeline', 'create-version'], + ); + assert.equal( + fixture.mutations[0].data.namespace, + visibility === 'private' ? 'team-a' : undefined, + ); + assert.equal( + fixture.mutations[1].data.package_url.pipeline_url, + 'https://example.test/fixture.yaml', + ); + assert.equal(fixture.mutations[1].data.code_source_url, 'https://example.test/source'); + }, + ); + }); +} + +test('file upload validates the package and preserves the existing pipeline/version relationship', async () => { + await withFixture({}, async (page, fixture) => { + await page.goto( + `${fixture.origin}/#/pipeline_versions/new?pipelineId=${encodeURIComponent(specialPipelineId)}`, + ); + await page.getByRole('radio', { name: 'Upload a file', exact: true }).check(); + const input = page.locator('input[type="file"]'); + await input.setInputFiles({ + name: 'rejected.gz', + mimeType: 'application/gzip', + buffer: Buffer.from('rejected'), + }); + await page + .getByText('Invalid file type. Supported formats: .yaml, .yml, .zip, .tar.gz', { + exact: true, + }) + .waitFor(); + assert.equal( + await page.getByRole('button', { name: 'Create', exact: true }).isDisabled(), + true, + ); + assert.equal(fixture.mutations.length, 0); + await input.setInputFiles({ + name: 'fixture.yaml', + mimeType: 'application/yaml', + buffer: Buffer.from(JSON.stringify(pipelineSpec)), + }); + await page + .getByRole('textbox', { name: 'Pipeline Version Name', exact: true }) + .fill('browser-version'); + await page.getByRole('button', { name: 'Create', exact: true }).click(); + await page.waitForURL(`**/#${pipelinePath(specialPipelineId, 'created-version')}`); + await page.getByTestId('DagCanvas').waitFor(); + assert.deepEqual( + fixture.mutations.map(({ kind }) => kind), + ['upload-version'], + ); + assert.equal(fixture.mutations[0].data.pipelineid, specialPipelineId); + assert.equal(fixture.mutations[0].data.name, 'browser-version'); + }); +}); + +test('local file upload creates a private pipeline using its namespace and package body', async () => { + await withFixture({ namespace: 'team-a' }, async (page, fixture) => { + await page.goto(`${fixture.origin}/#/pipeline_versions/new`); + await page.getByRole('radio', { name: 'Private', exact: true }).check(); + await page.getByRole('radio', { name: 'Upload a file', exact: true }).check(); + await page.locator('input[type="file"]').setInputFiles({ + name: 'fixture.yaml', + mimeType: 'application/yaml', + buffer: Buffer.from(JSON.stringify(pipelineSpec)), + }); + await page.getByRole('textbox', { name: 'Pipeline Name', exact: true }).fill('browser-upload'); + await page.getByRole('button', { name: 'Create', exact: true }).click(); + await page.waitForURL(`**/#${pipelinePath('created-pipeline', specialVersionId)}`); + await page.getByTestId('DagCanvas').waitFor(); + assert.deepEqual( + fixture.mutations.map(({ kind }) => kind), + ['upload-pipeline'], + ); + assert.equal(fixture.mutations[0].data.namespace, 'team-a'); + assert.equal(fixture.mutations[0].data.name, 'browser-upload'); + }); +}); + +test('pipeline table keeps selection column compact on ultrawide screens', async () => { + await withFixture({}, async (page, fixture) => { + await page.setViewportSize({ width: 3440, height: 1263 }); + await page.goto(`${fixture.origin}/#/pipelines`); + const table = page.getByRole('table', { name: 'Pipelines', exact: true }); + await table.getByRole('link', { name: 'Pipeline 01', exact: true }).waitFor(); + const selection = await table.locator('thead .kfp-runs-selection').boundingBox(); + assert.equal(await table.locator('thead .kfp-resource-expansion').count(), 0); + assert.ok(Math.abs(selection.width - 40) <= 1, `selection width: ${selection.width}`); + }); +}); diff --git a/frontend/scripts/ui-modernization-run-details.smoke.mjs b/frontend/scripts/ui-modernization-run-details.smoke.mjs new file mode 100644 index 00000000000..7384f8b99b2 --- /dev/null +++ b/frontend/scripts/ui-modernization-run-details.smoke.mjs @@ -0,0 +1,1275 @@ +/* + * Copyright 2026 The Kubeflow Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Run after npm run build: node --test scripts/ui-modernization-run-details.smoke.mjs +// Local HTTP fixtures only. This guards presentation migration, not live cluster authorization. +import assert from 'node:assert/strict'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { after, before, test } from 'node:test'; +import { launchBrowser } from './browser-launch.mjs'; + +const origin = 'http://kfp.test'; +const build = new URL('../build/', import.meta.url); +const runId = 'browser-run'; +const createdAt = '2026-09-26T12:00:00.000Z'; +const finishedAt = '2026-09-26T12:01:00.000Z'; +const baseSpec = JSON.parse( + await readFile( + new URL( + '../mock-backend/data/v2/pipeline/lightweight_python_functions_v2_pipeline.json', + import.meta.url, + ), + 'utf8', + ), +); +const pipelineSpec = { + ...baseSpec, + pipelineInfo: { name: 'browser-nested-pipeline' }, + components: { ...baseSpec.components, 'comp-group': baseSpec.root }, + root: { + ...baseSpec.root, + dag: { + tasks: { + prepare: { ...baseSpec.root.dag.tasks.preprocess, taskInfo: { name: 'prepare' } }, + group: { + taskInfo: { name: 'group' }, + componentRef: { name: 'comp-group' }, + dependentTasks: ['prepare'], + }, + }, + }, + }, +}; +let browser; +before(async () => { + browser = await launchBrowser(); + if (process.env.KFP_EXPECTED_BROWSER_VERSION) { + assert.equal( + browser.version(), + process.env.KFP_EXPECTED_BROWSER_VERSION, + 'Browser version must match KFP_EXPECTED_BROWSER_VERSION; select the intended browser binary', + ); + } +}); +after(async () => browser?.close()); + +function task(name, parent, type = 'RUNTIME', state = 'SUCCEEDED') { + return { + task_id: `task-${name}`, + run_id: runId, + name, + display_name: name, + parent_task_id: parent && `task-${parent}`, + type, + state, + create_time: createdAt, + end_time: state === 'RUNNING' ? undefined : finishedAt, + state_history: [{ state, update_time: finishedAt }], + }; +} +function gate() { + let release; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} + +async function withFixture(options, exercise) { + const state = options.state || 'SUCCEEDED'; + const context = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + locale: 'en-US', + timezoneId: 'UTC', + reducedMotion: 'reduce', + }); + const page = await context.newPage(); + page.setDefaultTimeout(10000); + await page.clock.install({ time: new Date('2026-09-26T12:02:00.000Z') }); + const fixture = { + run: { + run_id: runId, + display_name: 'Nested browser run', + description: 'Run Details browser fixture', + experiment_id: 'experiment-a', + pipeline_spec: pipelineSpec, + storage_state: 'AVAILABLE', + state, + created_at: createdAt, + scheduled_at: createdAt, + finished_at: state === 'RUNNING' ? undefined : finishedAt, + state_history: [ + { state: 'RUNNING', update_time: createdAt }, + { state, update_time: finishedAt }, + ], + runtime_config: { parameters: { message: 'fixture input' } }, + }, + tasks: [ + task('root', undefined, 'ROOT', state), + task('prepare', 'root'), + task('group', 'root', 'DAG', state), + task('preprocess', 'group'), + task('train', 'group', 'RUNTIME', state), + ], + logs: new Map([ + ['train-pod', 'executor attempt one\ntraining complete'], + ['prepare-driver', 'driver initialization output'], + ]), + artifactLogs: new Map([['preprocess.log', 'artifact executor output\npreprocessing complete']]), + requests: [], + errors: [], + failRunReads: !!options.failRunReads, + retryCount: 0, + runGate: undefined, + tasksGate: undefined, + experimentGate: undefined, + logGates: new Map(), + }; + fixture.tasks[1].pods = [{ name: 'prepare-driver', type: 'DRIVER' }]; + fixture.tasks[3].pods = [{ name: 'preprocess-pod', type: 'EXECUTOR' }]; + fixture.tasks[3].outputs = { + artifacts: [ + { + artifact_key: 'executor-logs', + artifacts: [ + { + artifact_id: 'logs-artifact', + name: 'executor-logs', + type: 'Artifact', + uri: 's3://fixture-bucket/preprocess.log', + }, + ], + }, + ], + }; + fixture.tasks[4].pods = [{ name: 'train-pod', type: 'EXECUTOR' }]; + if (state === 'FAILED') + fixture.tasks[4].state_history[0].error = { code: 13, message: 'Training fixture failed' }; + page.on('pageerror', (error) => fixture.errors.push(error.message)); + await context.addInitScript(({ namespace }) => { + localStorage.setItem('kfp.theme', 'light'); + if (namespace) + window.centraldashboard = { + CentralDashboardEventHandler: { + init(callback) { + const handler = {}; + callback(handler); + handler.onNamespaceSelected(namespace); + }, + }, + }; + }, options); + await context.route('**/*', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const path = url.pathname.replace(/^\/pipeline(?=\/)/, ''); + const json = (value, status = 200) => + route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(value) }); + try { + assert.equal(url.origin, origin, 'fixture must not contact external services'); + assert.ok(fixture.requests.length < 250, 'requests must remain bounded'); + fixture.requests.push({ + path, + pathname: url.pathname, + method: request.method(), + query: Object.fromEntries(url.searchParams), + }); + if (path === '/embed') + return route.fulfill({ + contentType: 'text/html', + body: 'Dashboard fixture', + }); + if (path === '/' || path.startsWith('/static/')) { + const name = path === '/' ? 'index.html' : path.slice(1); + let body = await readFile(new URL(name, build)); + if (path === '/') + body = body + .toString() + .replace( + /window\.KFP_FLAGS\.DEPLOYMENT\s*=\s*null;?/, + `window.KFP_FLAGS.DEPLOYMENT=${JSON.stringify(options.namespace ? 'KUBEFLOW' : null)};`, + ) + .replace( + /window\.KFP_FLAGS\.HIDE_SIDENAV\s*=\s*null;?/, + `window.KFP_FLAGS.HIDE_SIDENAV=${!!options.namespace};`, + ); + return route.fulfill({ + body, + contentType: name.endsWith('.js') + ? 'text/javascript' + : name.endsWith('.css') + ? 'text/css' + : name.endsWith('.html') + ? 'text/html' + : 'application/octet-stream', + }); + } + if (path === '/apis/v2/healthz') return json({ apiServerTagName: 'fixture' }); + if (path === '/system/cluster-name' || path === '/system/project-id') + return route.fulfill({ body: '' }); + if (path === `/apis/v2/runs/${runId}`) { + assert.equal(request.method(), 'GET'); + if (fixture.failRunReads) + return json({ code: 14, message: 'Fixture run temporarily unavailable' }, 503); + const snapshot = structuredClone(fixture.run); + if (fixture.runGate) { + const pending = fixture.runGate; + await pending.promise; + fixture.runGate = undefined; + } + return json(snapshot); + } + if (path === `/apis/v2/runs/${runId}/tasks`) { + assert.equal(request.method(), 'GET'); + assert.equal(url.searchParams.get('page_size'), '200'); + assert.equal(url.searchParams.get('order_by'), 'create_time asc'); + if (fixture.tasksGate) await fixture.tasksGate.promise; + return json({ tasks: fixture.tasks }); + } + if (path === `/apis/v2/runs/${runId}:retry`) { + assert.equal(request.method(), 'POST'); + assert.equal(request.postData(), null); + assert.equal(++fixture.retryCount, 1, 'retry must happen exactly once'); + fixture.run.state = 'RUNNING'; + fixture.run.finished_at = undefined; + fixture.run.state_history.push({ + state: 'RUNNING', + update_time: '2026-09-26T12:02:00.000Z', + }); + fixture.tasks = fixture.tasks.map((task) => + task.name === 'train' + ? { + ...task, + state: 'RUNNING', + end_time: undefined, + pods: [...task.pods, { name: 'retry-pod', type: 'EXECUTOR' }], + } + : task, + ); + return json({}); + } + if (path === '/apis/v2/experiments/experiment-a') { + if (fixture.experimentGate) await fixture.experimentGate.promise; + return json({ + experiment_id: 'experiment-a', + display_name: 'Browser experiment', + namespace: options.namespace || 'team-a', + }); + } + if (path === '/k8s/pod/logs') { + assert.equal(request.method(), 'GET'); + assert.equal(url.searchParams.get('runid'), runId); + assert.equal(url.searchParams.get('podnamespace'), options.namespace || 'team-a'); + assert.equal(url.searchParams.get('createdat'), '2026-09-26'); + const pod = url.searchParams.get('podname'); + if (pod === 'preprocess-pod') + return route.fulfill({ status: 404, body: 'Fixture pod was collected' }); + assert.ok(fixture.logs.has(pod), `unexpected pod ${pod}`); + const text = fixture.logs.get(pod); + if (fixture.logGates.has(pod)) await fixture.logGates.get(pod).promise; + return route.fulfill({ contentType: 'text/plain', body: text }); + } + if (path === '/artifacts/get') { + assert.equal(request.method(), 'GET'); + assert.equal(url.searchParams.get('source'), 's3'); + assert.equal(url.searchParams.get('bucket'), 'fixture-bucket'); + assert.ok(fixture.artifactLogs.has(url.searchParams.get('key')), 'known log artifact'); + assert.equal(url.searchParams.get('namespace'), options.namespace || 'team-a'); + return route.fulfill({ + contentType: 'text/plain', + body: fixture.artifactLogs.get(url.searchParams.get('key')), + }); + } + throw new Error(`Unexpected fixture request: ${request.method()} ${path}`); + } catch (error) { + fixture.errors.push(error.message); + await json({ message: error.message }, 500); + } + }); + try { + await exercise(page, fixture); + assert.deepEqual( + fixture.errors, + [], + 'bundle and request contracts must have no unexpected errors', + ); + } catch (error) { + if (fixture.errors.length) console.error('Fixture errors:', fixture.errors); + // Keep timeout evidence small and failure-only; never alter graph timing or + // suppress browser errors to make a visibility assertion pass. + const graphDiagnostic = await page + .evaluate(() => { + const describe = (element) => { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return { + id: element.getAttribute('data-id'), + className: String(element.className), + inlineStyle: element.getAttribute('style'), + visibility: style.visibility, + display: style.display, + width: style.width, + height: style.height, + offsetWidth: element.offsetWidth, + offsetHeight: element.offsetHeight, + bounds: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + }; + }; + const nodes = [...document.querySelectorAll('.react-flow__node')]; + return { + url: location.href, + nodeCount: nodes.length, + edgeCount: document.querySelectorAll('.react-flow__edge').length, + nodes: nodes.slice(0, 20).map(describe), + containers: [ + ...document.querySelectorAll( + '.kfp-graph-workspace,.kfp-graph-canvas,.react-flow__renderer,.react-flow__viewport', + ), + ] + .slice(0, 8) + .map(describe), + }; + }) + .catch((diagnosticError) => ({ unavailable: diagnosticError.message })); + console.error('Run Details failure diagnostics:', JSON.stringify(graphDiagnostic)); + throw error; + } finally { + fixture.runGate?.release(); + fixture.tasksGate?.release(); + fixture.experimentGate?.release(); + for (const pending of fixture.logGates.values()) pending.release(); + await context.close(); + } +} + +const node = (page, name) => { + const id = ['prepare', 'group'].includes(name) + ? `task.${name}` + : JSON.stringify(['root', 'group', `task.${name}`]); + return page.locator(`.react-flow__node[data-id=${JSON.stringify(id)}]`); +}; +const tab = (page, name) => + page.getByRole('button', { name, exact: true }).or(page.getByRole('tab', { name, exact: true })); +async function selected(page, name) { + await node(page, name).and(page.locator('.selected')).waitFor(); +} +async function openTask(page, taskName = 'train', extra = '') { + await page.goto(`${origin}/#/runs/details/${runId}?task=task-${taskName}${extra}`); + await selected(page, taskName); +} +async function changeTask(page, taskName) { + await page.evaluate((name) => { + const url = new URL(location.href); + const query = new URLSearchParams(url.hash.split('?')[1]); + query.set('task', `task-${name}`); + location.hash = `${url.hash.split('?')[0]}?${query}`; + }, taskName); + await selected(page, taskName); +} +async function logs(page, text) { + await tab(page, 'Logs').click(); + await page.getByTestId('logs-view-window').getByText(text, { exact: true }).waitFor(); +} +async function screenshot(page, name) { + if (!process.env.KFP_RUN_DETAILS_SCREENSHOT_DIR) return; + await mkdir(process.env.KFP_RUN_DETAILS_SCREENSHOT_DIR, { recursive: true }); + await page.screenshot({ + path: join(process.env.KFP_RUN_DETAILS_SCREENSHOT_DIR, `${name}.png`), + animations: 'disabled', + }); +} +async function graph(page) { + await page.getByTestId('DagCanvas').waitFor(); + return page.evaluate(async () => { + await document.fonts.ready; + function measure() { + return { + nodes: [...document.querySelectorAll('.react-flow__node')] + .map((element) => { + const matrix = new DOMMatrixReadOnly(element.style.transform); + return { + id: element.dataset.id, + x: matrix.m41, + y: matrix.m42, + width: element.offsetWidth, + height: element.offsetHeight, + visible: getComputedStyle(element).visibility === 'visible', + }; + }) + .sort((a, b) => a.id.localeCompare(b.id)), + edges: [...document.querySelectorAll('.react-flow__edge')] + .map((element) => ({ + id: element.dataset.id, + path: element.querySelector('.react-flow__edge-path')?.getAttribute('d') || '', + visible: getComputedStyle(element).visibility === 'visible', + })) + .sort((a, b) => a.id.localeCompare(b.id)), + }; + } + let previous, + stable = 0; + for (let frame = 0; frame < 90; frame++) { + await new Promise(requestAnimationFrame); + const current = measure(); + const key = JSON.stringify(current); + stable = key === previous ? stable + 1 : 0; + previous = key; + if ( + current.nodes.length >= 2 && + current.nodes.every((node) => node.visible && node.width > 0 && node.height > 0) && + current.edges.length > 0 && + current.edges.every((edge) => edge.visible && edge.path) && + stable >= 3 + ) + return current; + } + throw new Error('graph node/edge geometry did not settle'); + }); +} +function sameGraph(before, after) { + assert.deepEqual( + after.nodes.map((node) => node.id), + before.nodes.map((node) => node.id), + ); + assert.deepEqual( + after.edges.map((edge) => edge.id), + before.edges.map((edge) => edge.id), + ); + for (let i = 0; i < before.nodes.length; i++) + for (const field of ['x', 'y', 'width', 'height']) + assert.ok( + Math.abs(before.nodes[i][field] - after.nodes[i][field]) < 0.05, + `${before.nodes[i].id} ${field} changed`, + ); + for (let i = 0; i < before.edges.length; i++) { + const numbers = (value) => value.match(/-?\d+(?:\.\d+)?(?:e[-+]?\d+)?/gi).map(Number); + const a = numbers(before.edges[i].path), + b = numbers(after.edges[i].path); + assert.equal(a.length, b.length); + assert.ok( + a.every((value, index) => Math.abs(value - b[index]) < 0.05), + `edge ${before.edges[i].id} changed`, + ); + } +} + +test('Run Details keeps graph space stable and puts metadata in Detail', async () => { + await withFixture({}, async (page, fixture) => { + await page.setViewportSize({ width: 1280, height: 720 }); + const pendingTasks = gate(); + fixture.tasksGate = pendingTasks; + const tasksPath = `/apis/v2/runs/${runId}/tasks`; + const requested = page.waitForRequest( + (request) => new URL(request.url()).pathname === tasksPath, + ); + await page.goto(`${origin}/#/runs/details/${runId}`); + await requested; + await page.getByTestId('page-title').getByText('Nested browser run', { exact: true }).waitFor(); + assert.equal(await page.locator('.kfp-run-summary').count(), 0); + const measure = () => + page.locator('.kfp-run-graph').evaluate(async (element) => { + await document.fonts.ready; + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + const { x, y, width, height } = element.getBoundingClientRect(); + return { x, y, width, height }; + }); + const loading = await measure(); + const received = page.waitForResponse( + (response) => new URL(response.url()).pathname === tasksPath, + ); + pendingTasks.release(); + await received; + await graph(page); + const loaded = await measure(); + for (const field of ['x', 'y', 'width', 'height']) { + assert.ok( + Math.abs(loaded[field] - loading[field]) <= 1, + `Graph ${field} shifted from ${loading[field]} to ${loaded[field]}`, + ); + } + assert.equal(fixture.requests.filter((request) => request.path === tasksPath).length, 1); + await tab(page, 'Details').click(); + const summary = page.locator('.kfp-run-summary'); + await summary.getByText('3 done · 0 failed · 0 running · 0 skipped', { exact: true }).waitFor(); + assert.equal( + await page + .getByRole('region', { name: 'Run details', exact: true }) + .getByText(runId, { exact: true }) + .count(), + 1, + ); + for (const width of [1200, 375]) { + await page.setViewportSize({ width, height: 650 }); + if (width === 375) await page.locator('.kfp-shell-sidebar[data-collapsed="true"]').waitFor(); + assert.equal( + await summary.evaluate((element) => element.scrollWidth <= element.clientWidth), + true, + ); + assert.equal( + await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), + true, + ); + } + }); +}); + +test('Run Details preserves nested task links, history, copied URLs, and graph geometry', async () => { + await withFixture({}, async (page, fixture) => { + const pendingRun = gate(); + fixture.runGate = pendingRun; + const requested = page.waitForRequest( + (request) => new URL(request.url()).pathname === `/apis/v2/runs/${runId}`, + ); + await page.goto(`${origin}/#/runs/details/${runId}?task=task-train&view=graph`); + await requested; + const content = page.locator('.kfp-modern-page-content'); + await content.waitFor(); + const pendingY = await content.evaluate((element) => element.getBoundingClientRect().y); + pendingRun.release(); + await selected(page, 'train'); + await page.getByTestId('page-title').getByText('Nested browser run', { exact: true }).waitFor(); + const loadedY = await content.evaluate((element) => element.getBoundingClientRect().y); + assert.ok( + Math.abs(loadedY - pendingY) < 1, + `Run Details content shifted from ${pendingY} to ${loadedY}`, + ); + await tab(page, 'Task Details').click(); + await page.getByText('task-train', { exact: true }).waitFor(); + const original = await graph(page); + await screenshot(page, 'run-details-task'); + if (process.env.KFP_RUN_DETAILS_SCREENSHOT_DIR) { + await writeFile( + join(process.env.KFP_RUN_DETAILS_SCREENSHOT_DIR, 'run-details-geometry.json'), + `${JSON.stringify(original, null, 2)}\n`, + ); + } + await changeTask(page, 'preprocess'); + await tab(page, 'Task Details').click(); + await page.getByText('task-preprocess', { exact: true }).waitFor(); + sameGraph(original, await graph(page)); + await page.goBack(); + await selected(page, 'train'); + await page.goForward(); + await selected(page, 'preprocess'); + await page.reload(); + await selected(page, 'preprocess'); + sameGraph(original, await graph(page)); + await page.getByRole('button', { name: 'close', exact: true }).click(); + await page.waitForURL((url) => url.hash === `#/runs/details/${runId}?view=graph`); + assert.equal(await page.locator('.react-flow__node.selected').count(), 0); + sameGraph(original, await graph(page)); + // Closing the inspector replaces controlled node objects. Actual measured + // dimensions and edge handles must survive each selection/close rerender. + for (const taskName of ['train', 'preprocess']) { + await page.getByRole('button', { name: 'Fit view', exact: true }).click(); + await node(page, taskName).getByRole('button').click(); + await selected(page, taskName); + sameGraph(original, await graph(page)); + const inspector = page.getByRole('dialog'); + await inspector.getByRole('button', { name: 'close', exact: true }).click(); + await inspector.waitFor({ state: 'hidden' }); + await page.waitForURL((url) => url.hash === `#/runs/details/${runId}?view=graph`); + assert.equal(await page.locator('.react-flow__node.selected').count(), 0); + sameGraph(original, await graph(page)); + } + await screenshot(page, 'run-details-graph'); + }); +}); + +test('Run Details defers input/output previews and downloads until the experiment namespace resolves', async () => { + await withFixture({}, async (page, fixture) => { + const pendingExperiment = gate(); + fixture.experimentGate = pendingExperiment; + await openTask(page, 'preprocess'); + await page.getByText('Loading experiment namespace…', { exact: true }).waitFor(); + assert.equal( + fixture.requests.filter((request) => request.path === '/artifacts/get').length, + 0, + 'automatic previews must wait for the experiment namespace', + ); + assert.equal( + await page.locator('a[download]').count(), + 0, + 'unscoped download links must not be exposed while namespace resolution is pending', + ); + pendingExperiment.release(); + await page + .locator('.kfp-artifact-preview-preview') + .getByText('artifact executor output', { exact: false }) + .waitFor(); + assert.deepEqual( + fixture.requests + .filter((request) => request.path === '/artifacts/get') + .map((request) => request.query.namespace), + ['team-a'], + 'resolution starts one namespaced preview request', + ); + assert.equal( + new URL(await page.locator('a[download]').getAttribute('href'), origin).searchParams.get( + 'namespace', + ), + 'team-a', + ); + await screenshot(page, 'run-details-scoped-artifact-preview'); + }); +}); + +test('Run Details distinguishes executor, artifact, and driver log sources', async () => { + await withFixture({}, async (page, fixture) => { + const pendingExperiment = gate(); + fixture.experimentGate = pendingExperiment; + await openTask(page); + await tab(page, 'Task Details').click(); + await page.getByText('task-train', { exact: true }).waitFor(); + await tab(page, 'Logs').click(); + await page.getByText('Loading experiment namespace…', { exact: true }).waitFor(); + assert.equal( + fixture.requests.filter((request) => + ['/k8s/pod/logs', '/artifacts/get'].includes(request.path), + ).length, + 0, + 'pod and artifact logs must wait for the experiment namespace', + ); + pendingExperiment.release(); + await page + .getByTestId('logs-view-window') + .getByText('executor attempt one', { exact: true }) + .waitFor(); + assert.deepEqual( + fixture.requests + .filter((request) => request.path === '/k8s/pod/logs') + .map((request) => request.query.podnamespace), + ['team-a'], + 'resolving the experiment starts exactly one namespaced log request', + ); + await changeTask(page, 'preprocess'); + await logs(page, 'artifact executor output'); + assert.equal( + await page + .getByTestId('logs-view-window') + .getByText('executor attempt one', { exact: true }) + .count(), + 0, + ); + await changeTask(page, 'prepare'); + await tab(page, 'Logs').click(); + await page + .getByTestId('driver-logs-view-window') + .getByText('driver initialization output', { exact: true }) + .waitFor(); + assert.equal( + await page.getByTestId('logs-view-window').count(), + 0, + 'driver output must not appear in task output', + ); + assert.equal( + await page + .getByRole('button', { name: 'Driver', exact: true }) + .getAttribute('aria-expanded'), + 'true', + ); + assert.deepEqual( + fixture.requests + .filter((request) => request.path === '/k8s/pod/logs') + .map((request) => request.query.podname), + ['train-pod', 'preprocess-pod', 'prepare-driver'], + ); + await screenshot(page, 'run-details-driver-logs'); + }); +}); + +test('one Logs tab separates collapsible driver output from task output', async () => { + await withFixture({}, async (page, fixture) => { + fixture.tasks + .find((task) => task.name === 'train') + .pods.push({ name: 'train-driver', type: 'DRIVER' }); + fixture.logs.set('train-driver', 'driver orchestration output'); + await openTask(page); + await logs(page, 'executor attempt one'); + const driverToggle = page.getByRole('button', { name: 'Driver', exact: true }); + assert.equal(await driverToggle.getAttribute('aria-expanded'), 'false'); + assert.equal(await page.getByRole('tab', { name: 'Driver logs', exact: true }).count(), 0); + await driverToggle.click(); + await page + .getByTestId('driver-logs-view-window') + .getByText('driver orchestration output', { exact: true }) + .waitFor(); + assert.equal(await page.getByText('driver orchestration output', { exact: true }).count(), 1); + assert.equal( + await page + .getByTestId('logs-view-window') + .getByText('executor attempt one', { exact: true }) + .count(), + 1, + ); + const positions = await page.evaluate(() => ({ + driver: document + .querySelector('[data-testid="driver-logs-view-window"]') + .getBoundingClientRect().top, + task: document.querySelector('[data-testid="logs-view-window"]').getBoundingClientRect().top, + })); + assert.ok(positions.driver < positions.task, 'driver logs precede component logs'); + const copyLayout = await page.evaluate(() => + [...document.querySelectorAll('.kfp-node-logs-heading')].map((heading) => { + const text = heading.querySelector('h3, .kfp-node-logs-toggle').getBoundingClientRect(); + const copy = heading.querySelector('.kfp-node-logs-copy').getBoundingClientRect(); + return { + textCenter: text.top + text.height / 2, + copyCenter: copy.top + copy.height / 2, + height: heading.getBoundingClientRect().height, + }; + }), + ); + assert.ok( + copyLayout.every((row) => Math.abs(row.textCenter - row.copyCenter) < 1 && row.height <= 28), + 'copy controls stay on existing heading rows', + ); + + assert.ok( + (await page.getByTestId('driver-logs-view-window').boundingBox()).height < 50, + 'short driver output fits its content', + ); + await driverToggle.click(); + assert.equal(await driverToggle.getAttribute('aria-expanded'), 'false'); + assert.equal(await page.getByTestId('driver-logs-view-window').count(), 0); + await page + .getByTestId('logs-view-window') + .getByText('executor attempt one', { exact: true }) + .waitFor(); + }); +}); + +test('task log attempts stay independent, collapsible, and on one heading row', async () => { + await withFixture({}, async (page, fixture) => { + const train = fixture.tasks.find((task) => task.name === 'train'); + train.outputs = { + artifacts: [ + { + artifact_key: 'executor-logs', + artifacts: [ + { artifact_id: 'attempt-zero', uri: 's3://fixture-bucket/executor-logs-0' }, + { artifact_id: 'attempt-one', uri: 's3://fixture-bucket/executor-logs-1' }, + ], + }, + ], + }; + fixture.artifactLogs.set('executor-logs-0', 'first attempt failed'); + fixture.artifactLogs.set('executor-logs-1', 'second attempt failed'); + await openTask(page); + await logs(page, 'executor attempt one'); + assert.equal(await page.getByRole('combobox', { name: 'Available component logs' }).count(), 0); + const toggle = page.getByRole('button', { name: 'Component · Attempt 1', exact: true }); + assert.equal(await toggle.getAttribute('aria-expanded'), 'false'); + await toggle.click(); + await page + .getByTestId('logs-view-window') + .getByText('first attempt failed', { exact: true }) + .waitFor(); + assert.equal(await page.getByText('executor attempt one', { exact: true }).count(), 1); + await toggle.click(); + assert.equal(await page.getByText('first attempt failed', { exact: true }).count(), 0); + assert.equal(await toggle.getAttribute('aria-expanded'), 'false'); + await toggle.click(); + await page + .getByTestId('logs-view-window') + .getByText('first attempt failed', { exact: true }) + .waitFor(); + const geometry = await page.evaluate(() => { + const heading = document.querySelector('.kfp-node-logs-task .kfp-node-logs-heading'); + const toggle = heading.querySelector('button').getBoundingClientRect(); + const copy = heading.querySelector('.kfp-node-logs-copy').getBoundingClientRect(); + return { + height: heading.getBoundingClientRect().height, + toggle: toggle.top + toggle.height / 2, + copy: copy.top + copy.height / 2, + }; + }); + assert.ok(geometry.height <= 28 && Math.abs(geometry.toggle - geometry.copy) < 1); + for (const window of await page.getByTestId('logs-view-window').all()) { + assert.ok((await window.boundingBox()).height <= 70, 'short attempts fit their content'); + } + await screenshot(page, 'run-details-log-attempts'); + }); +}); + +test('long driver and component logs are capped and scrollable', async () => { + await withFixture({}, async (page, fixture) => { + fixture.tasks + .find((task) => task.name === 'train') + .pods.push({ name: 'train-driver', type: 'DRIVER' }); + fixture.logs.set( + 'train-driver', + Array.from({ length: 100 }, (_, i) => `driver line ${i}`).join('\n'), + ); + fixture.logs.set('train-pod', Array.from({ length: 100 }, (_, i) => `component line ${i}`).join('\n')); + await openTask(page); + await logs(page, 'component line 0'); + const componentWindow = page.getByTestId('logs-view-window'); + assert.equal((await componentWindow.boundingBox()).height, 320); + const componentDimensions = await componentWindow.getByRole('grid').evaluate((el) => ({ height: el.clientHeight, content: el.scrollHeight })); + assert.ok(componentDimensions.content > componentDimensions.height, 'long component output scrolls within its cap'); + await page.getByRole('button', { name: 'Driver', exact: true }).click(); + const window = page.getByTestId('driver-logs-view-window'); + await window.getByRole('grid').waitFor(); + const dimensions = await window + .getByRole('grid') + .evaluate((el) => ({ height: el.clientHeight, content: el.scrollHeight })); + assert.equal((await window.boundingBox()).height, 200); + assert.ok(dimensions.content > dimensions.height, 'long driver output scrolls within its cap'); + }); +}); + +async function tick(page, path) { + const response = page.waitForResponse((response) => + new URL(response.url()).pathname.endsWith(path), + ); + await page.clock.fastForward(10000); + await response; +} + +test('Run Details recovers a failed initial read and preserves its graph during refresh failures', async () => { + await withFixture({ failRunReads: true, state: 'RUNNING' }, async (page, fixture) => { + await page.goto(`${origin}/#/runs/details/${runId}?task=task-train`); + await page + .getByRole('alert') + .filter({ hasText: 'Unable to load run details. Refresh this page to retry.' }) + .waitFor({ timeout: 15000 }); + fixture.failRunReads = false; + await page.reload(); + await selected(page, 'train'); + assert.equal( + await page + .getByText('Unable to load run details. Refresh this page to retry.', { exact: true }) + .count(), + 0, + ); + const original = await graph(page); + fixture.failRunReads = true; + await tick(page, `/apis/v2/runs/${runId}`); + const warning = page.getByText( + 'Unable to refresh this run. The last known run state is still shown. Refresh the page to try again.', + { exact: true }, + ); + await warning.waitFor(); + await selected(page, 'train'); + sameGraph(original, await graph(page)); + fixture.failRunReads = false; + await tick(page, `/apis/v2/runs/${runId}`); + await warning.waitFor({ state: 'hidden' }); + sameGraph(original, await graph(page)); + }); +}); + +test('Run Details retries once and never shows prior attempt output under a new attempt', async () => { + await withFixture({ state: 'FAILED' }, async (page, fixture) => { + await openTask(page); + await tab(page, 'Task Details').click(); + await page.getByText('Training fixture failed', { exact: true }).first().waitFor(); + await logs(page, 'executor attempt one'); + await screenshot(page, 'run-details-failed-logs'); + const runPending = gate(), + logsPending = gate(); + fixture.runGate = runPending; + fixture.logGates.set('retry-pod', logsPending); + fixture.logs.set('retry-pod', 'retry attempt output'); + const retryRead = page.waitForRequest( + (request) => new URL(request.url()).pathname === `/apis/v2/runs/${runId}`, + ); + await page.getByRole('button', { name: 'Retry', exact: true }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Retry', exact: true }).click(); + await retryRead; + assert.equal(fixture.retryCount, 1); + await page + .getByTestId('logs-view-window') + .getByText('executor attempt one', { exact: true }) + .waitFor(); + const retryLogs = page.waitForRequest( + (request) => new URL(request.url()).searchParams.get('podname') === 'retry-pod', + ); + runPending.release(); + await retryLogs; + await page.getByText('Loading component logs…', { exact: true }).waitFor(); + assert.equal(await page.getByText('executor attempt one', { exact: true }).count(), 0); + await changeTask(page, 'preprocess'); + await logs(page, 'artifact executor output'); + const completed = page.waitForResponse( + (response) => new URL(response.url()).searchParams.get('podname') === 'retry-pod', + ); + logsPending.release(); + await completed; + await page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))), + ); + assert.equal( + await page + .getByTestId('logs-view-window') + .getByText('retry attempt output', { exact: true }) + .count(), + 0, + ); + await page + .getByTestId('logs-view-window') + .getByText('artifact executor output', { exact: true }) + .waitFor(); + await changeTask(page, 'train'); + await logs(page, 'retry attempt output'); + assert.equal(await page.getByRole('button', { name: 'Retry', exact: true }).isDisabled(), true); + assert.equal(fixture.retryCount, 1); + }); +}); + +test('Run Details log output scrolls horizontally and pauses then resumes following new lines', async () => { + await withFixture({ state: 'RUNNING' }, async (page, fixture) => { + const lines = Array.from( + { length: 650 }, + (_, index) => `entry-${String(index).padStart(4, '0')} ${'x'.repeat(140)}`, + ); + fixture.logs.set('train-pod', lines.join('\n')); + await openTask(page); + await logs(page, lines.at(-1)); + const viewer = page.locator('[data-testid="logs-view-window"] [role="grid"]'); + await page.waitForFunction(() => { + const viewer = document.querySelector('[data-testid="logs-view-window"] [role="grid"]'); + return viewer.scrollHeight - viewer.scrollTop - viewer.clientHeight <= 20; + }); + assert.equal(await viewer.evaluate((node) => node.scrollWidth > node.clientWidth), true); + await viewer.evaluate( + (node) => + new Promise((resolve) => { + node.addEventListener('scroll', () => requestAnimationFrame(resolve), { once: true }); + node.scrollTop = 0; + }), + ); + lines.push('appended while reading earlier output'); + fixture.logs.set('train-pod', lines.join('\n')); + await tick(page, '/k8s/pod/logs'); + await page.waitForFunction((count) => { + const viewer = document.querySelector('[data-testid="logs-view-window"] [role="grid"]'); + return viewer.scrollHeight >= count * 15 && viewer.scrollTop <= 20; + }, lines.length); + await viewer.evaluate( + (node) => + new Promise((resolve) => { + node.addEventListener('scroll', () => requestAnimationFrame(resolve), { once: true }); + node.scrollTop = node.scrollHeight; + }), + ); + lines.push('appended while following the tail'); + fixture.logs.set('train-pod', lines.join('\n')); + await tick(page, '/k8s/pod/logs'); + await page.waitForFunction((count) => { + const viewer = document.querySelector('[data-testid="logs-view-window"] [role="grid"]'); + return ( + viewer.scrollHeight >= count * 15 && + viewer.scrollHeight - viewer.scrollTop - viewer.clientHeight <= 20 + ); + }, lines.length); + await screenshot(page, 'run-details-long-logs'); + }); +}); + +test('Embedded Run Details preserves its prefix and namespace for a deep-linked task log', async () => { + await withFixture({ namespace: 'team-b' }, async (page, fixture) => { + await page.goto(`${origin}/embed`); + const app = page.frames().find((frame) => frame.url().includes('/pipeline/')); + assert.ok(app); + await selected(app, 'train'); + await logs(app, 'executor attempt one'); + const request = fixture.requests.find((request) => request.path === '/k8s/pod/logs'); + assert.equal(request.pathname, '/pipeline/k8s/pod/logs'); + assert.equal(request.query.podnamespace, 'team-b'); + await tab(app, 'Task Details').click(); + await app.getByText('task-train', { exact: true }).waitFor(); + await page.reload(); + const reloaded = page.frames().find((frame) => frame.url().includes('/pipeline/')); + assert.ok(reloaded); + await selected(reloaded, 'train'); + }); +}); + +// The failed-task entry point is new presentation behavior, beyond the six captured baseline cases. +test('Run Details View logs opens the real failed runtime task from the run summary', async () => { + await withFixture({ state: 'FAILED' }, async (page, fixture) => { + await page.goto(`${origin}/#/runs/details/${runId}`); + await node(page, 'group').waitFor(); + await page.getByRole('button', { name: 'View logs', exact: true }).click(); + await selected(page, 'train'); + assert.equal( + new URLSearchParams(new URL(page.url()).hash.split('?')[1]).get('task'), + 'task-train', + ); + await page + .getByTestId('logs-view-window') + .getByText('executor attempt one', { exact: true }) + .waitFor(); + assert.equal(fixture.retryCount, 0); + await page.getByRole('switch', { name: 'Dark mode', exact: true }).setChecked(true); + assert.equal( + await page + .getByRole('dialog') + .evaluate((element) => element.closest('.kfp-theme')?.classList.contains('dark')), + true, + ); + await screenshot(page, 'run-details-dark-failed-logs'); + }); +}); + +// Focus behavior is part of the new responsive inspector, beyond the legacy baseline. +test('Run Details inspector restores desktop focus and traps keyboard focus on narrow screens', async () => { + await withFixture({}, async (page) => { + await page.goto(`${origin}/#/runs/details/${runId}`); + const group = node(page, 'group'); + await group.waitFor(); + const trigger = group.locator('button').first(); + await page.evaluate(() => { + document.addEventListener('focusin', (event) => { + if (event.target.closest?.('.react-flow__node[data-id="task.group"]')) + window.kfpFixtureGraphFocusTarget = event.target; + }); + // Safari pointer activation focuses the nearest focusable ancestor. Record + // that real target before React opens the inspector; keyboard activation + // still records and returns to the native node button below. + document.addEventListener( + 'click', + (event) => { + if (event.target.closest?.('.react-flow__node[data-id="task.group"]')) + window.kfpFixturePointerFocusTarget = document.activeElement; + }, + true, + ); + }); + await trigger.focus(); + await trigger.click(); + const inspector = page.getByRole('dialog'); + await inspector.waitFor(); + const desktopReturnTarget = await page.evaluateHandle( + () => window.kfpFixturePointerFocusTarget, + ); + assert.equal(await inspector.getAttribute('aria-modal'), null); + const close = inspector.getByRole('button', { name: 'close', exact: true }); + const resize = inspector.getByRole('separator', { name: 'Resize node details', exact: true }); + const assertWidth = (expected) => + page.waitForFunction((width) => { + const panel = document.querySelector('.kfp-inspector-panel'); + const handle = document.querySelector( + '[role="separator"][aria-label="Resize node details"]', + ); + return ( + panel && + handle && + Number(handle.getAttribute('aria-valuenow')) === width && + Math.abs(panel.getBoundingClientRect().width - width) < 1 + ); + }, expected); + await assertWidth(380); + await resize.focus(); + await page.keyboard.press('ArrowLeft'); + await assertWidth(400); + await page.keyboard.press('ArrowRight'); + await assertWidth(380); + await page.keyboard.press('End'); + await assertWidth(Number(await resize.getAttribute('aria-valuemax'))); + await page.keyboard.press('Home'); + await assertWidth(Number(await resize.getAttribute('aria-valuemin'))); + await page.keyboard.press('ArrowLeft'); + await assertWidth(320); + const handleBox = await resize.boundingBox(); + assert.ok(handleBox); + const handleX = handleBox.x + handleBox.width / 2; + const handleY = handleBox.y + handleBox.height / 2; + await page.mouse.move(handleX, handleY); + await page.mouse.down(); + await page.mouse.move(handleX - 40, handleY, { steps: 4 }); + await page.mouse.up(); + await assertWidth(360); + await tab(page, 'Task Details').click(); + await assertWidth(360); + await resize.focus(); + await page.setViewportSize({ width: 600, height: 900 }); + await page.waitForFunction( + () => + !document.querySelector('[role="separator"][aria-label="Resize node details"]') && + document.activeElement?.getAttribute('aria-label') === 'close', + ); + assert.equal(await inspector.getAttribute('aria-modal'), 'true'); + await page.waitForFunction( + () => + Math.abs( + document.querySelector('.kfp-inspector-panel').getBoundingClientRect().width - innerWidth, + ) < 1, + ); + await page.setViewportSize({ width: 1440, height: 900 }); + await assertWidth(360); + await page.waitForFunction( + () => document.querySelector('[role="dialog"]')?.getAttribute('aria-modal') !== 'true', + ); + await close.focus(); + await page.keyboard.press('Escape'); + await inspector.waitFor({ state: 'hidden' }); + await page.waitForFunction((target) => document.activeElement === target, desktopReturnTarget); + await desktopReturnTarget.dispose(); + await page.setViewportSize({ width: 600, height: 900 }); + // Bring the task into the resized canvas before testing keyboard activation. + await page.getByRole('button', { name: 'Fit view', exact: true }).click(); + await trigger.focus(); + await page.keyboard.press('Enter'); + await inspector.waitFor(); + const narrowReturnTarget = await page.evaluateHandle(() => window.kfpFixtureGraphFocusTarget); + assert.equal(await inspector.getAttribute('aria-modal'), 'true'); + await close.focus(); + await page.keyboard.press('Shift+Tab'); + await page.waitForFunction(() => { + const panel = document.querySelector('[role="dialog"]'); + const controls = [ + ...panel.querySelectorAll('a[href],button,input,select,textarea,[tabindex]'), + ].filter( + (element) => + element.tabIndex >= 0 && + !element.disabled && + element.getBoundingClientRect().width > 0 && + getComputedStyle(element).visibility !== 'hidden', + ); + return document.activeElement === controls.at(-1); + }); + await page.keyboard.press('Tab'); + await page.waitForFunction( + () => document.activeElement?.getAttribute('aria-label') === 'close', + ); + assert.equal( + await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), + true, + ); + await screenshot(page, 'run-details-narrow-inspector'); + await page.keyboard.press('Escape'); + await inspector.waitFor({ state: 'hidden' }); + await page.waitForFunction((target) => document.activeElement === target, narrowReturnTarget); + await narrowReturnTarget.dispose(); + }); +}); + +// Covers the detail selectors and modal dismissal used by the live Selenium smoke tests. +test('Run Details exposes semantic values and restores navigation after closing narrow logs', async () => { + await withFixture({}, async (page) => { + await page.setViewportSize({ width: 780, height: 437 }); + await page.goto(`${origin}/#/runs/details/${runId}`); + await tab(page, 'Details').click(); + const value = (label) => + page.getByText(label, { exact: true }).and(page.locator('dt')).locator('..').locator('dd'); + await value('Status').getByText('Succeeded', { exact: true }).waitFor(); + const detailsPanel = page.getByRole('tabpanel', { name: 'Details', exact: true }); + for (const width of [780, 375]) { + await page.setViewportSize({ width, height: 437 }); + await page.evaluate(async () => { + await document.fonts.ready; + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + }); + assert.ok((await detailsPanel.boundingBox()).height > 0, 'Detail panel must not collapse'); + // Native scrolling and hit testing prove the values can actually be read, + // including when the summary is taller than the available viewport. + await value('Description').click({ trial: true }); + assert.equal(await value('Description').innerText(), 'Run Details browser fixture'); + await value('message').click({ trial: true }); + assert.equal(await value('message').innerText(), 'fixture input'); + await value('Created at').click({ trial: true }); + assert.equal( + await value('Created at').evaluate((element) => Date.parse(element.textContent)), + Date.parse(createdAt), + ); + assert.equal( + await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), + true, + ); + } + await tab(page, 'Graph').click(); + await openTask(page, 'train'); + const inspector = page.getByRole('dialog'); + const narrowReturnTarget = await page.evaluateHandle(() => window.kfpFixtureGraphFocusTarget); + assert.equal(await inspector.getAttribute('aria-modal'), 'true'); + await tab(page, 'Logs').click(); + await page + .getByTestId('logs-view-window') + .getByText('executor attempt one', { exact: true }) + .waitFor(); + await inspector.getByRole('button', { name: 'close', exact: true }).click(); + await inspector.waitFor({ state: 'hidden' }); + await page.getByLabel('Breadcrumbs').getByRole('link', { name: 'Runs', exact: true }).click({ trial: true }); + await page.locator('#runsBtn').click({ trial: true }); + }); +}); + +test('Inspector resize preserves manual zoom and selected-node visibility', async () => { + await withFixture({}, async (page) => { + await page.goto(`${origin}/#/runs/details/${runId}`); + await node(page, 'prepare').waitFor(); + await page.getByRole('button', { name: 'Zoom out', exact: true }).click(); + const measure = () => + page.evaluate(() => { + const canvas = document.querySelector('[data-testid="DagCanvas"]').getBoundingClientRect(); + const matrix = new DOMMatrixReadOnly( + document.querySelector('.react-flow__viewport').style.transform, + ); + const selected = document + .querySelector('.react-flow__node.selected') + ?.getBoundingClientRect(); + return { + width: canvas.width, + zoom: matrix.a, + x: matrix.e, + visible: + !selected || + (selected.left >= canvas.left && + selected.right <= canvas.right && + selected.top >= canvas.top && + selected.bottom <= canvas.bottom), + }; + }); + const before = await measure(); + await node(page, 'prepare').locator('button').first().click(); + const inspector = page.getByRole('dialog'); + await inspector.waitFor(); + await page.waitForFunction( + (width) => document.querySelector('[data-testid="DagCanvas"]').clientWidth < width, + before.width, + ); + const opened = await measure(); + assert.equal(opened.zoom, before.zoom); + assert.ok(opened.visible); + const resize = inspector.getByRole('separator', { name: 'Resize node details' }); + await resize.focus(); + await page.keyboard.press('ArrowLeft'); + await page.waitForFunction( + (width) => document.querySelector('[data-testid="DagCanvas"]').clientWidth < width, + opened.width, + ); + const resized = await measure(); + assert.equal(resized.zoom, before.zoom); + assert.ok(resized.visible); + assert.notEqual(resized.x, opened.x); + await inspector.getByRole('button', { name: 'close', exact: true }).click(); + await inspector.waitFor({ state: 'hidden' }); + await page.waitForFunction( + (width) => document.querySelector('[data-testid="DagCanvas"]').clientWidth === width, + before.width, + ); + const closed = await measure(); + assert.equal(closed.zoom, before.zoom); + assert.equal(closed.width, before.width); + assert.notEqual(closed.x, resized.x); + }); +}); diff --git a/frontend/scripts/ui-modernization-runs.smoke.mjs b/frontend/scripts/ui-modernization-runs.smoke.mjs new file mode 100644 index 00000000000..619f41489de --- /dev/null +++ b/frontend/scripts/ui-modernization-runs.smoke.mjs @@ -0,0 +1,843 @@ +/* + * Copyright 2026 The Kubeflow Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Run after npm run build: node --test scripts/ui-modernization-runs.smoke.mjs +// Fixed HTTP fixtures exercise the production bundle; no cluster or external network is used. +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { after, before, test } from 'node:test'; +import { launchBrowser } from './browser-launch.mjs'; + +const origin = 'http://kfp.test'; +const build = new URL('../build/', import.meta.url); +const pipelineSpec = { + pipelineInfo: { name: 'runs-browser-fixture' }, + root: { dag: { tasks: {} } }, + schemaVersion: '2.1.0', + sdkVersion: 'kfp-2.0.0', +}; +const pipelineVersion = { + pipeline_id: 'pipeline-a', + pipeline_version_id: 'version-a', + display_name: 'Version one', + pipeline_spec: pipelineSpec, +}; +const states = ['SUCCEEDED', 'FAILED', 'RUNNING', 'PENDING', 'CANCELED', 'SKIPPED', 'PAUSED']; +const runId = (index) => `run-${String(index).padStart(2, '0')}`; +const runName = (index) => `Training ${String(index).padStart(2, '0')}`; +let browser; +before(async () => { + browser = await launchBrowser(); + if (process.env.KFP_EXPECTED_BROWSER_VERSION) { + assert.equal( + browser.version(), + process.env.KFP_EXPECTED_BROWSER_VERSION, + 'Browser version must match KFP_EXPECTED_BROWSER_VERSION; select the intended browser binary', + ); + } +}); +after(async () => browser?.close()); + +function makeRuns() { + return Array.from({ length: 18 }, (_, offset) => ({ + run_id: runId(offset + 1), + display_name: runName(offset + 1), + experiment_id: 'experiment-a', + storage_state: offset < 15 ? 'AVAILABLE' : 'ARCHIVED', + state: offset < 15 ? states[offset % states.length] : 'SUCCEEDED', + created_at: new Date(Date.UTC(2026, 0, 20 - offset)).toISOString(), + finished_at: new Date(Date.UTC(2026, 0, 20 - offset, 0, 1)).toISOString(), + pipeline_version_reference: { + pipeline_id: 'pipeline-a', + pipeline_version_id: offset === 7 ? 'deleted-version' : 'version-a', + }, + pipeline_spec: pipelineSpec, + run_details: { task_details: [] }, + })); +} + +function decodeFilter(value) { + // Existing clients encode the filter JSON before the generated query-string encoder. + return JSON.parse(value.startsWith('%') ? decodeURIComponent(value) : value); +} + +function gate() { + let release, enter; + const promise = new Promise((resolve) => { + release = resolve; + }); + const entered = new Promise((resolve) => { + enter = resolve; + }); + return { promise, release, entered, enter }; +} + +async function withFixture(options, exercise) { + const context = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + const page = await context.newPage(); + page.setDefaultTimeout(10000); + const fixture = { + runs: makeRuns(), + lists: [], + searches: [], + mutations: [], + versionReads: [], + errors: [], + forbidden: new Set(), + failNextList: options.failNextList || false, + }; + let requests = 0; + page.on('pageerror', (error) => fixture.errors.push(error.message)); + await context.addInitScript(({ namespace, useDefaultPageSize }) => { + // Retain a saved ten-row preference for fixtures that exercise token pagination. + if (!useDefaultPageSize && !localStorage.getItem('tablePageSize_runs')) { + localStorage.setItem('tablePageSize_runs', '10'); + } + if (!localStorage.getItem('kfp.theme')) localStorage.setItem('kfp.theme', 'light'); + if (namespace) { + window.centraldashboard = { + CentralDashboardEventHandler: { + init(callback) { + const handler = {}; + callback(handler); + handler.onNamespaceSelected(namespace); + window.selectFixtureNamespace = (value) => handler.onNamespaceSelected(value); + }, + }, + }; + } + }, options); + await context.route('**/*', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const path = url.pathname.replace(/^\/pipelines(?=\/)/, ''); + const json = (body, status = 200) => + route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }); + try { + assert.equal(url.origin, origin, 'requests must stay inside the fixture origin'); + assert.ok(++requests <= 250, 'fixture request count must remain bounded'); + if (path === '/embed') { + return route.fulfill({ + contentType: 'text/html', + body: 'Dashboard fixture', + }); + } + if (path === '/' || path.startsWith('/static/')) { + const name = path === '/' ? 'index.html' : path.slice(1); + let body = await readFile(new URL(name, build)); + if (path === '/') { + body = body + .toString() + .replace( + /window\.KFP_FLAGS\.DEPLOYMENT\s*=\s*null;?/, + `window.KFP_FLAGS.DEPLOYMENT=${JSON.stringify(options.namespace ? 'KUBEFLOW' : null)};`, + ) + .replace( + /window\.KFP_FLAGS\.HIDE_SIDENAV\s*=\s*null;?/, + `window.KFP_FLAGS.HIDE_SIDENAV=${!!options.embedded};`, + ); + } + const contentType = name.endsWith('.js') + ? 'text/javascript' + : name.endsWith('.css') + ? 'text/css' + : name.endsWith('.html') + ? 'text/html' + : 'application/octet-stream'; + return route.fulfill({ body, contentType }); + } + if (path === '/apis/v2/healthz') return json({ apiServerTagName: 'fixture' }); + if (path === '/system/cluster-name' || path === '/system/project-id') { + if (fixture.metadataGate) { + fixture.metadataGate.enter(); + await fixture.metadataGate.promise; + } + return route.fulfill({ + body: options.metadata + ? path.endsWith('cluster-name') + ? 'test-cluster' + : 'test-project' + : '', + }); + } + if ( + ['/apis/v2/runs', '/apis/v2/pipelines', '/apis/v2/experiments'].includes( + path, + ) && + url.searchParams.get('page_size') === '5' + ) { + assert.equal(request.method(), 'GET'); + const query = Object.fromEntries(url.searchParams); + assert.equal(query.sort_by, 'created_at desc'); + assert.equal( + query.page_token, + undefined, + 'palette searches must not scan subsequent pages', + ); + if (path.endsWith('/runs')) assert.equal(query.skip_count, 'true'); + const predicates = decodeFilter(query.filter).predicates; + assert.equal(predicates.length, 1); + assert.equal(predicates[0].key, 'name'); + assert.equal(predicates[0].operation, 'IS_SUBSTRING'); + assert.ok(predicates[0].string_value.length >= 2); + fixture.searches.push({ path, ...query }); + const matches = (item) => + item.display_name.toLowerCase().includes(predicates[0].string_value.toLowerCase()); + if (path.endsWith('/runs')) { + const runs = query.namespace === 'team-b' ? fixture.runs.slice(0, 2) : fixture.runs; + return json({ runs: runs.filter(matches).slice(0, 5), next_page_token: 'do-not-follow' }); + } + if (path.endsWith('/pipelines')) + return json({ + pipelines: [{ pipeline_id: 'pipeline-a', display_name: 'Training pipeline' }].filter( + matches, + ), + }); + return json({ + experiments: [ + { experiment_id: 'experiment-a', display_name: 'Training experiment' }, + ].filter(matches), + }); + } + if (path === '/apis/v2/runs' && request.method() === 'GET') { + assert.equal(url.pathname, `${options.embedded ? '/pipelines' : ''}/apis/v2/runs`); + const query = Object.fromEntries(url.searchParams); + assert.equal(query.skip_count, 'true'); + assert.ok([10, 20, 50, 100].includes(Number(query.page_size))); + assert.match(query.sort_by, /^(created_at|name)( (asc|desc))?$/); + const predicates = decodeFilter(query.filter).predicates; + const storage = predicates.find((predicate) => predicate.key === 'storage_state'); + assert.equal(storage.string_value, 'ARCHIVED'); + assert.ok(['EQUALS', 'NOT_EQUALS'].includes(storage.operation)); + const archived = storage.operation === 'EQUALS'; + const names = predicates.filter((predicate) => predicate.key === 'name'); + assert.ok(names.every((predicate) => predicate.operation === 'IS_SUBSTRING')); + assert.equal( + predicates.length, + names.length + 1, + 'only name and storage filters are expected', + ); + fixture.lists.push({ ...query, predicates }); + if (fixture.listGate) { + fixture.listGate.enter(); + await fixture.listGate.promise; + } + if (fixture.failNextList) { + fixture.failNextList = false; + return json({ code: 14, message: 'Fixture list temporarily unavailable' }, 503); + } + let runs = fixture.runs.filter((run) => (run.storage_state === 'ARCHIVED') === archived); + if (query.namespace === 'team-b') runs = runs.slice(0, 2); + runs = runs.filter((run) => + names.every((predicate) => + run.display_name.toLowerCase().includes(predicate.string_value.toLowerCase()), + ), + ); + const [sort, direction = 'asc'] = query.sort_by.split(' '); + runs.sort( + (a, b) => + (sort === 'name' + ? a.display_name.localeCompare(b.display_name) + : a.created_at.localeCompare(b.created_at)) * (direction === 'desc' ? -1 : 1), + ); + const offset = query.page_token + ? Number(query.page_token.replace(/^fixture-offset-/, '')) + : 0; + assert.ok(!query.page_token || /^fixture-offset-\d+$/.test(query.page_token)); + const end = offset + Number(query.page_size); + return json({ + runs: runs.slice(offset, end), + next_page_token: end < runs.length ? `fixture-offset-${end}` : '', + }); + } + const tasks = /^\/apis\/v2\/runs\/([^/]+)\/tasks$/.exec(path); + if (tasks) { + assert.equal(request.method(), 'GET'); + assert.ok(fixture.runs.some((run) => run.run_id === tasks[1])); + return json({ tasks: [] }); + } + const run = /^\/apis\/v2\/runs\/([^/:]+)(?::(archive|unarchive))?$/.exec(path); + if (run) { + const id = decodeURIComponent(run[1]); + const item = fixture.runs.find((candidate) => candidate.run_id === id); + assert.ok(item, `unknown fixture run ${id}`); + if (request.method() === 'GET' && !run[2]) return json(item); + const action = run[2] || 'delete'; + assert.equal(request.method(), action === 'delete' ? 'DELETE' : 'POST'); + assert.equal(request.postData(), null, 'run mutations have no body'); + fixture.mutations.push({ id, action }); + if (fixture.forbidden.has(id)) + return json({ code: 7, message: 'Fixture permission denied' }, 403); + if (action === 'delete') + fixture.runs = fixture.runs.filter((candidate) => candidate !== item); + else item.storage_state = action === 'archive' ? 'ARCHIVED' : 'AVAILABLE'; + return json({}); + } + if (path === '/apis/v2/experiments') + return json({ + experiments: [{ experiment_id: 'experiment-a', display_name: 'Browser experiment' }], + }); + if (path === '/apis/v2/experiments/experiment-a') + return json({ experiment_id: 'experiment-a', display_name: 'Browser experiment' }); + if (path === '/apis/v2/pipelines/pipeline-a') + return json({ pipeline_id: 'pipeline-a', display_name: 'Browser pipeline' }); + if (path === '/apis/v2/pipelines/pipeline-a/versions/deleted-version') { + fixture.versionReads.push(path); + return json({ code: 5, message: 'Fixture deleted version' }, 404); + } + if (path === '/apis/v2/pipelines/pipeline-a/versions') + return json({ pipeline_versions: [pipelineVersion] }); + if (path === '/apis/v2/pipelines/pipeline-a/versions/version-a') { + fixture.versionReads.push(path); + return json(pipelineVersion); + } + if (path === '/apis/v2/artifacts' || path === '/apis/v2/artifact_tasks') + return json({}); + throw new Error(`Unexpected fixture request: ${request.method()} ${path}`); + } catch (error) { + fixture.errors.push(error.message); + await json({ message: error.message }, 500); + } + }); + try { + await exercise(page, fixture); + assert.deepEqual( + fixture.errors, + [], + 'production bundle and HTTP contracts must have no unexpected errors', + ); + } catch (error) { + if (fixture.errors.length) console.error('Fixture errors:', fixture.errors); + throw error; + } finally { + fixture.listGate?.release(); + fixture.metadataGate?.release(); + await context.close(); + } +} + +const link = (page, index) => + page.locator(`[data-testid="run-name-link"][data-run-id="${runId(index)}"]`); +const row = (page, index) => page.getByRole('row').filter({ has: link(page, index) }); +const checkbox = (page, index) => row(page, index).getByRole('checkbox'); +async function ready(page, count = 10) { + await page.locator('table[aria-label="Runs"]').waitFor(); + await page.waitForFunction((expected) => { + const table = document.querySelector('table[aria-label="Runs"]'); + return ( + table?.getAttribute('aria-busy') === 'false' && + table.querySelectorAll('[data-testid="run-name-link"]').length === expected + ); + }, count); +} +async function openRuns(page, archived = false) { + await page.goto(`${origin}/#/${archived ? 'archive/runs' : 'runs'}`); + await ready(page, archived ? 3 : 10); +} +async function confirm(page, action) { + await page.getByRole('button', { name: action, exact: true }).click(); + const dialog = page.getByRole('dialog'); + await dialog.waitFor(); + await dialog.getByRole('button', { name: action, exact: true }).click(); +} + +async function reloadRows(page, action, count = 10) { + const host = typeof page.page === 'function' ? page.page() : page; + const response = host.waitForResponse( + (response) => + new URL(response.url()).pathname.endsWith('/apis/v2/runs') && + response.request().method() === 'GET', + ); + await action(); + await response; + await ready(page, count); +} + +async function focused(page, locator) { + const element = await locator.elementHandle(); + assert.ok(element); + try { + try { + await page.waitForFunction((node) => node === document.activeElement, element); + } catch (error) { + const actual = await page.evaluate(() => document.activeElement?.outerHTML.slice(0, 500)); + throw new Error(`Expected focus on ${locator}; actual active element: ${actual}`, { + cause: error, + }); + } + } finally { + await element.dispose(); + } +} + +async function screenshot(page, name) { + if (!process.env.KFP_RUNS_SCREENSHOT_DIR) return; + const { mkdir } = await import('node:fs/promises'); + const { join } = await import('node:path'); + await mkdir(process.env.KFP_RUNS_SCREENSHOT_DIR, { recursive: true }); + await page.screenshot({ + path: join(process.env.KFP_RUNS_SCREENSHOT_DIR, `${name}.png`), + animations: 'disabled', + }); +} + +test('Runs defaults to twenty rows when no page-size preference exists', async () => { + await withFixture({ useDefaultPageSize: true }, async (page, fixture) => { + await page.goto(`${origin}/#/runs`); + await ready(page, 15); + assert.equal(fixture.lists[0].page_size, '20'); + assert.equal(await page.getByRole('combobox', { name: 'Rows per page' }).inputValue(), '20'); + assert.equal( + await page.getByRole('button', { name: 'Next page', exact: true }).isEnabled(), + false, + ); + }); +}); + +test('Runs preserves token paging, filtering, sorting, and page selection contracts', async () => { + await withFixture({}, async (page, fixture) => { + await openRuns(page); + assert.equal(fixture.lists[0].sort_by, 'created_at desc'); + assert.equal(fixture.lists[0].page_size, '10'); + assert.deepEqual( + fixture.versionReads.toSorted(), + [ + '/apis/v2/pipelines/pipeline-a/versions/deleted-version', + '/apis/v2/pipelines/pipeline-a/versions/version-a', + ], + 'each distinct referenced version is fetched once per page load', + ); + assert.match(await row(page, 8).innerText(), /Unavailable/); + for (const state of ['Succeeded', 'Failed', 'Running', 'Pending', 'Canceled']) { + assert.ok((await page.getByRole('table', { name: 'Runs' }).innerText()).includes(state)); + } + await screenshot(page, 'runs-light'); + await reloadRows(page, () => page.getByRole('button', { name: 'Next page' }).click(), 5); + assert.equal(fixture.lists.at(-1).page_token, 'fixture-offset-10'); + assert.equal(await link(page, 11).count(), 1); + await reloadRows(page, () => page.getByRole('button', { name: 'Previous page' }).click()); + assert.equal(fixture.lists.at(-1).page_token || '', ''); + await reloadRows(page, () => page.getByRole('button', { name: 'Next page' }).click(), 5); + await reloadRows( + page, + () => page.getByRole('searchbox', { name: 'Search runs' }).fill('Training 15'), + 1, + ); + assert.deepEqual( + fixture.lists.at(-1).predicates.find((item) => item.key === 'name'), + { + key: 'name', + operation: 'IS_SUBSTRING', + string_value: 'Training 15', + }, + ); + assert.equal(fixture.lists.at(-1).page_token || '', ''); + await reloadRows(page, () => + page.getByRole('button', { name: 'Clear filter', exact: true }).click(), + ); + await reloadRows(page, () => page.getByRole('button', { name: 'Run', exact: true }).click()); + assert.equal(fixture.lists.at(-1).sort_by, 'name'); + assert.equal( + await page.getByRole('columnheader', { name: 'Run', exact: true }).getAttribute('aria-sort'), + 'ascending', + ); + await reloadRows(page, () => page.getByRole('button', { name: 'Run', exact: true }).click()); + assert.equal(fixture.lists.at(-1).sort_by, 'name desc'); + await reloadRows( + page, + () => page.getByRole('combobox', { name: 'Rows per page' }).selectOption('20'), + 15, + ); + assert.equal(fixture.lists.at(-1).page_size, '20'); + assert.equal(fixture.lists.at(-1).page_token || '', ''); + await page.getByRole('checkbox', { name: 'Select all runs on this page' }).check(); + assert.equal(await page.getByRole('checkbox', { checked: true }).count(), 16); + assert.equal(new URL(page.url()).hash, '#/runs'); + await page.getByRole('checkbox', { name: 'Select all runs on this page' }).uncheck(); + assert.equal(await page.getByRole('checkbox', { checked: true }).count(), 0); + await checkbox(page, 1).check(); + await reloadRows( + page, + () => page.getByRole('button', { name: 'Refresh', exact: true }).click(), + 15, + ); + assert.equal(await checkbox(page, 1).isChecked(), true); + }); +}); + +test('Runs row clicks toggle selection without navigation', async () => { + await withFixture({}, async (page) => { + await openRuns(page); + assert.equal(await checkbox(page, 1).isChecked(), false); + for (const checked of [true, false]) { + await row(page, 1).getByRole('cell').last().click(); + await page.waitForFunction( + ({ id, checked }) => { + const row = document.querySelector(`[data-row-id="${id}"]`); + return ( + row?.querySelector('[role="checkbox"]')?.getAttribute('aria-checked') === + String(checked) + ); + }, + { id: runId(1), checked }, + ); + assert.equal(await checkbox(page, 1).isChecked(), checked); + assert.equal(new URL(page.url()).hash, '#/runs'); + } + }); +}); + +test('Runs checkboxes select while links, clone, and comparison navigate', async () => { + await withFixture({}, async (page) => { + await openRuns(page); + await checkbox(page, 1).focus(); + await page.keyboard.press('Space'); + assert.equal(await checkbox(page, 1).isChecked(), true); + assert.equal(new URL(page.url()).hash, '#/runs'); + await page.getByRole('button', { name: 'Clone run', exact: true }).click(); + await page.waitForURL((url) => url.hash === '#/runs/new?cloneFromRun=run-01'); + await openRuns(page); + await checkbox(page, 1).check(); + await checkbox(page, 2).check(); + await page.getByRole('button', { name: 'Compare runs', exact: true }).click(); + await page.waitForURL((url) => url.hash.startsWith('#/compare?')); + assert.deepEqual( + new URLSearchParams(new URL(page.url()).hash.split('?')[1]).get('runlist').split(',').sort(), + ['run-01', 'run-02'], + ); + await openRuns(page); + await row(page, 1).getByRole('link', { name: 'Version one', exact: true }).click(); + await page.waitForURL((url) => url.hash === '#/pipelines/details/pipeline-a/version/version-a'); + await openRuns(page); + await link(page, 1).click(); + await page.waitForURL((url) => url.hash === '#/runs/details/run-01'); + await openRuns(page); + await link(page, 2).click(); + await page.waitForURL((url) => url.hash === '#/runs/details/run-02'); + }); +}); + +test('Runs confirms archive once, retains forbidden selections, and restores or deletes archived runs', async () => { + await withFixture({}, async (page, fixture) => { + await openRuns(page); + await checkbox(page, 1).check(); + await page.getByRole('button', { name: 'Archive', exact: true }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Cancel', exact: true }).click(); + assert.deepEqual(fixture.mutations, []); + assert.equal(await checkbox(page, 1).isChecked(), true); + await reloadRows(page, () => confirm(page, 'Archive')); + assert.deepEqual(fixture.mutations, [{ id: 'run-01', action: 'archive' }]); + assert.equal(await link(page, 1).count(), 0); + fixture.forbidden.add('run-05'); + await checkbox(page, 2).check(); + await checkbox(page, 5).check(); + await reloadRows(page, () => confirm(page, 'Archive')); + const failure = page.getByRole('dialog', { name: 'Failed to archive 1 run' }); + await failure.waitFor(); + assert.match(await failure.innerText(), /Failed to archive run: run-05/); + await failure.getByRole('button', { name: 'Dismiss', exact: true }).click(); + assert.equal(await checkbox(page, 5).isChecked(), true); + assert.equal(await link(page, 2).count(), 0); + assert.deepEqual( + fixture.mutations.toSorted((a, b) => a.id.localeCompare(b.id)), + [ + { id: 'run-01', action: 'archive' }, + { id: 'run-02', action: 'archive' }, + { id: 'run-05', action: 'archive' }, + ], + ); + await reloadRows( + page, + () => + page + .getByRole('group', { name: 'Run scope' }) + .getByRole('button', { name: 'Archived', exact: true }) + .click(), + 5, + ); + assert.equal(new URL(page.url()).hash, '#/archive/runs'); + assert.equal(fixture.lists.at(-1).predicates[0].operation, 'EQUALS'); + await checkbox(page, 1).check(); + await reloadRows(page, () => confirm(page, 'Restore'), 4); + await checkbox(page, 16).check(); + await reloadRows(page, () => confirm(page, 'Delete'), 3); + assert.deepEqual(fixture.mutations.slice(-2), [ + { id: 'run-01', action: 'unarchive' }, + { id: 'run-16', action: 'delete' }, + ]); + assert.equal(await link(page, 16).count(), 0); + await reloadRows(page, () => + page + .getByRole('group', { name: 'Run scope' }) + .getByRole('button', { name: 'Active', exact: true }) + .click(), + ); + assert.equal(await link(page, 1).count(), 1); + }); +}); + +test('Runs recovers after an API error and keeps dialogs themed, keyboard accessible, and contained on mobile', async () => { + await withFixture({ failNextList: true }, async (page, fixture) => { + await page.goto(`${origin}/#/runs`); + const alert = page.getByRole('alert'); + await alert.waitFor(); + await reloadRows(page, () => + alert.getByRole('button', { name: 'Refresh', exact: true }).click(), + ); + assert.equal(await alert.count(), 0); + assert.equal(fixture.lists.length, 2); + await checkbox(page, 1).check(); + await page.getByRole('button', { name: 'Archive', exact: true }).click(); + const dialog = page.getByRole('dialog'); + await dialog.waitFor(); + await focused(page, dialog.getByRole('button', { name: 'Cancel', exact: true })); + const lightBackground = await dialog.evaluate((node) => getComputedStyle(node).backgroundColor); + await page.keyboard.press('Escape'); + await dialog.waitFor({ state: 'hidden' }); + await focused(page, page.getByRole('button', { name: 'Archive', exact: true })); + assert.deepEqual(fixture.mutations, []); + await page.getByRole('switch', { name: 'Dark mode', exact: true }).setChecked(true); + await screenshot(page, 'runs-dark'); + await page.getByRole('button', { name: 'Archive', exact: true }).click(); + await dialog.waitFor(); + assert.notEqual( + await dialog.evaluate((node) => getComputedStyle(node).backgroundColor), + lightBackground, + ); + assert.equal(await dialog.evaluate((node) => !!node.closest('.kfp-theme.dark')), true); + await focused(page, dialog.getByRole('button', { name: 'Cancel', exact: true })); + await page.keyboard.press('Shift+Tab'); + await focused(page, dialog.getByRole('button', { name: 'Archive', exact: true })); + await page.keyboard.press('Tab'); + await focused(page, dialog.getByRole('button', { name: 'Cancel', exact: true })); + await screenshot(page, 'runs-dark-archive-dialog'); + await dialog.getByRole('button', { name: 'Cancel', exact: true }).click(); + await page.reload(); + await ready(page); + assert.equal( + await page.getByRole('switch', { name: 'Dark mode', exact: true }).isChecked(), + true, + ); + await page.setViewportSize({ width: 375, height: 812 }); + assert.equal( + await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), + true, + ); + const region = page.getByRole('region', { name: 'Runs table', exact: true }); + assert.equal(await region.evaluate((node) => node.scrollWidth > node.clientWidth), true); + await page.getByRole('searchbox', { name: 'Search runs' }).focus(); + assert.equal( + await page + .getByRole('searchbox', { name: 'Search runs' }) + .evaluate((node) => node === document.activeElement), + true, + ); + }); +}); + +test('Embedded Runs preserves its URL prefix and clears selection when the dashboard namespace changes', async () => { + await withFixture({ namespace: 'team-a', embedded: true }, async (page, fixture) => { + await page.goto(`${origin}/embed`); + const app = page.frames().find((frame) => frame.url().includes('/pipelines/')); + assert.ok(app, 'the dashboard fixture must load the production app in its iframe'); + await ready(app); + assert.equal(fixture.lists.at(-1).namespace, 'team-a'); + assert.equal(await app.getByRole('combobox', { name: 'Theme', exact: true }).count(), 1); + await checkbox(app, 1).check(); + await reloadRows(app, () => app.evaluate(() => window.selectFixtureNamespace('team-b')), 2); + assert.equal(fixture.lists.at(-1).namespace, 'team-b'); + assert.equal(fixture.lists.at(-1).page_token || '', ''); + assert.equal(await checkbox(app, 1).isChecked(), false); + assert.equal(new URL(app.url()).pathname, '/pipelines/'); + }); +}); + +test('Command palette searches the active namespace and preserves keyboard navigation and focus', async () => { + await withFixture({ namespace: 'team-a' }, async (page, fixture) => { + await openRuns(page); + const trigger = page.getByRole('button', { name: 'Search', exact: true }); + await trigger.focus(); + await page.keyboard.press('Control+k'); + const dialog = page.getByRole('dialog', { name: 'Search and navigate' }); + const input = dialog.getByRole('searchbox'); + await focused(page, input); + await input.fill('Training'); + await dialog.getByRole('link', { name: 'Training experiment', exact: true }).waitFor(); + await screenshot(page, 'command-palette-light'); + assert.equal(fixture.searches.length, 3); + assert.ok(fixture.searches.every((request) => request.namespace === 'team-a')); + assert.equal( + await dialog.getByRole('region', { name: 'Runs', exact: true }).getByRole('link').count(), + 5, + ); + await page.keyboard.press('ArrowDown'); + await focused(page, dialog.getByRole('link', { name: 'Training 01', exact: true })); + await page.keyboard.press('ArrowDown'); + await focused(page, dialog.getByRole('link', { name: 'Training 02', exact: true })); + await page.keyboard.press('ArrowUp'); + await focused(page, dialog.getByRole('link', { name: 'Training 01', exact: true })); + // Safari's default macOS keyboard policy includes links with Option-Tab. + // https://support.apple.com/guide/safari/cpsh003/mac + // Windows WebKit defaults TabsToLinks=false and has no Option-Tab inversion. + // Assert its exact native Tab destination as well as arrow-key reachability. + // https://github.com/WebKit/WebKit/blob/486de399887bc8fa8a69e2f194ebc9476589a08a/Source/WTF/Scripts/Preferences/UnifiedWebPreferences.yaml#L7162 + const windowsWebKit = process.env.KFP_BROWSER === 'webkit' && process.platform === 'win32'; + await page.keyboard.press( + process.env.KFP_BROWSER === 'webkit' && process.platform === 'darwin' ? 'Alt+Tab' : 'Tab', + ); + await focused( + page, + windowsWebKit + ? dialog.getByRole('button', { name: 'Close', exact: true }) + : dialog.getByRole('link', { name: 'Training 02', exact: true }), + ); + await input.focus(); + await page.keyboard.press('Shift+Tab'); + await focused(page, dialog.getByRole('button', { name: 'Close', exact: true })); + await page.keyboard.press('Tab'); + await focused(page, input); + await page.keyboard.press('Escape'); + await dialog.waitFor({ state: 'hidden' }); + await focused(page, trigger); + await page.getByRole('switch', { name: 'Dark mode', exact: true }).setChecked(true); + await trigger.click(); + await focused(page, input); + await input.fill('Training'); + await dialog.getByRole('link', { name: 'Training experiment', exact: true }).waitFor(); + await page.evaluate(() => window.selectFixtureNamespace('team-b')); + await dialog.getByText('Namespace: team-b', { exact: true }).waitFor(); + await dialog.getByRole('link', { name: 'Training experiment', exact: true }).waitFor(); + assert.equal( + await dialog.evaluate((element) => element.closest('.kfp-theme')?.classList.contains('dark')), + true, + ); + await screenshot(page, 'command-palette-dark'); + assert.equal(fixture.searches.length, 9); + assert.ok(fixture.searches.slice(-3).every((request) => request.namespace === 'team-b')); + assert.equal( + await dialog.getByRole('region', { name: 'Runs', exact: true }).getByRole('link').count(), + 2, + ); + await dialog.getByRole('link', { name: 'Training 01', exact: true }).click(); + await page.waitForURL((url) => url.hash === '#/runs/details/run-01'); + await dialog.waitFor({ state: 'hidden' }); + await trigger.click(); + await dialog.getByRole('link', { name: 'Runs', exact: true }).click(); + await page.waitForURL((url) => url.hash === '#/runs'); + await ready(page, 2); + }); +}); + +test('Runs keeps columns, pagination and sidebar controls stable across held loading and filtering', async () => { + await withFixture({ metadata: true }, async (page, fixture) => { + fixture.listGate = gate(); + fixture.metadataGate = gate(); + await page.goto(`${origin}/#/runs`); + await fixture.listGate.entered; + await page.getByRole('status').filter({ hasText: 'Loading runs…' }).waitFor(); + const geometry = async () => { + await page.evaluate(() => + document.fonts.ready.then( + () => + new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))), + ), + ); + return page.evaluate(() => { + const rect = (element) => { + const { x, y, width, height } = element.getBoundingClientRect(); + return { x, y, width, height }; + }; + return { + footer: rect(document.querySelector('.kfp-runs-table-footer')), + filter: rect(document.querySelector('input[type="search"]')), + sidebar: rect(document.querySelector('.kfp-shell-footer')), + theme: rect(document.querySelector('[role="switch"][aria-label="Dark mode"]')), + columns: [...document.querySelectorAll('table[aria-label="Runs"] th')].map(rect), + }; + }); + }; + const same = (before, after) => { + for (const key of ['footer', 'filter', 'sidebar', 'theme']) + for (const dimension of ['x', 'y', 'width', 'height']) + assert.ok( + Math.abs(before[key][dimension] - after[key][dimension]) <= 1, + `${key}.${dimension}: ${before[key][dimension]} -> ${after[key][dimension]}`, + ); + assert.equal(before.columns.length, after.columns.length); + before.columns.forEach((column, index) => { + for (const dimension of ['x', 'width']) + assert.ok( + Math.abs(column[dimension] - after.columns[index][dimension]) <= 1, + `column${index}.${dimension} moved`, + ); + }); + }; + const initial = await geometry(); + fixture.listGate.release(); + await ready(page, 10); + same(initial, await geometry()); + fixture.metadataGate.release(); + await page.getByText('Cluster: test-cluster', { exact: true }).waitFor(); + await page.getByText('Project: test-project', { exact: true }).waitFor(); + same(initial, await geometry()); + for (const [filter, expected] of [ + ['Training 15', 1], + ['no-such-run', 0], + ['', 10], + ]) { + fixture.listGate = gate(); + await page.getByRole('searchbox', { name: 'Search runs' }).fill(filter); + await fixture.listGate.entered; + same(initial, await geometry()); + fixture.listGate.release(); + await ready(page, expected); + same(initial, await geometry()); + const ids = await page + .locator('[data-testid="run-name-link"]') + .evaluateAll((links) => links.map((link) => link.getAttribute('data-run-id'))); + assert.deepEqual( + ids, + expected === 1 + ? [runId(15)] + : expected === 0 + ? [] + : Array.from({ length: 10 }, (_, index) => runId(index + 1)), + ); + if (expected === 0) await page.getByText('No runs match', { exact: true }).waitFor(); + assert.equal(fixture.lists.at(-1).page_token || '', ''); + } + await page.getByRole('button', { name: 'Collapse navigation', exact: true }).click(); + assert.equal(await page.locator('.kfp-shell-metadata').count(), 0); + await page.setViewportSize({ width: 375, height: 812 }); + assert.equal(await page.locator('.kfp-shell-metadata').count(), 0); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)); + const scroll = page.getByRole('region', { name: 'Runs table', exact: true }); + await scroll.focus(); + await page.keyboard.press('End'); + assert.equal( + await page.getByRole('button', { name: 'Next page', exact: true }).isEnabled(), + true, + ); + const fontRequests = await page.evaluate(() => + performance + .getEntriesByType('resource') + .filter((entry) => /\.woff2$/.test(entry.name)) + .map((entry) => entry.name), + ); + assert.equal(new Set(fontRequests).size, 4, 'four critical font faces are loaded'); + assert.equal(fontRequests.length, 4, 'each critical font face loads once'); + }); +}); diff --git a/frontend/scripts/ui-modernization-workflows.smoke.mjs b/frontend/scripts/ui-modernization-workflows.smoke.mjs new file mode 100644 index 00000000000..af1a99f217e --- /dev/null +++ b/frontend/scripts/ui-modernization-workflows.smoke.mjs @@ -0,0 +1,1007 @@ +/* + * Copyright 2026 The Kubeflow Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Run after npm run build: node --test scripts/ui-modernization-workflows.smoke.mjs +// Local HTTP fixtures exercise the production bundle. They do not qualify cluster authorization. +import assert from 'node:assert/strict'; +import { mkdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { after, before, test } from 'node:test'; +import { launchBrowser } from './browser-launch.mjs'; + +const origin = 'http://kfp.test'; +const build = new URL('../build/', import.meta.url); +const createdAt = '2026-09-26T12:00:00.000Z'; +const parameters = { + count: { parameterType: 'NUMBER_INTEGER', defaultValue: 0 }, + enabled: { parameterType: 'BOOLEAN', defaultValue: false }, + message: { parameterType: 'STRING', defaultValue: '' }, + choice: { parameterType: 'STRING', defaultValue: '', literals: ['', 'production'] }, + config: { parameterType: 'STRUCT', defaultValue: { nested: false } }, +}; +const pipelineSpec = { + pipelineInfo: { name: 'workflows-browser-fixture' }, + root: { inputDefinitions: { parameters }, dag: { tasks: {} } }, + deploymentSpec: { executors: {} }, + schemaVersion: '2.1.0', + sdkVersion: 'kfp-2.0.0', +}; +const pipeline = { + pipeline_id: 'pipeline-a', + display_name: 'Workflow pipeline', + created_at: createdAt, +}; +const version = { + pipeline_id: pipeline.pipeline_id, + pipeline_version_id: 'version-a', + display_name: 'Version one', + created_at: createdAt, + pipeline_spec: pipelineSpec, +}; +const latestVersion = { + ...version, + pipeline_version_id: 'version-latest', + display_name: 'Latest version', +}; +const expectedParameters = { + count: 0, + enabled: false, + message: '', + choice: '', + config: { nested: false }, +}; +const runCreationPath = + '/runs/new?pipelineId=pipeline-a&pipelineVersionId=version-a&experimentId=experiment-a'; +let browser; +before(async () => { + browser = await launchBrowser(); + if (process.env.KFP_EXPECTED_BROWSER_VERSION) { + assert.equal( + browser.version(), + process.env.KFP_EXPECTED_BROWSER_VERSION, + 'Browser version must match KFP_EXPECTED_BROWSER_VERSION; select the intended browser binary', + ); + } +}); +after(async () => browser?.close()); + +function gate() { + let release; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} +function decodeFilter(value) { + return value + ? JSON.parse(value.startsWith('%') ? decodeURIComponent(value) : value) + : { predicates: [] }; +} +function makeRun(index, experiment = 'experiment-a') { + return { + run_id: `run-${index}`, + display_name: `Training ${index}`, + experiment_id: experiment, + created_at: new Date(Date.UTC(2026, 8, 26 - index)).toISOString(), + finished_at: new Date(Date.UTC(2026, 8, 26 - index, 0, 1)).toISOString(), + state: index % 2 ? 'SUCCEEDED' : 'FAILED', + storage_state: 'AVAILABLE', + pipeline_spec: pipelineSpec, + pipeline_version_reference: { pipeline_id: 'pipeline-a', pipeline_version_id: 'version-a' }, + run_details: { task_details: [] }, + }; +} +async function withFixture(options, exercise) { + const context = await browser.newContext({ + viewport: { width: 1440, height: 960 }, + locale: 'en-US', + timezoneId: 'UTC', + reducedMotion: 'reduce', + }); + const page = await context.newPage(); + page.setDefaultTimeout(10000); + await page.clock.install({ time: new Date('2026-09-26T12:02:00.000Z') }); + const fixture = { + experiments: [ + { + experiment_id: 'experiment-a', + display_name: 'Training experiment', + description: 'First line\nSecond line\nFull experiment description', + namespace: 'team-a', + storage_state: 'AVAILABLE', + created_at: createdAt, + }, + { + experiment_id: 'experiment-b', + display_name: 'Small experiment', + description: 'Two recent runs', + namespace: 'team-b', + storage_state: 'AVAILABLE', + created_at: createdAt, + }, + { + experiment_id: 'experiment-archived', + display_name: 'Archived experiment', + namespace: 'team-a', + storage_state: 'ARCHIVED', + created_at: createdAt, + }, + ], + runs: [ + ...Array.from({ length: 7 }, (_, i) => makeRun(i + 1)), + makeRun(8, 'experiment-b'), + makeRun(9, 'experiment-b'), + ], + schedules: [ + { + recurring_run_id: 'schedule-a', + display_name: 'Nightly training', + experiment_id: 'experiment-a', + namespace: 'team-a', + status: 'ENABLED', + created_at: createdAt, + max_concurrency: '2', + no_catchup: false, + trigger: { periodic_schedule: { interval_second: '3600' } }, + pipeline_version_reference: { pipeline_id: 'pipeline-a', pipeline_version_id: 'version-a' }, + pipeline_spec: pipelineSpec, + }, + ], + requests: [], + mutations: [], + errors: [], + failNext: new Set(), + held: new Map(), + }; + page.on('pageerror', (error) => fixture.errors.push(error.message)); + await context.addInitScript(({ namespace }) => { + if (!localStorage.getItem('kfp.theme')) localStorage.setItem('kfp.theme', 'light'); + if (namespace) { + window.centraldashboard = { + CentralDashboardEventHandler: { + init(callback) { + const handler = {}; + callback(handler); + handler.onNamespaceSelected(namespace); + window.selectFixtureNamespace = (value) => handler.onNamespaceSelected(value); + }, + }, + }; + } + }, options); + await context.route('**/*', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const path = url.pathname; + const method = request.method(); + const query = Object.fromEntries(url.searchParams); + const json = (body, status = 200) => + route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }); + try { + assert.equal(url.origin, origin, 'requests must stay within the fixture origin'); + assert.ok(fixture.requests.length < 250, 'request count must remain bounded'); + fixture.requests.push({ method, path, query }); + // WebKit routes native Blob workers; their imported HTTP assets remain fixture-bound. + if (url.protocol === 'blob:') return await route.continue(); + if (path === '/' || path.startsWith('/static/')) { + const name = path === '/' ? 'index.html' : path.slice(1); + let body = await readFile(new URL(name, build)); + if (path === '/') + body = body + .toString() + .replace( + /window\.KFP_FLAGS\.DEPLOYMENT\s*=\s*null;?/, + `window.KFP_FLAGS.DEPLOYMENT=${JSON.stringify(options.namespace ? 'KUBEFLOW' : null)};`, + ); + const contentType = name.endsWith('.js') + ? 'text/javascript' + : name.endsWith('.css') + ? 'text/css' + : name.endsWith('.html') + ? 'text/html' + : 'application/octet-stream'; + return route.fulfill({ body, contentType }); + } + if (path === '/apis/v2/healthz') return json({ apiServerTagName: 'fixture' }); + if (path === '/system/cluster-name' || path === '/system/project-id') + return route.fulfill({ body: '' }); + if (method === 'POST') { + const action = `${method} ${path}`; + const body = request.postData() ? request.postDataJSON() : null; + fixture.mutations.push({ path, body }); + if (fixture.held.has(action)) await fixture.held.get(action).promise; + if (fixture.failNext.delete(action)) + return json({ code: 14, message: 'Fixture mutation temporarily unavailable' }, 503); + if (path === '/apis/v2/experiments') { + const experiment = { + ...body, + experiment_id: 'experiment-created', + storage_state: 'AVAILABLE', + created_at: createdAt, + }; + fixture.experiments.push(experiment); + return json(experiment); + } + if (path === '/apis/v2/runs') { + const run = { ...makeRun(20), ...body, run_id: 'run-created', state: 'SUCCEEDED' }; + fixture.runs.push(run); + return json(run); + } + if (path === '/apis/v2/recurringruns') { + const schedule = { + ...body, + recurring_run_id: 'schedule-created', + status: 'ENABLED', + created_at: createdAt, + }; + fixture.schedules.push(schedule); + return json(schedule); + } + const scheduleAction = /^\/apis\/v2\/recurringruns\/([^/:]+):(enable|disable)$/.exec( + path, + ); + if (scheduleAction) { + assert.equal(body, null, 'schedule toggles have no request body'); + const schedule = fixture.schedules.find( + (item) => item.recurring_run_id === scheduleAction[1], + ); + assert.ok(schedule); + schedule.status = scheduleAction[2] === 'enable' ? 'ENABLED' : 'DISABLED'; + return json({}); + } + const runAction = /^\/apis\/v2\/runs\/([^/:]+):(archive|unarchive)$/.exec(path); + if (runAction) { + assert.equal(body, null, 'run archive actions have no body'); + const run = fixture.runs.find((item) => item.run_id === runAction[1]); + assert.ok(run); + run.storage_state = runAction[2] === 'archive' ? 'ARCHIVED' : 'AVAILABLE'; + return json({}); + } + } + assert.equal(method, 'GET', `Unexpected method for ${path}`); + if (path === '/apis/v2/experiments') { + const predicates = decodeFilter(query.filter).predicates || []; + let experiments = fixture.experiments.filter( + (item) => !query.namespace || item.namespace === query.namespace, + ); + for (const predicate of predicates) { + if (predicate.key === 'storage_state') { + assert.equal(predicate.string_value, 'ARCHIVED'); + assert.ok(['EQUALS', 'NOT_EQUALS'].includes(predicate.operation)); + experiments = experiments.filter( + (item) => (item.storage_state === 'ARCHIVED') === (predicate.operation === 'EQUALS'), + ); + } else { + assert.equal(predicate.key, 'name'); + assert.equal(predicate.operation, 'IS_SUBSTRING'); + experiments = experiments.filter((item) => + item.display_name.toLowerCase().includes(predicate.string_value.toLowerCase()), + ); + } + } + return json({ experiments, total_size: experiments.length }); + } + const experimentMatch = /^\/apis\/v2\/experiments\/([^/]+)$/.exec(path); + if (experimentMatch) { + const experiment = fixture.experiments.find( + (item) => item.experiment_id === decodeURIComponent(experimentMatch[1]), + ); + assert.ok(experiment); + return json(experiment); + } + if (path === '/apis/v2/runs') { + assert.equal(query.skip_count, 'true'); + const predicates = decodeFilter(query.filter).predicates || []; + let runs = fixture.runs.filter( + (item) => !query.experiment_id || item.experiment_id === query.experiment_id, + ); + if (query.namespace) + runs = runs.filter((item) => + fixture.experiments.some( + (experiment) => + experiment.experiment_id === item.experiment_id && + experiment.namespace === query.namespace, + ), + ); + if (query.page_size === '5') { + assert.ok(query.experiment_id, 'recent-run samples are scoped to one experiment'); + assert.equal(query.sort_by, 'created_at desc'); + assert.ok(!query.page_token, 'recent-run samples must not fetch further pages'); + } + for (const predicate of predicates) { + if (predicate.key === 'storage_state') { + assert.equal(predicate.string_value, 'ARCHIVED'); + runs = runs.filter( + (item) => (item.storage_state === 'ARCHIVED') === (predicate.operation === 'EQUALS'), + ); + } else { + assert.equal(predicate.key, 'name'); + assert.equal(predicate.operation, 'IS_SUBSTRING'); + runs = runs.filter((item) => item.display_name.includes(predicate.string_value)); + } + } + runs.sort((a, b) => b.created_at.localeCompare(a.created_at)); + return json({ runs: runs.slice(0, Number(query.page_size || 100)) }); + } + const runMatch = /^\/apis\/v2\/runs\/([^/]+)(\/tasks)?$/.exec(path); + if (runMatch) { + const run = fixture.runs.find((item) => item.run_id === runMatch[1]); + assert.ok(run); + return json(runMatch[2] ? { tasks: [] } : run); + } + if (path === '/apis/v2/recurringruns') { + const schedules = fixture.schedules.filter( + (item) => + (!query.namespace || item.namespace === query.namespace) && + (!query.experiment_id || item.experiment_id === query.experiment_id), + ); + return json({ recurringRuns: schedules, total_size: schedules.length }); + } + const scheduleMatch = /^\/apis\/v2\/recurringruns\/([^/]+)$/.exec(path); + if (scheduleMatch) { + const schedule = fixture.schedules.find( + (item) => item.recurring_run_id === scheduleMatch[1], + ); + assert.ok(schedule); + return json(schedule); + } + if (path === '/apis/v2/pipelines') { + if (options.secondaryRoutes && query.filter) { + const predicates = decodeFilter(query.filter).predicates; + assert.equal(query.page_size, '10'); + assert.equal(predicates.length, 1); + assert.equal(predicates[0].key, 'name'); + assert.equal(predicates[0].operation, 'EQUALS'); + assert.ok(!query.page_token, 'tutorial lookup must not scan pages'); + if (predicates[0].string_value === '[Tutorial] Data passing in python components') + return json({ pipelines: [{ ...pipeline, display_name: predicates[0].string_value }] }); + assert.equal(predicates[0].string_value, '[Tutorial] DSL - Control structures'); + return json({ pipelines: [] }); + } + return json({ pipelines: [pipeline] }); + } + if (path === '/apis/v2/pipelines/pipeline-a') return json(pipeline); + if (path === '/apis/v2/pipelines/pipeline-a/versions') { + if (query.page_size === '1') assert.equal(query.sort_by, 'created_at desc'); + return json({ + pipeline_versions: [latestVersion, version].slice(0, Number(query.page_size || 2)), + }); + } + if (path === '/apis/v2/pipelines/pipeline-a/versions/version-a') return json(version); + if (path === '/apis/v2/pipelines/pipeline-a/versions/version-latest') + return json(latestVersion); + if (path === '/apis/v2/artifacts' || path === '/apis/v2/artifact_tasks') + return json({}); + throw new Error(`Unexpected fixture request: ${method} ${path}`); + } catch (error) { + fixture.errors.push(error.message); + await json({ message: error.message }, 500); + } + }); + try { + await exercise(page, fixture); + assert.deepEqual( + fixture.errors, + [], + 'bundle and HTTP contracts must have no unexpected errors', + ); + } catch (error) { + if (fixture.errors.length) console.error('Fixture errors:', fixture.errors); + throw error; + } finally { + for (const held of fixture.held.values()) held.release(); + await context.close(); + } +} + +async function tableReady(page, name) { + await page.getByRole('table', { name, exact: true }).waitFor(); + await page.waitForFunction( + (label) => + document.querySelector(`table[aria-label="${label}"]`)?.getAttribute('aria-busy') === 'false', + name, + ); +} +async function focused(page, locator) { + const handle = await locator.elementHandle(); + assert.ok(handle); + try { + await page.waitForFunction((node) => node === document.activeElement, handle); + } finally { + await handle.dispose(); + } +} +async function screenshot(page, name) { + const directory = process.env.KFP_WORKFLOWS_SCREENSHOT_DIR; + if (!directory) return; + await mkdir(directory, { recursive: true }); + await page.evaluate(() => { + window.scrollTo(0, 0); + document.querySelectorAll('.kfp-modern-page').forEach((element) => { + element.scrollTop = 0; + }); + }); + await page.screenshot({ path: join(directory, `${name}.png`) }); +} +async function theme(page, value) { + await page.getByRole('switch', { name: 'Dark mode', exact: true }).setChecked(value === 'dark'); + await page.locator(`.kfp-theme[data-theme="${value}"]`).waitFor(); + // The theme attribute changes before inherited descendant colors settle. + await page.waitForFunction((theme) => { + const dark = theme === 'dark'; + const foreground = dark ? 'rgb(236, 238, 244)' : 'rgb(21, 23, 30)'; + const background = dark ? 'rgb(14, 16, 21)' : 'rgb(245, 246, 249)'; + const card = dark ? 'rgb(22, 25, 33)' : 'rgb(255, 255, 255)'; + const shell = document.querySelector('.kfp-shell'); + const brand = document.querySelector('.kfp-shell-brand-title'); + return ( + getComputedStyle(shell).backgroundColor === background && + getComputedStyle(brand).color === foreground && + [...document.querySelectorAll('.kfp-new-run-fields,.kfp-run-form-summary')].every( + (element) => getComputedStyle(element).backgroundColor === card, + ) + ); + }, value); +} +async function newRun(page, suffix = '') { + await page.goto(`${origin}/#${runCreationPath}${suffix}`); + await page.getByLabel('count - integer', { exact: true }).waitFor(); + assert.equal(await page.getByLabel('count - integer', { exact: true }).inputValue(), '0'); + assert.equal(await page.getByLabel('enabled - boolean', { exact: true }).inputValue(), 'false'); + assert.equal(await page.getByLabel('message - string', { exact: true }).inputValue(), ''); +} +function mutation(fixture, path) { + return fixture.mutations.filter((entry) => entry.path === path); +} +async function waitMutation(page, action) { + const received = page.waitForRequest( + (request) => request.method() === 'POST' && new URL(request.url()).pathname === action, + ); + return received; +} +async function submit(page, path) { + const received = waitMutation(page, path); + await page.locator('#startNewRunBtn').click(); + await received; +} +function assertRunBody(body, name, versionId = 'version-a', experimentId = 'experiment-a') { + assert.equal(body.display_name, name); + assert.equal(body.experiment_id, experimentId); + assert.deepEqual( + body.pipeline_version_reference, + versionId + ? { pipeline_id: 'pipeline-a', pipeline_version_id: versionId } + : { pipeline_id: 'pipeline-a' }, + ); + assert.equal( + body.pipeline_spec, + undefined, + 'version-reference creation must not embed a duplicate pipeline spec', + ); + assert.deepEqual(body.runtime_config.parameters, expectedParameters); +} + +// The sample is deliberately seven runs for one experiment and two for another: labels must +// describe the fetched sample; full selection and management live on the detail page. +test('Experiments: bounded recent-run samples, expansion, archive navigation, namespace and themes', async () => { + await withFixture({ namespace: 'team-a' }, async (page, fixture) => { + await page.goto(`${origin}/#/experiments`); + await tableReady(page, 'Experiments'); + await page.getByRole('img', { name: /^Last 5 runs:/ }).waitFor(); + assert.equal( + fixture.requests.filter((r) => r.path === '/apis/v2/runs' && r.query.page_size === '5') + .length, + 1, + ); + await screenshot(page, 'experiments-light'); + const expand = page.getByRole('button', { + name: 'Expand experiment Training experiment', + exact: true, + }); + await expand.focus(); + await page.keyboard.press('Enter'); + const preview = page.getByRole('region', { name: 'Recent runs preview', exact: true }); + await preview.waitFor(); + assert.equal(await preview.getByRole('listitem').count(), 5); + assert.equal(await preview.getByRole('checkbox').count(), 0); + assert.equal(await page.getByRole('table', { name: 'Runs', exact: true }).count(), 0); + assert.match(page.url(), /#\/experiments$/); + await theme(page, 'dark'); + await screenshot(page, 'experiments-dark-expanded'); + await preview.getByRole('link', { name: 'View all runs', exact: true }).click(); + await page.waitForURL(/#\/experiments\/details\/experiment-a$/); + await tableReady(page, 'Runs'); + assert.equal(await page.getByTestId('run-name-link').count(), 7); + await page.getByRole('checkbox', { name: 'Select run Training 1', exact: true }).check(); + assert.equal( + await page + .getByRole('button', { name: 'Archive', exact: true }) + .and(page.locator('[aria-description="Archive selected run(s)"]')) + .isEnabled(), + true, + ); + await page.goto(`${origin}/#/experiments`); + await tableReady(page, 'Experiments'); + await page.getByRole('group', { name: 'Experiment scope' }).getByRole('button', { name: 'Archived', exact: true }).click(); + await tableReady(page, 'Archived experiments'); + await page.getByRole('link', { name: 'Archived experiment', exact: true }).waitFor(); + assert.match(page.url(), /#\/archive\/experiments$/); + await page.getByRole('group', { name: 'Experiment scope' }).getByRole('button', { name: 'Active', exact: true }).click(); + await tableReady(page, 'Experiments'); + await page.evaluate(() => window.selectFixtureNamespace('team-b')); + await page.getByRole('link', { name: 'Small experiment', exact: true }).waitFor(); + await tableReady(page, 'Experiments'); + await page.getByRole('img', { name: /^Last 2 runs:/ }).waitFor(); + assert.equal( + await page.getByRole('link', { name: 'Training experiment', exact: true }).count(), + 0, + ); + assert.ok( + fixture.requests.some( + (r) => r.path === '/apis/v2/experiments' && r.query.namespace === 'team-b', + ), + ); + assert.equal( + fixture.mutations.length, + 0, + 'preview navigation and selecting a run must not mutate it', + ); + }); +}); + +async function assertForcedColorSwitch(control) { + const styles = await control.evaluate((element) => { + const thumb = element.querySelector('.kfp-switch-thumb'); + if (!thumb) throw new Error('Switch thumb missing'); + return { + track: getComputedStyle(element).backgroundColor, + thumb: getComputedStyle(thumb).backgroundColor, + transition: getComputedStyle(thumb).transitionDuration, + transform: getComputedStyle(thumb).transform, + }; + }); + const luminance = (color) => { + const rgb = color + .match(/[\d.]+/g) + ?.slice(0, 3) + .map(Number); + assert.equal(rgb?.length, 3, `Expected an RGB system color, got ${color}`); + const linear = rgb.map((channel) => { + const value = channel / 255; + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]; + }; + const light = Math.max(luminance(styles.track), luminance(styles.thumb)); + const dark = Math.min(luminance(styles.track), luminance(styles.thumb)); + assert.ok((light + 0.05) / (dark + 0.05) >= 3, JSON.stringify(styles)); + assert.ok(styles.transition.split(',').every((value) => parseFloat(value) <= 0.001)); + return styles.transform; +} + +test('Recurring workflows: keyboard toggle failure/recovery, detail navigation and manager mutation', async () => { + await withFixture({ namespace: 'team-a' }, async (page, fixture) => { + await page.goto(`${origin}/#/recurringruns`); + await tableReady(page, 'Recurring runs'); + const action = '/apis/v2/recurringruns/schedule-a:disable'; + const held = gate(); + fixture.held.set(`POST ${action}`, held); + fixture.failNext.add(`POST ${action}`); + const control = page.getByRole('switch', { name: 'Enable schedule Nightly training' }); + await page.emulateMedia({ forcedColors: 'active', reducedMotion: 'reduce' }); + assert.equal(await page.evaluate(() => matchMedia('(forced-colors: active)').matches), true); + const checkedThumbPosition = await assertForcedColorSwitch(control); + const firstToggle = waitMutation(page, action); + await control.focus(); + await page.keyboard.press('Space'); + await firstToggle; + assert.equal(await control.isDisabled(), true); + await page.keyboard.press('Space'); + assert.equal(mutation(fixture, action).length, 1); + held.release(); + fixture.held.delete(`POST ${action}`); + const notification = page.getByRole('alert').filter({ hasText: 'Could not update schedule' }); + await notification.waitFor(); + assert.equal(await control.isChecked(), true); + assert.equal(await control.isDisabled(), false); + await notification.getByRole('button', { name: 'Retry', exact: true }).click(); + await page.waitForFunction( + () => + document + .querySelector('[role="switch"][aria-label="Enable schedule Nightly training"]') + ?.getAttribute('aria-checked') === 'false', + ); + await tableReady(page, 'Recurring runs'); + assert.equal(mutation(fixture, action).length, 2); + await notification.waitFor({ state: 'hidden' }); + const uncheckedThumbPosition = await assertForcedColorSwitch(control); + assert.notEqual(uncheckedThumbPosition, checkedThumbPosition); + await page.emulateMedia({ forcedColors: 'none', reducedMotion: 'no-preference' }); + await theme(page, 'dark'); + await screenshot(page, 'recurring-dark'); + await page.getByRole('link', { name: 'Nightly training', exact: true }).click(); + await page.waitForURL(/#\/recurringrun\/details\/schedule-a$/); + await page.getByRole('heading', { name: 'Nightly training', exact: true }).waitFor(); + await page.goto(`${origin}/#/experiments/details/experiment-a`); + const manage = page.locator('#manageExperimentRecurringRunsBtn'); + await manage.click(); + const dialog = page.getByRole('dialog', { name: 'Recurring run configs', exact: true }); + await dialog.waitFor(); + await dialog.getByRole('switch', { name: 'Enable schedule Nightly training' }).click(); + await page.waitForFunction( + () => + document.querySelector('[role="dialog"] [role="switch"]')?.getAttribute('aria-checked') === + 'true', + ); + assert.equal(mutation(fixture, '/apis/v2/recurringruns/schedule-a:enable').length, 1); + await dialog.locator('#closeExperimentRecurringRunManagerBtn').click(); + await dialog.waitFor({ state: 'hidden' }); + await focused(page, manage); + await page + .getByRole('region', { name: 'Recurring run configs' }) + .getByText('1 active', { exact: true }) + .waitFor(); + await screenshot(page, 'experiment-detail-dark'); + }); +}); + +test('New experiment: validation, held failure/retry, namespace and latest-version handoff', async () => { + await withFixture({ namespace: 'team-a' }, async (page, fixture) => { + await page.goto(`${origin}/#/experiments/new?pipelineId=pipeline-a`); + const next = page.locator('#createExperimentBtn'); + await page.getByRole('textbox', { name: 'Experiment name', exact: true }).waitFor(); + assert.equal(await next.isDisabled(), true); + await page + .getByRole('textbox', { name: 'Experiment name', exact: true }) + .fill('Created experiment'); + await page.getByLabel('Description', { exact: true }).fill('Browser-created description'); + const action = '/apis/v2/experiments'; + const held = gate(); + fixture.held.set(`POST ${action}`, held); + fixture.failNext.add(`POST ${action}`); + const request = waitMutation(page, action); + await next.click(); + await request; + assert.equal(await next.isDisabled(), true); + await next.press('Enter'); + assert.equal(mutation(fixture, action).length, 1); + held.release(); + fixture.held.delete(`POST ${action}`); + // The shared generated client's ResponseError currently exposes this generic message. + const dialog = page.getByRole('dialog', { name: 'Experiment creation failed', exact: true }); + await dialog.getByText('Response returned an error code', { exact: true }).waitFor(); + await dialog.getByRole('button', { name: 'Dismiss', exact: true }).click(); + await next.click(); + await page.waitForURL(/#\/runs\/new\?/); + await page.getByLabel('count - integer', { exact: true }).waitFor(); + const query = new URL(page.url().split('#')[1], origin).searchParams; + assert.equal(query.get('experimentId'), 'experiment-created'); + assert.equal(query.get('pipelineId'), 'pipeline-a'); + assert.equal(query.get('pipelineVersionId'), 'version-latest'); + assert.equal(query.get('firstRunInExperiment'), '1'); + assert.equal( + await page.getByRole('textbox', { name: 'Experiment', exact: true }).inputValue(), + 'Created experiment', + ); + assert.equal(mutation(fixture, action).length, 2); + assert.deepEqual(mutation(fixture, action)[1].body, { + display_name: 'Created experiment', + description: 'Browser-created description', + namespace: 'team-a', + }); + assert.equal(fixture.mutations.filter((entry) => entry.path !== action).length, 0); + }); +}); + +test('One-off creation: typed falsy parameters, validation, responsive themes and one POST per attempt', async () => { + await withFixture({ namespace: 'team-a' }, async (page, fixture) => { + await newRun(page); + const start = page.locator('#startNewRunBtn'); + await page.getByRole('textbox', { name: 'Run name', exact: true }).fill(''); + assert.equal(await start.isDisabled(), true); + await page.getByRole('textbox', { name: 'Run name', exact: true }).fill('One-off browser run'); + await page.getByRole('radio', { name: 'Recurring', exact: true }).check(); + await page.getByRole('radio', { name: 'One-off', exact: true }).check(); + await page.getByLabel('Description', { exact: true }).fill('Retained after failure'); + await page.getByLabel('Service Account', { exact: true }).fill('pipeline-runner'); + await page.getByLabel('count - integer', { exact: true }).fill('1.5'); + assert.equal(await start.isDisabled(), true); + assert.equal(fixture.mutations.length, 0); + await page.getByLabel('count - integer', { exact: true }).fill('0'); + await page.getByLabel('enabled - boolean', { exact: true }).fill('false'); + await page.getByLabel('message - string', { exact: true }).fill('temporary'); + await page.getByLabel('message - string', { exact: true }).fill(''); + await page.getByLabel('choice - string', { exact: true }).selectOption({ label: 'production' }); + await page + .getByLabel('choice - string', { exact: true }) + .selectOption({ label: 'Empty string' }); + await page.getByLabel('config - dict', { exact: true }).fill('invalid'); + assert.equal(await start.isDisabled(), true); + await page.getByLabel('config - dict', { exact: true }).fill('{"nested":false}'); + await page.getByRole('button', { name: 'Open Json Editor', exact: true }).click(); + await page.locator('.ace_editor').waitFor(); + assert.equal(await page.getByLabel('config - dict', { exact: true }).isDisabled(), true); + await page.getByRole('button', { name: 'Close Json Editor', exact: true }).click(); + const customRoot = page.getByRole('checkbox', { + name: /Custom Pipeline Root$/, + }); + await customRoot.focus(); + await page.keyboard.press('Space'); + await page.getByLabel('pipeline-root', { exact: true }).fill('s3://fixture-artifacts/browser'); + await screenshot(page, 'new-run-light'); + await theme(page, 'dark'); + await screenshot(page, 'new-run-dark'); + await page.setViewportSize({ width: 390, height: 844 }); + await screenshot(page, 'new-run-narrow-dark'); + assert.equal( + await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), + true, + 'narrow form must not overflow the viewport', + ); + await page.setViewportSize({ width: 1440, height: 960 }); + const action = '/apis/v2/runs'; + const held = gate(); + fixture.held.set(`POST ${action}`, held); + fixture.failNext.add(`POST ${action}`); + await submit(page, action); + assert.equal(await start.isDisabled(), true); + await start.press('Enter'); + assert.equal(mutation(fixture, action).length, 1); + held.release(); + fixture.held.delete(`POST ${action}`); + const dialog = page.getByRole('dialog', { name: 'Run creation failed', exact: true }); + await dialog.getByText('Response returned an error code', { exact: true }).waitFor(); + await dialog.getByRole('button', { name: 'Dismiss', exact: true }).click(); + assert.equal( + await page.getByRole('textbox', { name: 'Run name', exact: true }).inputValue(), + 'One-off browser run', + ); + await submit(page, action); + await page.waitForURL(/#\/runs\/details\/run-created$/); + assert.equal(mutation(fixture, action).length, 2); + const body = mutation(fixture, action)[1].body; + assertRunBody(body, 'One-off browser run'); + assert.equal(body.runtime_config.pipeline_root, 's3://fixture-artifacts/browser'); + assert.equal(body.description, 'Retained after failure'); + assert.equal(body.service_account, 'pipeline-runner'); + for (const key of ['mode', 'max_concurrency', 'no_catchup', 'trigger']) + assert.equal(body[key], undefined, `one-off payload excludes ${key}`); + assert.equal(mutation(fixture, '/apis/v2/recurringruns').length, 0); + }); +}); + +test('Recurring creation: periodic bounds, concurrency validation, catchup and latest-version payload', async () => { + await withFixture({ namespace: 'team-a' }, async (page, fixture) => { + await newRun(page, '&recurring=1'); + await page + .getByRole('textbox', { name: 'Recurring run config name', exact: true }) + .fill('Periodic browser schedule'); + await page + .getByRole('checkbox', { name: 'Always use the latest pipeline version', exact: true }) + .check(); + await page.getByRole('combobox', { name: 'Interval unit', exact: true }).selectOption('Hour'); + await page.getByRole('spinbutton', { name: 'Interval', exact: true }).fill('2'); + await page.getByText('Advanced execution settings', { exact: true }).click(); + await page.getByRole('textbox', { name: 'Maximum concurrent runs', exact: true }).fill('0'); + assert.equal(await page.locator('#startNewRunBtn').isDisabled(), true); + await page.getByRole('textbox', { name: 'Maximum concurrent runs', exact: true }).fill('3'); + await page + .getByRole('checkbox', { name: 'Run missed schedules when resumed', exact: true }) + .uncheck(); + await page.getByRole('combobox', { name: 'Starts', exact: true }).selectOption('date'); + await page.getByLabel('Start date', { exact: true }).fill('2026-10-01'); + await page.getByLabel('Start time', { exact: true }).fill('08:30'); + await page.getByRole('combobox', { name: 'Ends', exact: true }).selectOption('date'); + await page.getByLabel('End date', { exact: true }).fill('2026-10-31'); + await page.getByLabel('End time', { exact: true }).fill('18:45'); + await theme(page, 'dark'); + await screenshot(page, 'new-recurring-dark'); + await submit(page, '/apis/v2/recurringruns'); + await page.waitForURL(/#\/recurringrun\/details\/schedule-created$/); + assert.equal(mutation(fixture, '/apis/v2/recurringruns').length, 1); + assert.equal(mutation(fixture, '/apis/v2/runs').length, 0); + const body = mutation(fixture, '/apis/v2/recurringruns')[0].body; + assertRunBody(body, 'Periodic browser schedule', null); + assert.equal(body.mode, 'ENABLE'); + assert.equal(body.max_concurrency, '3'); + assert.equal(body.no_catchup, true); + assert.deepEqual(body.trigger, { + periodic_schedule: { + start_time: '2026-10-01T08:30:00.000Z', + end_time: '2026-10-31T18:45:00.000Z', + interval_second: '7200', + }, + }); + }); +}); + +test('Inline experiment and cron creation preserve selected pipeline, typed parameters and single mutation', async () => { + await withFixture({ namespace: 'team-a' }, async (page, fixture) => { + await newRun(page); + await page.locator('#chooseExperimentBtn').click(); + const chooser = page.getByRole('dialog', { name: 'Choose an experiment', exact: true }); + await chooser.getByRole('button', { name: 'Create new experiment', exact: true }).click(); + const editor = page.getByRole('dialog', { name: 'New experiment', exact: true }); + await editor + .getByRole('textbox', { name: 'Experiment name', exact: true }) + .fill('Inline experiment'); + await editor.locator('#createExperimentBtn').click(); + await editor.waitFor({ state: 'hidden' }); + await page.getByLabel('count - integer', { exact: true }).waitFor(); + assert.equal( + await page.getByRole('textbox', { name: 'Experiment', exact: true }).inputValue(), + 'Inline experiment', + ); + assert.equal( + await page.getByRole('textbox', { name: 'Pipeline', exact: true }).inputValue(), + 'Workflow pipeline', + ); + assert.equal(mutation(fixture, '/apis/v2/experiments').length, 1); + await page.getByRole('radio', { name: 'Recurring', exact: true }).check(); + await page + .getByRole('textbox', { name: 'Recurring run config name', exact: true }) + .fill('Cron browser schedule'); + await page.getByRole('combobox', { name: 'Repeat', exact: true }).selectOption('weekly'); + const monday = page.getByRole('button', { name: 'Monday', exact: true }); + await monday.focus(); + await page.keyboard.press('Space'); + assert.equal(await monday.getAttribute('aria-pressed'), 'false'); + await page.getByRole('combobox', { name: 'Repeat', exact: true }).selectOption('custom'); + await page.getByLabel('Cron expression', { exact: true }).fill('0 15 8 ? * 1-5'); + await submit(page, '/apis/v2/recurringruns'); + await page.waitForURL(/#\/recurringrun\/details\/schedule-created$/); + const body = mutation(fixture, '/apis/v2/recurringruns')[0].body; + assertRunBody(body, 'Cron browser schedule', 'version-latest', 'experiment-created'); + assert.equal(body.mode, 'ENABLE'); + assert.equal(body.max_concurrency, '10'); + assert.equal(body.no_catchup, false); + assert.deepEqual(body.trigger, { cron_schedule: { cron: '0 15 8 ? * 1-5' } }); + assert.equal(mutation(fixture, '/apis/v2/recurringruns').length, 1); + assert.equal(mutation(fixture, '/apis/v2/runs').length, 0); + }); +}); + +test('Secondary routes preserve tutorial links, feature draft save/reset/persistence, theme and keyboard navigation', async () => { + await withFixture({ secondaryRoutes: true }, async (page, fixture) => { + await page.goto(`${origin}/#/start`); + await page + .getByRole('heading', { name: 'Build your own pipeline with', exact: true }) + .waitFor(); + const dataTutorial = page.getByRole('link', { + name: 'Data passing in Python components', + exact: true, + }); + const controlTutorial = page.getByRole('link', { + name: 'DSL - Control structures', + exact: true, + }); + await page.waitForFunction(() => + [...document.querySelectorAll('a')].some( + (link) => + link.textContent === 'Data passing in Python components' && + link.getAttribute('href')?.split('?')[0] === '#/pipelines/details/pipeline-a', + ), + ); + assert.equal( + await dataTutorial.getAttribute('target'), + null, + 'tutorial navigation stays in the app', + ); + assert.equal( + await controlTutorial.getAttribute('href'), + '#/pipelines', + 'missing tutorials retain the pipeline-list fallback', + ); + const sdk = page.getByRole('link', { name: 'SDK', exact: true }); + assert.equal(await sdk.getAttribute('target'), '_blank'); + assert.match(await sdk.getAttribute('rel'), /noopener/); + assert.equal( + fixture.requests.filter( + (request) => request.path === '/apis/v2/pipelines' && request.query.filter, + ).length, + 2, + ); + const runtimeFlags = await page.evaluate(() => JSON.stringify(window.KFP_FLAGS)); + await theme(page, 'dark'); + await screenshot(page, 'getting-started-dark'); + await dataTutorial.focus(); + await page.keyboard.press('Enter'); + await page.waitForURL((url) => url.hash.split('?')[0] === '#/pipelines/details/pipeline-a'); + await page.getByTestId('DagCanvas').waitFor(); + + await page.goto(`${origin}/#/frontend_features`); + const feature = page.getByRole('switch', { name: 'Enable functional_component', exact: true }); + await feature.waitFor(); + assert.equal(await feature.isChecked(), false); + const originalFlags = await page.evaluate(() => ({ + stored: localStorage.getItem('flags'), + runtime: window.__FEATURE_FLAGS__, + })); + await feature.focus(); + await page.keyboard.press('Space'); + assert.equal(await feature.isChecked(), true); + assert.deepEqual( + await page.evaluate(() => ({ + stored: localStorage.getItem('flags'), + runtime: window.__FEATURE_FLAGS__, + })), + originalFlags, + 'editing a draft must not persist or activate it', + ); + await page.getByRole('button', { name: 'Reset', exact: true }).click(); + assert.equal(await feature.isChecked(), false); + await feature.focus(); + await page.keyboard.press('Space'); + const save = page.getByRole('button', { name: 'Save changes', exact: true }); + await save.focus(); + await page.keyboard.press('Enter'); + await page.waitForFunction( + () => + JSON.parse(localStorage.getItem('flags')).find( + (flag) => flag.name === 'functional_component', + ).active === true, + ); + assert.equal( + await page.evaluate( + () => + JSON.parse(window.__FEATURE_FLAGS__).find((flag) => flag.name === 'functional_component') + .active, + ), + true, + ); + await page.reload(); + await feature.waitFor(); + assert.equal(await feature.isChecked(), true, 'saved preferences survive a full reload'); + assert.equal( + await page.getByRole('switch', { name: 'Dark mode', exact: true }).isChecked(), + true, + ); + await feature.focus(); + await page.keyboard.press('Space'); + assert.equal(await feature.isChecked(), false); + await page.getByRole('button', { name: 'Reset', exact: true }).click(); + assert.equal( + await feature.isChecked(), + true, + 'reset restores the saved preference rather than the default', + ); + await screenshot(page, 'frontend-features-dark'); + await feature.focus(); + await page.keyboard.press('Space'); + await save.click(); + assert.equal( + await page.evaluate(() => JSON.stringify(window.KFP_FLAGS)), + runtimeFlags, + 'frontend preferences must not rewrite deployment flags', + ); + + await page.goto(`${origin}/#/missing-browser-fixture`); + await page.getByRole('heading', { name: 'Page not found', exact: true }).waitFor(); + await page.getByText('/missing-browser-fixture', { exact: true }).waitFor(); + await page.setViewportSize({ width: 600, height: 900 }); + assert.equal( + await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), + true, + ); + await screenshot(page, 'not-found-dark-narrow'); + const returnToPipelines = page.getByRole('button', { name: 'Go to pipelines', exact: true }); + await returnToPipelines.focus(); + await page.keyboard.press('Enter'); + await page.waitForURL(`**/#/pipelines`); + await page.getByRole('link', { name: 'Workflow pipeline', exact: true }).waitFor(); + assert.equal(fixture.mutations.length, 0, 'secondary routes only change browser preferences'); + }); +}); diff --git a/frontend/scripts/ui-smoke-test/capture-screenshots.js b/frontend/scripts/ui-smoke-test/capture-screenshots.js index 6bda8ccb64a..e1e74a31dfd 100755 --- a/frontend/scripts/ui-smoke-test/capture-screenshots.js +++ b/frontend/scripts/ui-smoke-test/capture-screenshots.js @@ -168,7 +168,7 @@ const PAGES = [ { name: 'pipelines', path: '/#/pipelines', - waitFor: '[class*="tableRow"]', + waitFor: '[aria-label="Pipelines"] li, [class*="tableRow"]', waitForData: 'a[href*="pipeline"]', }, { @@ -194,13 +194,13 @@ const PAGES = [ { name: 'experiments', path: '/#/experiments', - waitFor: '[class*="tableRow"]', + waitFor: '[data-testid="table-row"], [class*="tableRow"]', waitForData: 'a[href*="experiment"]', }, { name: 'runs', path: '/#/runs', - waitFor: '[class*="tableRow"]', + waitFor: '[data-testid="table-row"], [class*="tableRow"]', waitForData: 'a[href*="run"]', }, { @@ -308,8 +308,8 @@ const PAGES = [ { type: 'waitForSelector', selector: '#dropZone' }, ], }, - { name: 'recurring-runs', path: '/#/recurringruns', waitFor: '[class*="tableRow"]' }, - { name: 'artifacts', path: '/#/artifacts', waitFor: '[class*="tableRow"]' }, + { name: 'recurring-runs', path: '/#/recurringruns', waitFor: '[data-testid="table-row"], [class*="tableRow"]' }, + { name: 'artifacts', path: '/#/artifacts', waitFor: '[data-testid="table-row"], [class*="tableRow"]' }, { name: 'artifact-related-tasks-seeded', path: '/#/artifacts/{seed.artifactId}', diff --git a/frontend/server/app.ts b/frontend/server/app.ts index 4b868415dea..61e0eeaaf64 100644 --- a/frontend/server/app.ts +++ b/frontend/server/app.ts @@ -19,6 +19,7 @@ import { createProxyMiddleware } from 'http-proxy-middleware'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); import { UIConfigs, legacyApiVersion2Prefix } from './configs.js'; +import { createPrecompressedStaticApp } from './static-assets.js'; import { getAddress } from './utils.js'; import { getBuildMetadata, getHealthzEndpoint, getHealthzHandler } from './handlers/healthz.js'; import { @@ -391,6 +392,9 @@ function createUIServer(options: UIConfigs) { registerHandler(app.get, '/index.html', indexHtmlHandler); /** Static resource (i.e. react app) */ + const precompressedAssets = createPrecompressedStaticApp(options.server.staticDir); + app.use(basePath, precompressedAssets); + app.use(precompressedAssets); app.use(basePath, StaticHandler(options.server.staticDir)); app.use(StaticHandler(options.server.staticDir)); diff --git a/frontend/server/integration-tests/mock-api-server.test.ts b/frontend/server/integration-tests/mock-api-server.test.ts index 263b74ab84f..f311903ee3c 100644 --- a/frontend/server/integration-tests/mock-api-server.test.ts +++ b/frontend/server/integration-tests/mock-api-server.test.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { describe, it } from 'vitest'; +import { describe, expect, it } from 'vitest'; import requests from 'supertest'; import { createMockApiApp } from '../../mock-backend/mock-api-app.ts'; import { setupMockBackendTest } from './test-helper.js'; @@ -20,6 +20,137 @@ import { setupMockBackendTest } from './test-helper.js'; setupMockBackendTest(); describe('standalone mock API server', () => { + it('serves task-specific output with failures, progress and cancellation', async () => { + const request = requests(createMockApiApp()); + for (const [id, expected] of [ + ['training-failed', 'ValueError: Training data contains invalid feature values.'], + ['training-running', 'Progress: 40 of 100 estimators fitted'], + ['sweep-canceled', 'Execution stopped after the run was canceled.'], + ]) { + const { body } = await request.get(`/apis/v2/runs/sample-run-${id}/tasks`).expect(200); + const task = body.tasks.find((task: { name: string }) => task.name === 'train-model'); + const response = await request + .get('/k8s/pod/logs') + .query({ podname: task.pods[0].name }) + .expect(200); + expect(response.text).toContain(expected); + expect(response.text).not.toContain('hello world'); + expect(response.text).not.toContain('Component completed successfully'); + const prepare = body.tasks.find((task: { name: string }) => task.name === 'prepare-data'); + const prepared = await request + .get('/k8s/pod/logs') + .query({ podname: prepare.pods[0].name }) + .expect(200); + expect(prepared.text).toContain('Prepared 12480 rows'); + expect(prepared.text).toContain('Component completed successfully'); + } + await request + .get('/k8s/pod/logs') + .query({ podname: 'sample-run-training-failed-evaluate-model-executor' }) + .expect(404); + }); + + it('exposes distinct failed automatic retry archives and pod logs', async () => { + const request = requests(createMockApiApp()); + const { body } = await request.get('/apis/v2/runs/sample-run-import-failed/tasks').expect(200); + const task = body.tasks.find((task: { name: string }) => task.name === 'train-model'); + expect(task.state).toBe('FAILED'); + expect(task.pods).toHaveLength(2); + const archives = task.outputs.artifacts[0].artifacts; + expect(archives).toHaveLength(2); + for (const [index, expected] of [ + 'TimeoutError: Timed out reading the training dataset', + 'ValueError: Training data contains invalid feature values.', + ].entries()) { + const uri = new URL(archives[index].uri); + const archived = await request + .get('/artifacts/get') + .query({ + source: 's3', + bucket: uri.hostname, + key: uri.pathname.slice(1), + namespace: 'kubeflow', + }) + .expect(200); + const pod = await request + .get('/k8s/pod/logs') + .query({ podname: task.pods[index].name }) + .expect(200); + expect(archived.text).toContain(expected); + expect(archived.text).toEqual(pod.text); + expect(archived.text).not.toContain('Component completed successfully'); + } + await request.get('/artifacts/get').query({ key: 'review-executor-logs/missing' }).expect(404); + }); + + it('serves varied recurring schedules with detail lookups', async () => { + const request = requests(createMockApiApp()); + const { body } = await request.get('/apis/v2/recurringruns').expect(200); + const samples = body.recurringRuns.filter((run: { recurring_run_id: string }) => + run.recurring_run_id.startsWith('sample-recurring-'), + ); + expect(samples).toHaveLength(5); + expect( + samples.filter((run: { trigger: { cron_schedule?: unknown } }) => run.trigger.cron_schedule), + ).toHaveLength(2); + expect(samples.some((run: { status: string }) => run.status === 'DISABLED')).toBe(true); + for (const sample of samples) { + const detail = await request + .get(`/apis/v2/recurringruns/${sample.recurring_run_id}`) + .expect(200); + expect(detail.body.trigger).toEqual(sample.trigger); + } + const upcoming = samples.find((run: { display_name: string }) => + run.display_name.startsWith('Upcoming'), + ); + const expired = samples.find((run: { display_name: string }) => + run.display_name.startsWith('Completed'), + ); + expect(new Date(upcoming.trigger.periodic_schedule.start_time).getTime()).toBeGreaterThan( + Date.now(), + ); + expect(new Date(expired.trigger.periodic_schedule.end_time).getTime()).toBeLessThan(Date.now()); + }); + + it('serves driver logs linked from sample task metadata', async () => { + const request = requests(createMockApiApp()); + const { body } = await request + .get('/apis/v2/runs/sample-run-training-failed/tasks') + .expect(200); + const task = body.tasks.find((task: { name: string }) => task.name === 'train-model'); + const uri = new URL(task.status_metadata.custom_properties.driver_logs_uri); + const response = await request + .get('/artifacts/get') + .query({ source: 's3', bucket: uri.hostname, key: uri.pathname.slice(1) }) + .expect(200); + expect(response.headers['content-type']).toContain('text/plain'); + expect(response.text).toContain('Cache disabled globally at the server level.'); + expect(response.text).toContain('Creating task train-model in pod '); + await request.get('/artifacts/get').query({ key: 'review-driver-logs/missing' }).expect(404); + }); + + it('resolves saved sample versions while retaining inline submissions', async () => { + const request = requests(createMockApiApp()); + for (const [id, versionName] of [ + ['training-running', 'v1.1'], + ['monthly-success', 'v1.0'], + ]) { + const { body: run } = await request.get(`/apis/v2/runs/sample-run-${id}`).expect(200); + const ref = run.pipeline_version_reference; + const { body: version } = await request + .get(`/apis/v2/pipelines/${ref.pipeline_id}/versions/${ref.pipeline_version_id}`) + .expect(200); + expect(version.display_name).toBe(versionName); + expect(version.pipeline_spec).toEqual(run.pipeline_spec); + await request.get(`/apis/v2/pipelines/${ref.pipeline_id}`).expect(200); + } + for (const id of ['sweep-canceled', 'import-failed']) { + const { body: run } = await request.get(`/apis/v2/runs/sample-run-${id}`).expect(200); + expect(run.pipeline_version_reference).toBeUndefined(); + expect(run.pipeline_spec.root.dag.tasks).toHaveProperty('train-model'); + } + }); + it('constructs the app with the mock backend dependency graph', async () => { const request = requests(createMockApiApp()); diff --git a/frontend/server/integration-tests/mock-backend-routes.test.ts b/frontend/server/integration-tests/mock-backend-routes.test.ts index f649e49073c..5bb3783c6ee 100644 --- a/frontend/server/integration-tests/mock-backend-routes.test.ts +++ b/frontend/server/integration-tests/mock-backend-routes.test.ts @@ -60,6 +60,30 @@ describe('mock backend routes', () => { }); describe('v2 fixture routes', () => { + it('resolves sample graph artifact links and their producer/consumer lineage', async () => { + const runId = 'sample-run-daily-success'; + const artifactId = `${runId}-prepare-data-dataset`; + const artifact = await request.get(`/apis/v2/artifacts/${artifactId}`).expect(200); + expect(artifact.body).toMatchObject({ + artifact_id: artifactId, + type: 'Dataset', + name: 'Prepared taxi features', + }); + const lineage = await request + .get('/apis/v2/artifact_tasks') + .query({ artifact_ids: artifactId }) + .expect(200); + expect(lineage.body.artifact_tasks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ task_id: `${runId}-prepare-data`, type: 'OUTPUT' }), + expect.objectContaining({ task_id: `${runId}-train-model`, type: 'TASK_OUTPUT_INPUT' }), + ]), + ); + await request + .get('/apis/v2/artifacts/sample-run-training-failed-train-model-model') + .expect(404); + }); + it('lists pipelines with v2 field names, pagination, and total size', async () => { const response = await request.get('/apis/v2/pipelines?page_token=0&page_size=3').expect(200); @@ -72,6 +96,34 @@ describe('mock backend routes', () => { }); }); + it('combines uploaded timestamps and name filters before pagination', async () => { + const all = await request.get('/apis/v2/pipelines?page_size=100').expect(200); + const pipeline = all.body.pipelines.find( + (item: { display_name: string }) => item.display_name === 'XGBoost', + ); + const predicates = [ + { key: 'name', operation: 'IS_SUBSTRING', string_value: 'XGBoost' }, + { + key: 'created_at', + operation: 'GREATER_THAN_EQUALS', + timestamp_value: pipeline.created_at, + }, + ]; + const inclusive = await request + .get( + `/apis/v2/pipelines${buildQuery({ filter: JSON.stringify({ predicates }), page_size: '1' })}`, + ) + .expect(200); + expect(inclusive.body.pipelines).toHaveLength(1); + predicates[1].timestamp_value = new Date( + new Date(pipeline.created_at).getTime() + 1000, + ).toISOString(); + const excluded = await request + .get(`/apis/v2/pipelines${buildQuery({ filter: JSON.stringify({ predicates }) })}`) + .expect(200); + expect(excluded.body.pipelines || []).toHaveLength(0); + }); + it('filters v2 pipelines by name', async () => { const filter = JSON.stringify({ predicates: [{ key: 'name', operation: 'IS_SUBSTRING', string_value: 'XGBoost' }], @@ -148,6 +200,73 @@ describe('mock backend routes', () => { }); }); + it('filters review runs by status and relative date before pagination', async () => { + const query = { + experiment_id: '275ea11d-ac63-4ce3-bc33-ec81981ed56b', + filter: JSON.stringify({ + predicates: [ + { key: 'state', operation: 'EQUALS', string_value: 'FAILED' }, + { key: 'storage_state', operation: 'EQUALS', string_value: 'AVAILABLE' }, + { + key: 'created_at', + operation: 'GREATER_THAN_EQUALS', + timestamp_value: new Date(Date.now() - 30 * 86_400_000).toISOString(), + }, + ], + }), + sort_by: 'created_at desc', + page_size: 1, + }; + const first = await request.get(`/apis/v2/runs${buildQuery(query)}`).expect(200); + expect(first.body.total_size).toBe(2); + expect(first.body.runs[0].run_id).toBe('sample-run-training-failed'); + const next = await request + .get(`/apis/v2/runs${buildQuery({ ...query, page_token: first.body.next_page_token })}`) + .expect(200); + expect(next.body.runs[0].run_id).toBe('sample-run-import-failed'); + expect(next.body.next_page_token).toBe(''); + }); + + it('keeps archived samples separate and unfinished runs without finish times', async () => { + const filter = JSON.stringify({ + predicates: [{ key: 'storage_state', operation: 'EQUALS', string_value: 'ARCHIVED' }], + }); + const archived = await request.get(`/apis/v2/runs${buildQuery({ filter })}`).expect(200); + expect(archived.body.total_size).toBe(2); + expect( + archived.body.runs.every( + (run: { storage_state: string }) => run.storage_state === 'ARCHIVED', + ), + ).toBe(true); + for (const id of ['training-running', 'validation-pending', 'refresh-canceling']) { + const response = await request.get(`/apis/v2/runs/sample-run-${id}`).expect(200); + expect(response.body.finished_at).toBeUndefined(); + } + }); + + it.each(['nested-conditions', 'parallel-folds', 'regional-training'])( + 'serves complex %s review runs and their saved versions', + async (id) => { + const run = (await request.get(`/apis/v2/runs/sample-run-${id}`).expect(200)).body; + expect(run.state_history[0]).toMatchObject({ + state: 'RUNNING', + update_time: expect.any(String), + }); + const version = ( + await request.get(`/apis/v2/pipelines/${id}/versions/${id}-v1`).expect(200) + ).body; + expect(run.pipeline_spec).toEqual(version.pipeline_spec); + const tasks = (await request.get(`/apis/v2/runs/sample-run-${id}/tasks`).expect(200)).body + .tasks; + expect(tasks.length).toBeGreaterThan(5); + const leaf = tasks.find((task: { type: string }) => task.type === 'RUNTIME'); + const fetched = ( + await request.get(`/apis/v2/runs/sample-run-${id}/tasks/${leaf.task_id}`).expect(200) + ).body; + expect(fetched.task_id).toBe(leaf.task_id); + }, + ); + it('fetches a v2 run by id', async () => { const response = await request .get('/apis/v2/runs/e0115ac1-0479-4194-a22d-01e65e09a32b') @@ -192,15 +311,15 @@ describe('mock backend routes', () => { const listResponse = await request.get('/apis/v2/artifacts').expect(200); expect(listResponse.body).toMatchObject({ - total_size: 1, - artifacts: [ - { + total_size: 6, + artifacts: expect.arrayContaining([ + expect.objectContaining({ artifact_id: 'mock-artifact-1', name: 'mock-dataset', namespace: 'kubeflow-user-example-com', type: 'Dataset', - }, - ], + }), + ]), }); const artifactResponse = await request.get('/apis/v2/artifacts/mock-artifact-1').expect(200); @@ -215,6 +334,78 @@ describe('mock backend routes', () => { }); }); + it('combines artifact type, date and name filters before pagination', async () => { + const predicates = [ + { key: 'type', operation: 'IN', int_values: { values: [2, 3] } }, + { + key: 'created_at', + operation: 'GREATER_THAN_EQUALS', + timestamp_value: '2026-01-01T00:00:00Z', + }, + { key: 'name', operation: 'IS_SUBSTRING', string_value: 'dataset' }, + ]; + const query = { filter: JSON.stringify({ predicates }), page_size: 1, sort_by: 'name desc' }; + const first = await request.get(`/apis/v2/artifacts${buildQuery(query)}`).expect(200); + expect(first.body.total_size).toBe(3); + expect(first.body.artifacts[0].artifact_id).toBe('mock-artifact-3'); + const next = await request + .get(`/apis/v2/artifacts${buildQuery({ ...query, page_token: 1 })}`) + .expect(200); + expect(next.body.total_size).toBe(3); + expect(next.body.artifacts[0].artifact_id).toBe('mock-artifact-2'); + expect(next.body.next_page_token).toBeTruthy(); + }); + + it.each([ + { key: 'type', operation: 'IN', int_values: { values: [4] } }, + { + key: 'created_at', + operation: 'GREATER_THAN_EQUALS', + timestamp_value: '2026-02-01T00:00:00Z', + }, + ])('excludes artifacts that fail a predicate: %j', async (predicate) => { + const response = await request + .get( + `/apis/v2/artifacts${buildQuery({ filter: JSON.stringify({ predicates: [predicate] }) })}`, + ) + .expect(200); + expect(response.body).toMatchObject({ total_size: 0, artifacts: [] }); + }); + + it('filters experiments by their latest run date and excludes experiments without runs', async () => { + const filter = JSON.stringify({ + predicates: [ + { + key: 'last_run_created_at', + operation: 'GREATER_THAN_EQUALS', + timestamp_value: '2026-01-01T00:00:00Z', + }, + ], + }); + const response = await request + .get(`/apis/v2/experiments${buildQuery({ filter })}`) + .expect(200); + expect( + response.body.experiments.map( + (experiment: { display_name: string }) => experiment.display_name, + ), + ).toEqual(['Default']); + expect(Date.parse(response.body.experiments[0].last_run_created_at)).toBeGreaterThan( + Date.now() - 2 * 86_400_000, + ); + }); + + it('does not assign the original dataset lineage to additional sample artifacts', async () => { + const response = await request + .get('/apis/v2/artifact_tasks?artifact_ids=mock-artifact-4') + .expect(200); + expect(response.body.artifact_tasks).toEqual([]); + const original = await request + .get('/apis/v2/artifact_tasks?artifact_ids=mock-artifact-1') + .expect(200); + expect(original.body.artifact_tasks).toHaveLength(2); + }); + it('returns 404 for an unknown native artifact', async () => { await request .get('/apis/v2/artifacts/does-not-exist') @@ -222,7 +413,10 @@ describe('mock backend routes', () => { }); it('serves representative native artifact-task relationships', async () => { - const response = await request.get('/apis/v2/artifact_tasks').expect(200); + const response = await request + .get('/apis/v2/artifact_tasks') + .query({ artifact_ids: 'mock-artifact-1' }) + .expect(200); expect(response.body.artifact_tasks).toEqual([ expect.objectContaining({ diff --git a/frontend/server/static-assets.test.ts b/frontend/server/static-assets.test.ts new file mode 100644 index 00000000000..c5b80587e37 --- /dev/null +++ b/frontend/server/static-assets.test.ts @@ -0,0 +1,253 @@ +// Copyright 2026 The Kubeflow Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import * as fs from 'node:fs/promises'; +import { createServer, request, type IncomingHttpHeaders, type Server } from 'node:http'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { gzipSync } from 'node:zlib'; +import express from 'express'; +import { createPrecompressedStaticApp } from './static-assets.js'; + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, stat: vi.fn(actual.stat) }; +}); + +const source = Buffer.from('window.editor = "' + 'editor contents '.repeat(100) + '";'); +const compressed = gzipSync(source); +let directory: string; +let server: Server; +let port: number; +let serverError: unknown; + +function get(url: string, headers: Record = {}, method = 'GET') { + return new Promise<{ status: number; headers: IncomingHttpHeaders; body: Buffer }>( + (resolve, reject) => { + const req = request({ hostname: '127.0.0.1', port, path: url, method, headers }, (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('error', reject); + res.on('end', () => + resolve({ status: res.statusCode!, headers: res.headers, body: Buffer.concat(chunks) }), + ); + }); + req.on('error', reject); + req.end(); + }, + ); +} + +beforeAll(async () => { + directory = await mkdtemp(path.join(tmpdir(), 'kfp-static-assets-')); + await mkdir(path.join(directory, 'static')); + await Promise.all([ + writeFile(path.join(directory, 'static/Editor-hash.js'), source), + writeFile(path.join(directory, 'static/Editor-hash.js.gz'), compressed), + writeFile(path.join(directory, 'static/Editor-hash.css'), source), + writeFile(path.join(directory, 'static/Editor-hash.css.gz'), compressed), + writeFile(path.join(directory, 'static/Editor.min-hash.js'), source), + writeFile(path.join(directory, 'static/Editor.min-hash.js.gz'), compressed), + writeFile(path.join(directory, 'static/identity-only.js'), source), + writeFile(path.join(directory, 'static/orphan.js.gz'), compressed), + ]); + const app = express(); + const assets = createPrecompressedStaticApp(directory); + app.use('/pipeline', assets); + app.use('/fixture', createPrecompressedStaticApp(directory, { cacheControl: 'no-store' })); + app.use(assets); + app.use((_req, res) => { + res.status(404).send('fallback'); + }); + app.use( + (error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + serverError = error; + res.status(500).send('lookup error'); + }, + ); + server = createServer(app); + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Missing test listener'); + port = address.port; +}); + +afterAll(async () => { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + await rm(directory, { recursive: true, force: true }); +}); + +it.each([ + '/static/Editor-hash.js', + '/pipeline/static/Editor-hash.js', + '/static/Editor.min-hash.js', +])('serves exact gzip bytes with original MIME at %s', async (url) => { + const res = await get(url, { 'Accept-Encoding': 'gzip' }); + expect(res.status).toBe(200); + expect(res.body).toEqual(compressed); + expect(res.headers['content-encoding']).toBe('gzip'); + expect(res.headers['content-type']).toMatch(/javascript/); + expect(res.headers['content-length']).toBe(String(compressed.length)); + expect(res.headers.vary).toBe('Accept-Encoding'); + expect(res.headers['cache-control']).toBe('public, max-age=0'); +}); + +it('preserves CSS MIME and query strings', async () => { + const res = await get('/static/Editor-hash.css?v=1', { 'Accept-Encoding': 'gzip' }); + expect(res.headers['content-type']).toMatch(/^text\/css/); + expect(res.body).toEqual(compressed); +}); + +it.each([ + [undefined, undefined, 200], + ['gzip;q=0, identity;q=1', undefined, 200], + ['gzip;q=0.5, identity;q=1', undefined, 200], + ['gzip;q=1, identity;q=0.5', 'gzip', 200], + ['br, gzip;q=0.5', 'gzip', 200], + ['*;q=1, identity;q=0', 'gzip', 200], + ['gzip;q=bogus, identity;q=1', undefined, 200], + ['gzip;q=0, identity;q=0', undefined, 406], + ['*;q=0', undefined, 406], +])('negotiates %s through Express', async (accept, encoding, status) => { + const res = await get('/static/Editor-hash.js', accept ? { 'Accept-Encoding': accept } : {}); + expect(res.status).toBe(status); + expect(res.headers['content-encoding']).toBe(encoding); + expect(res.headers.vary).toBe('Accept-Encoding'); + if (status === 200) expect(res.body).toEqual(encoding ? compressed : source); +}); + +it('supports missing sidecars without fabricating unavailable encodings', async () => { + const fallback = await get('/static/identity-only.js', { 'Accept-Encoding': 'gzip' }); + expect(fallback.status).toBe(200); + expect(fallback.body).toEqual(source); + expect(fallback.headers.vary).toBe('Accept-Encoding'); + const rejected = await get('/static/identity-only.js', { + 'Accept-Encoding': 'gzip, identity;q=0', + }); + expect(rejected.status).toBe(406); +}); + +it('keeps validators specific to each representation and varies 304 responses', async () => { + const gzip = await get('/static/Editor-hash.js', { 'Accept-Encoding': 'gzip' }); + const identity = await get('/static/Editor-hash.js', { 'Accept-Encoding': 'identity' }); + expect(gzip.headers.etag).not.toBe(identity.headers.etag); + for (const [encoding, etag] of [ + ['gzip', gzip.headers.etag!], + ['identity', identity.headers.etag!], + ]) { + const fresh = await get('/static/Editor-hash.js', { + 'Accept-Encoding': encoding, + 'If-None-Match': etag, + }); + expect(fresh.status).toBe(304); + expect(fresh.headers.vary).toBe('Accept-Encoding'); + expect(fresh.body.length).toBe(0); + } + const different = await get('/static/Editor-hash.js', { + 'Accept-Encoding': 'identity', + 'If-None-Match': gzip.headers.etag!, + }); + expect(different.status).toBe(200); + expect(different.body).toEqual(source); +}); + +it('serves HEAD without a body and ignores gzip ranges while preserving identity ranges', async () => { + const head = await get('/static/Editor-hash.js', { 'Accept-Encoding': 'gzip' }, 'HEAD'); + expect(head.status).toBe(200); + expect(head.body.length).toBe(0); + expect(head.headers['content-length']).toBe(String(compressed.length)); + const gzip = await get('/static/Editor-hash.js', { + 'Accept-Encoding': 'gzip', + Range: 'bytes=0-3', + }); + expect(gzip.status).toBe(200); + expect(gzip.body).toEqual(compressed); + expect(gzip.headers['accept-ranges']).toBeUndefined(); + const identity = await get('/static/Editor-hash.js', { + 'Accept-Encoding': 'identity', + Range: 'bytes=0-3', + }); + expect(identity.status).toBe(206); + expect(identity.body).toEqual(source.subarray(0, 4)); +}); + +it('allows the qualification fixture to preserve no-store caching', async () => { + const res = await get('/fixture/static/Editor-hash.js', { 'Accept-Encoding': 'gzip' }); + expect(res.status).toBe(200); + expect(res.headers['cache-control']).toBe('no-store'); +}); + +it.each([ + '/static/orphan.js', + '/static/missing.js', + '/index.html', + '/apis/v2/runs', + '/apis/v2beta1/runs', + '/artifacts/get', + '/static/../Editor-hash.js', + '/static/%2e%2e/Editor-hash.js', + '/static/nested/Editor-hash.js', +])('leaves non-assets or missing originals to existing routes: %s', async (url) => { + const res = await get(url, { 'Accept-Encoding': 'gzip' }); + expect(res.status).toBe(404); + expect(res.body.toString()).toBe('fallback'); + expect(res.headers['content-encoding']).toBeUndefined(); +}); + +it('does not handle mutations', async () => { + expect((await get('/static/Editor-hash.js', { 'Accept-Encoding': 'gzip' }, 'POST')).status).toBe( + 404, + ); +}); + +it('leaves an overlong asset filename to existing routes', async () => { + const res = await get(`/static/${'x'.repeat(300)}.js`, { 'Accept-Encoding': 'gzip' }); + expect(res.status).toBe(404); + expect(res.body.toString()).toBe('fallback'); + expect(res.headers['content-encoding']).toBeUndefined(); +}); + +it.each(['ENOENT', 'ENOTDIR', 'ENAMETOOLONG'])( + 'preserves static-route fallthrough for %s lookup failures', + async (code) => { + const lookup = vi + .mocked(fs.stat) + .mockRejectedValueOnce(Object.assign(new Error(code), { code })); + try { + const res = await get('/static/Editor-hash.js', { 'Accept-Encoding': 'gzip' }); + expect(res.status).toBe(404); + expect(res.body.toString()).toBe('fallback'); + expect(res.headers['content-encoding']).toBeUndefined(); + } finally { + lookup.mockReset(); + } + }, +); + +it.each(['EACCES', 'EIO'])('preserves unexpected %s lookup errors', async (code) => { + const failure = Object.assign(new Error(code), { code }); + const lookup = vi.mocked(fs.stat).mockRejectedValueOnce(failure); + try { + const res = await get('/static/Editor-hash.js', { 'Accept-Encoding': 'gzip' }); + expect(res.status).toBe(500); + expect(serverError).toBe(failure); + } finally { + lookup.mockReset(); + } +}); diff --git a/frontend/server/static-assets.ts b/frontend/server/static-assets.ts new file mode 100644 index 00000000000..7b108526fd9 --- /dev/null +++ b/frontend/server/static-assets.ts @@ -0,0 +1,87 @@ +// Copyright 2026 The Kubeflow Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { stat } from 'node:fs/promises'; +import path from 'node:path'; +import express from 'express'; + +async function isFile(filename: string): Promise { + try { + return (await stat(filename)).isFile(); + } catch (error) { + // Match send's lookup fallthrough; permission and I/O failures remain errors. + if ( + ['ENOENT', 'ENOTDIR', 'ENAMETOOLONG'].includes((error as NodeJS.ErrnoException).code || '') + ) { + return false; + } + throw error; + } +} + +/** Serve build-time gzip representations without changing the public asset URLs. */ +export function createPrecompressedStaticApp( + staticDir: string, + options: { cacheControl?: string } = {}, +): express.Application { + const app = express(); + app.disable('x-powered-by'); + app.use(async (req, res, next) => { + // Vite emits flat public assets. Never handle HTML, API/proxy responses, + // user artifacts, encoded path separators, or nested/traversal paths. + if ( + !['GET', 'HEAD'].includes(req.method) || + !/^\/static\/[A-Za-z0-9_.-]+\.(js|css)$/.test(req.path) + ) { + next(); + return; + } + try { + const original = path.resolve(staticDir, `.${req.path}`); + if (!(await isFile(original))) { + next(); + return; + } + const gzip = `${original}.gz`; + const available = (await isFile(gzip)) ? ['gzip', 'identity'] : ['identity']; + const encoding = req.acceptsEncodings(...available); + res.vary('Accept-Encoding'); + if (!encoding) { + res.sendStatus(406); + return; + } + const compressed = encoding === 'gzip'; + res.type(path.extname(original)); + if (compressed) res.setHeader('Content-Encoding', 'gzip'); + res.sendFile( + compressed ? gzip : original, + { + // Ignore Range for compressed representations; identity retains the + // existing static handler's range behavior. sendFile owns validators. + acceptRanges: !compressed, + ...(options.cacheControl ? { headers: { 'Cache-Control': options.cacheControl } } : {}), + }, + (error) => { + if (error) { + if (!res.headersSent) res.removeHeader('Content-Encoding'); + next(error); + } + }, + ); + } catch (error) { + next(error); + } + }); + return app; +} diff --git a/frontend/src/Css.test.tsx b/frontend/src/Css.test.tsx deleted file mode 100644 index a3d611168d2..00000000000 --- a/frontend/src/Css.test.tsx +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright 2018 The Kubeflow Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { spacing, _paddingInternal } from './Css'; -import * as Css from './Css'; - -describe('Css', () => { - describe('theme typography', () => { - it('uses a numeric base font size and valid derived variant sizes', () => { - expect(typeof Css.theme.typography.fontSize).toBe('number'); - expect(Number.isFinite(Css.theme.typography.fontSize)).toBe(true); - expect(String(Css.theme.typography.body1.fontSize)).not.toContain('NaN'); - expect(String(Css.theme.typography.body2.fontSize)).not.toContain('NaN'); - }); - }); - - describe('padding', () => { - it('returns padding units in all directions by default', () => { - expect(_paddingInternal()).toEqual({ - paddingBottom: spacing.base, - paddingLeft: spacing.base, - paddingRight: spacing.base, - paddingTop: spacing.base, - }); - }); - - it('returns specified padding units in all directions', () => { - expect(_paddingInternal(100)).toEqual({ - paddingBottom: 100, - paddingLeft: 100, - paddingRight: 100, - paddingTop: 100, - }); - }); - - it('returns default units in specified directions', () => { - expect(_paddingInternal(undefined, 'lr')).toEqual({ - paddingLeft: spacing.base, - paddingRight: spacing.base, - }); - }); - - it('returns a non-empty CSS class name', () => { - const result = Css.padding(100, 'lr'); - expect(typeof result).toBe('string'); - expect(result.length).toBeGreaterThan(0); - }); - }); -}); diff --git a/frontend/src/Css.tsx b/frontend/src/Css.tsx deleted file mode 100644 index 40a056845f8..00000000000 --- a/frontend/src/Css.tsx +++ /dev/null @@ -1,409 +0,0 @@ -/* - * Copyright 2018 The Kubeflow Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { createTheme } from '@mui/material/styles'; -import { style, stylesheet } from 'typestyle'; -import { NestedCSSProperties } from 'typestyle/lib/types'; - -// Gradually migrate the css styling workflow as below: -// 1. Navigate to tailwind.config.js file. -// 2. Extend styling in the way tailwindcss can recognize. -// a. For color: Use https://material.io/resources/color. -// 3. Compile CSS: npm run build:tailwind - -export const color = { - activeBg: '#eaf1fd', - alert: '#f9ab00', // Google yellow 600 - background: '#fff', - blue: '#4285f4', // Google blue 500 - disabledBg: '#ddd', - divider: '#e0e0e0', - errorBg: '#fbe9e7', - errorText: '#d50000', - foreground: '#000', - graphBg: '#f2f2f2', - grey: '#5f6368', // Google grey 500 - inactive: '#5f6368', - lightGrey: '#eeeeee', // Google grey 200 - lowContrast: '#80868b', // Google grey 600 - secondaryText: 'rgba(0, 0, 0, .88)', - separator: '#e8e8e8', - strong: '#202124', // Google grey 900 - success: '#34a853', - successWeak: '#e6f4ea', // Google green 50 - terminated: '#80868b', - theme: '#1a73e8', - themeDarker: '#0049b5', - warningBg: '#f9f9e1', - warningText: '#ee8100', - infoBg: '#f3f4ff', - infoText: '#1a73e8', - tooltipBg: '#1d2744', - tooltipShadow: '#aaa', - weak: '#9aa0a6', - link: '#0d47a1', - linkLight: '#5472d3', - whiteSmoke: '#f3f3f3', -}; - -export const dimension = { - auto: 'auto', - base: 40, - jumbo: 64, - large: 48, - small: 36, - tiny: 24, - xlarge: 56, - xsmall: 32, -}; - -// tslint:disable:object-literal-sort-keys -export const zIndex = { - DROP_ZONE_OVERLAY: 1, - GRAPH_NODE: 1, - BUSY_OVERLAY: 2, - PIPELINE_SUMMARY_CARD: 2, - SIDE_PANEL: 2, -}; - -export const fontsize = { - small: 12, - base: 14, - medium: 16, - large: 18, - title: 18, - pageTitle: 24, -}; -// tslint:enable:object-literal-sort-keys - -const baseSpacing = 24; -export const spacing = { - base: baseSpacing, - units: (unit: number) => baseSpacing + unit * 4, -}; - -export const fonts = { - code: '"Source Code Pro", monospace', - main: '"Google Sans", "Helvetica Neue", sans-serif', - secondary: '"Roboto", "Helvetica Neue", sans-serif', -}; - -const palette = { - primary: { - dark: color.themeDarker, - main: color.theme, - }, - secondary: { - main: 'rgba(0, 0, 0, .38)', - }, -}; - -export const theme = createTheme({ - components: { - MuiButton: { - styleOverrides: { - text: { - fontSize: fontsize.base, - fontWeight: 'bold', - minHeight: dimension.tiny, - textTransform: 'none', - }, - textPrimary: { - border: '1px solid #ddd', - cursor: 'pointer', - fontSize: fontsize.base, - marginRight: 10, - textTransform: 'none', - }, - textSecondary: { - color: color.theme, - }, - root: { - '&.Mui-disabled': { - backgroundColor: 'initial', - }, - color: color.theme, - marginRight: 10, - padding: '0 8px', - }, - }, - }, - MuiDialogActions: { - styleOverrides: { - root: { - margin: 15, - }, - }, - }, - MuiDialogTitle: { - styleOverrides: { - root: { - fontSize: fontsize.large, - }, - }, - }, - MuiFormControlLabel: { - styleOverrides: { - root: { - marginLeft: 0, - }, - }, - }, - MuiFormLabel: { - styleOverrides: { - filled: { - marginLeft: 0, - marginTop: 0, - }, - root: { - '&.Mui-focused': { - marginLeft: 0, - marginTop: 0, - }, - fontSize: fontsize.base, - marginLeft: 5, - marginTop: -8, - }, - }, - }, - MuiIconButton: { - styleOverrides: { - root: { - padding: 9, - }, - }, - }, - MuiInput: { - styleOverrides: { - input: { padding: 0 }, - root: { padding: 0 }, - }, - }, - MuiInputAdornment: { - styleOverrides: { - positionEnd: { - paddingRight: 0, - }, - root: { padding: 0 }, - }, - }, - MuiTableSortLabel: { - styleOverrides: { - // Match v3 behavior more closely: keep header text color inherited - // from table column styles and avoid washed-out inactive labels. - root: { - '&.Mui-active': { - color: 'inherit', - }, - '&:hover': { - color: 'inherit', - }, - color: 'inherit', - }, - icon: { - color: `${color.strong} !important`, - }, - }, - }, - MuiSvgIcon: { - styleOverrides: { - // Keep icon sizing consistent with MUI v3 where these were fixed pixel values. - root: { fontSize: 24 }, - fontSizeSmall: { fontSize: 20 }, - fontSizeLarge: { fontSize: 35 }, - }, - }, - MuiTooltip: { - styleOverrides: { - tooltip: { - backgroundColor: '#666', - color: '#f1f1f1', - fontSize: 12, - }, - }, - }, - }, - palette, - typography: { - fontFamily: fonts.main, - fontSize: fontsize.base, - }, -}); - -export const commonCss = stylesheet({ - absoluteCenter: { - left: 'calc(50% - 15px)', - position: 'absolute', - top: 'calc(50% - 15px)', - }, - busyOverlay: { - backgroundColor: '#ffffffaa', - bottom: 0, - left: 0, - position: 'absolute', - right: 0, - top: 0, - zIndex: zIndex.BUSY_OVERLAY, - }, - buttonAction: { - $nest: { - '&:disabled': { - backgroundColor: color.background, - }, - '&:hover': { - backgroundColor: theme.palette.primary.dark, - }, - }, - backgroundColor: palette.primary.main, - color: 'white', - }, - ellipsis: { - display: 'block', - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - }, - flex: { - alignItems: 'center !important', - display: 'flex !important', - flexShrink: 0, - }, - flexColumn: { - display: 'flex !important', - flexDirection: 'column', - }, - flexGrow: { - display: 'flex !important', - flexGrow: 1, - }, - header: { - fontSize: fontsize.large, - fontWeight: 'bold', - paddingBottom: 16, - paddingTop: 20, - }, - header2: { - fontSize: fontsize.medium, - fontWeight: 'bold', - paddingBottom: 16, - paddingTop: 20, - }, - infoIcon: { - color: color.lowContrast, - height: 16, - width: 16, - }, - link: { - $nest: { - '&:hover': { - color: color.linkLight, - textDecoration: 'underline', - cursor: 'pointer', - }, - }, - color: color.strong, - cursor: 'pointer', - textDecoration: 'none', - }, - noShrink: { - flexShrink: 0, - }, - page: { - display: 'flex', - flexFlow: 'column', - flexGrow: 1, - overflow: 'auto', - }, - pageOverflowHidden: { - display: 'flex', - flexFlow: 'column', - flexGrow: 1, - overflowX: 'auto', - overflowY: 'hidden', - }, - prewrap: { - whiteSpace: 'pre-wrap', - }, - scrollContainer: { - background: `linear-gradient(white 30%, rgba(255,255,255,0)), - linear-gradient(rgba(255,255,255,0), white 70%) 0 100%, - radial-gradient(farthest-corner at 50% 0, rgba(0,0,0,.2), rgba(0,0,0,0)), - radial-gradient(farthest-corner at 50% 100%, rgba(0,0,0,.2), rgba(0,0,0,0)) 0 100%`, - backgroundAttachment: 'local, local, scroll, scroll', - backgroundColor: 'white', - backgroundRepeat: 'no-repeat', - backgroundSize: '100% 40px, 100% 40px, 100% 2px, 100% 2px', - overflow: 'auto', - position: 'relative', - }, - textField: { - display: 'flex', - height: 40, - marginBottom: 20, - marginTop: 15, - }, - unstyled: { - color: 'inherit', - outline: 'none', - textDecoration: 'none', - }, - transitiveReductionSwitch: { - position: 'absolute', - top: 0, - left: 0, - }, - codeEditor: { - $nest: { - '& .CodeMirror': { - height: '100%', - width: '80%', - }, - - '& .CodeMirror-gutters': { - backgroundColor: '#f7f7f7', - }, - }, - background: '#f7f7f7', - height: '100%', - }, -}); - -export const tailwindcss = { - sideNavItem: 'flex flex-row flex-shrink-0', -}; - -export function _paddingInternal(units?: number, directions?: string): NestedCSSProperties { - units = units || baseSpacing; - directions = directions || 'blrt'; - const rules: NestedCSSProperties = {}; - if (directions.indexOf('b') > -1) { - rules.paddingBottom = units; - } - if (directions.indexOf('l') > -1) { - rules.paddingLeft = units; - } - if (directions.indexOf('r') > -1) { - rules.paddingRight = units; - } - if (directions.indexOf('t') > -1) { - rules.paddingTop = units; - } - return rules; -} - -export function padding(units?: number, directions?: string): string { - return style(_paddingInternal(units, directions)); -} diff --git a/frontend/src/TestUtils.ts b/frontend/src/TestUtils.ts index 77f15429261..269b6fe6964 100644 --- a/frontend/src/TestUtils.ts +++ b/frontend/src/TestUtils.ts @@ -23,7 +23,7 @@ import { QueryClient } from '@tanstack/react-query'; import React from 'react'; import { Location, useLocation } from 'react-router'; import { beforeEach, expect, MockInstance } from 'vitest'; -import { ToolbarActionConfig } from './components/Toolbar'; +import { ToolbarActionConfig } from 'src/lib/PageChromeTypes'; import { Feature } from './features'; import { logger } from './lib/Utils'; import { Page, PageProps } from './pages/Page'; diff --git a/frontend/src/__mocks__/typestyle.js b/frontend/src/__mocks__/typestyle.js deleted file mode 100644 index 06e1424f694..00000000000 --- a/frontend/src/__mocks__/typestyle.js +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2018 The Kubeflow Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -const { classes } = require('typestyle'); - -// Mocks the typestyle module to emit a fixed string for class names to avoid -// test churn on style changes -module.exports = { - classes, - cssRaw: () => null, - cssRule: () => null, - style: () => '', - stylesheet: obj => { - const mock = {}; - Object.keys(obj).forEach(key => { - mock[key] = key; - }); - return mock; - }, -}; diff --git a/frontend/src/atoms/BusyButton.test.tsx b/frontend/src/atoms/BusyButton.test.tsx index b96a85a5da4..cc7df455ebf 100644 --- a/frontend/src/atoms/BusyButton.test.tsx +++ b/frontend/src/atoms/BusyButton.test.tsx @@ -15,8 +15,8 @@ */ import BusyButton from './BusyButton'; -import TestIcon from '@mui/icons-material/Help'; -import { render } from '@testing-library/react'; +import { CircleHelp as TestIcon } from 'lucide-react'; +import { fireEvent, render, screen } from '@testing-library/react'; describe('BusyButton', () => { it('renders with just a title', () => { @@ -47,4 +47,19 @@ describe('BusyButton', () => { const { asFragment } = render(); expect(asFragment()).toMatchSnapshot(); }); + it('disables duplicate actions while busy and keeps the accessible title', () => { + const onClick = vi.fn(); + const { rerender } = render(); + const button = screen.getByRole('button', { name: 'Start' }); + fireEvent.click(button); + expect(onClick).toHaveBeenCalledTimes(1); + rerender(); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute('aria-busy', 'true'); + fireEvent.click(button); + expect(onClick).toHaveBeenCalledTimes(1); + rerender(); + expect(button).not.toBeDisabled(); + expect(button).not.toHaveAttribute('aria-busy'); + }); }); diff --git a/frontend/src/atoms/BusyButton.tsx b/frontend/src/atoms/BusyButton.tsx index ed8282e3172..0003174f10d 100644 --- a/frontend/src/atoms/BusyButton.tsx +++ b/frontend/src/atoms/BusyButton.tsx @@ -14,64 +14,40 @@ * limitations under the License. */ -import * as React from 'react'; -import { ButtonProps } from '@mui/material/Button'; -import { stylesheet, classes } from 'typestyle'; -import { Button, CircularProgress } from '@mui/material'; +import type { ComponentType } from 'react'; +import { LoaderCircle } from 'lucide-react'; +import { Button, type ButtonProps } from '../components/ui/button'; +import { cn } from '../components/ui/utils'; +import './SharedAtoms.css'; -const css = stylesheet({ - icon: { - height: 20, - marginRight: 4, - width: 20, - }, - root: { - cursor: 'pointer', - marginBottom: 2, // To prevent container from flickering as the spinner shows up - position: 'relative', - transition: 'padding 0.3s', - }, - rootBusy: { - cursor: 'default', - paddingRight: 35, - }, - spinner: { - opacity: 0, - position: 'absolute', - right: '0.8em', - transition: 'all 0.3s', - }, - spinnerBusy: { - opacity: 1, - }, -}); - -interface BusyButtonProps extends ButtonProps { +type BusyButtonProps = Omit & { title: string; - icon?: any; + icon?: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>; busy?: boolean; outlined?: boolean; -} +}; -class BusyButton extends React.Component { - public render(): React.JSX.Element { - const { title, busy, className, disabled, icon, outlined, ...rest } = this.props; - - return ( - - ); - } +export default function BusyButton({ + title, + icon: Icon, + busy, + outlined, + disabled, + className, + variant, + ...props +}: BusyButtonProps) { + return ( + + ); } - -export default BusyButton; diff --git a/frontend/src/atoms/CardTooltip.tsx b/frontend/src/atoms/CardTooltip.tsx deleted file mode 100644 index 3ecaedae2a8..00000000000 --- a/frontend/src/atoms/CardTooltip.tsx +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2020 Arrikto Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React, { ReactNode } from 'react'; -import { color, fontsize } from '../Css'; -import { Card, CardContent, Tooltip, tooltipClasses, TooltipProps } from '@mui/material'; -import { styled } from '@mui/material/styles'; - -const NostyleTooltip = styled(({ className, ...props }: TooltipProps) => ( - -))({ - [`& .${tooltipClasses.tooltip}`]: { - backgroundColor: 'transparent', - border: '0 none', - color: color.secondaryText, - fontSize: fontsize.base, - maxWidth: 220, - }, -}); - -interface CardTooltipProps { - helpText?: ReactNode; - children: React.ReactElement; -} -export const CardTooltip: React.FC = (props) => { - return ( - - {props.helpText} - - } - // v4 used `interactive={true}`; the v5 equivalent is `disableInteractive={false}`. - disableInteractive={false} - leaveDelay={400} - placement='top' - > - {props.children} - - ); -}; diff --git a/frontend/src/atoms/ErrorBoundary.css b/frontend/src/atoms/ErrorBoundary.css new file mode 100644 index 00000000000..a42866ed640 --- /dev/null +++ b/frontend/src/atoms/ErrorBoundary.css @@ -0,0 +1,34 @@ +/* The outer error boundary can render before or outside the application's theme provider. */ +.kfp-error-boundary { + margin: 20px; + padding: 16px; + border: 1px solid var(--status-failed, #c9302c); + border-radius: 10px; + color: var(--foreground, #15171e); + background: var(--status-failed-soft, #fbe8e7); + font-family: var(--kfp-font-sans, system-ui, sans-serif); + font-size: 14px; + line-height: 1.5; +} +.kfp-error-boundary > p { + margin: 0 0 12px; + color: var(--status-failed, #c9302c); + font-weight: 600; +} +.kfp-error-boundary summary { + width: fit-content; + cursor: pointer; + font-weight: 600; +} +.kfp-error-boundary summary:focus-visible { + outline: 2px solid var(--primary, #2563d9); + outline-offset: 4px; + border-radius: 2px; +} +.kfp-error-boundary pre { + margin: 12px 0 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + font-family: var(--kfp-font-mono, ui-monospace, monospace); + font-size: 12px; +} diff --git a/frontend/src/atoms/ErrorBoundary.test.tsx b/frontend/src/atoms/ErrorBoundary.test.tsx index 5a8fb5add24..8bdba705278 100644 --- a/frontend/src/atoms/ErrorBoundary.test.tsx +++ b/frontend/src/atoms/ErrorBoundary.test.tsx @@ -14,7 +14,8 @@ * limitations under the License. */ -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import React, { useState } from 'react'; import { testBestPractices } from 'src/TestUtils'; import { ErrorBoundary } from './ErrorBoundary'; @@ -34,8 +35,9 @@ describe('ErrorBoundary', () => { expect(screen.getByText('child content')).toBeInTheDocument(); }); - it('renders a Banner with error details when a child throws', () => { + it('exposes focusable native diagnostics without a theme provider', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const user = userEvent.setup(); render( @@ -43,9 +45,27 @@ describe('ErrorBoundary', () => { , ); - expect(screen.getByText('Something went wrong.')).toBeInTheDocument(); - expect(screen.getByText('Details')).toBeInTheDocument(); - + const alert = screen.getByRole('alert'); + expect(within(alert).getByText('Something went wrong.')).toBeVisible(); + const summary = within(alert).getByText('Details'); + const details = summary.closest('details'); + expect(summary.tagName).toBe('SUMMARY'); + const diagnostics = within(alert).getByText(/Error: test render crash/); + expect(details).not.toHaveAttribute('open'); + expect(diagnostics).not.toBeVisible(); + + await user.tab(); + expect(summary).toHaveFocus(); + // jsdom does not implement the browser's native summary keyboard activation. + await user.click(summary); + expect(details).toHaveAttribute('open'); + expect(diagnostics).toBeVisible(); + expect(diagnostics).toHaveTextContent('ThrowingChild'); + + await user.click(summary); + expect(details).not.toHaveAttribute('open'); + expect(diagnostics).not.toBeVisible(); + expect(summary).toHaveFocus(); consoleSpy.mockRestore(); }); @@ -79,12 +99,18 @@ describe('ErrorBoundary', () => { , ); + const details = screen.getByText('Details').closest('details'); + fireEvent.click(screen.getByText('Details')); + expect(details).toHaveAttribute('open'); + view.rerender(
recovered content
, ); + expect(details).toHaveAttribute('open'); + expect(screen.getByText(/Error: test render crash/)).toBeVisible(); expect(screen.getByText('Something went wrong.')).toBeInTheDocument(); expect(screen.queryByText('recovered content')).not.toBeInTheDocument(); consoleSpy.mockRestore(); @@ -107,8 +133,12 @@ describe('ErrorBoundary', () => { render(); expect(screen.getByText('Something went wrong.')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Details')); + expect(screen.getByText(/Error: test render crash/)).toBeVisible(); fireEvent.click(screen.getByRole('button', { name: 'navigate' })); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.queryByText('Details')).not.toBeInTheDocument(); expect(screen.queryByText('Something went wrong.')).not.toBeInTheDocument(); expect(screen.getByText('recovered content')).toBeInTheDocument(); diff --git a/frontend/src/atoms/ErrorBoundary.tsx b/frontend/src/atoms/ErrorBoundary.tsx index 0719149845c..6196b93d52d 100644 --- a/frontend/src/atoms/ErrorBoundary.tsx +++ b/frontend/src/atoms/ErrorBoundary.tsx @@ -15,11 +15,11 @@ */ import React from 'react'; -import Banner from 'src/components/Banner'; +import './ErrorBoundary.css'; interface ErrorBoundaryState { - error: any; - errorInfo: any; + error: unknown; + errorInfo: React.ErrorInfo | null; } type ErrorBoundaryProps = React.PropsWithChildren<{ resetKey?: string }>; @@ -37,7 +37,7 @@ export class ErrorBoundary extends React.Component - - +
+

Something went wrong.

+
+ Details +
{diagnostics}
+
+
); } return this.props.children; diff --git a/frontend/src/atoms/ExternalLink.test.tsx b/frontend/src/atoms/ExternalLink.test.tsx new file mode 100644 index 00000000000..84bb496abf8 --- /dev/null +++ b/frontend/src/atoms/ExternalLink.test.tsx @@ -0,0 +1,52 @@ +/* + * Copyright 2018 The Kubeflow Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createRef } from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { AutoLink, ExternalLink } from './ExternalLink'; + +it('preserves external href, callback, custom classes and ref while opening safely', () => { + const click = vi.fn((event) => event.preventDefault()); + const ref = createRef(); + render( + + Documentation + , + ); + const link = screen.getByRole('link', { name: 'Documentation' }); + expect(link).toHaveAttribute('href', 'https://example.test/docs'); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener'); + expect(link).toHaveClass('kfp-external-link', 'custom'); + expect(ref.current).toBe(link); + fireEvent.click(link); + expect(click).toHaveBeenCalledTimes(1); +}); + +it('keeps hash navigation in the current tab and external links in a new tab', () => { + render( + <> + Pipeline + Website + , + ); + expect(screen.getByRole('link', { name: 'Pipeline' })).not.toHaveAttribute('target'); + expect(screen.getByRole('link', { name: 'Pipeline' })).toHaveAttribute( + 'href', + '#/pipelines/details/id', + ); + expect(screen.getByRole('link', { name: 'Website' })).toHaveAttribute('target', '_blank'); +}); diff --git a/frontend/src/atoms/ExternalLink.tsx b/frontend/src/atoms/ExternalLink.tsx index 351db43ffb5..204657878dd 100644 --- a/frontend/src/atoms/ExternalLink.tsx +++ b/frontend/src/atoms/ExternalLink.tsx @@ -14,34 +14,17 @@ * limitations under the License. */ -import React, { DetailedHTMLProps, AnchorHTMLAttributes } from 'react'; -import { classes, stylesheet } from 'typestyle'; -import { color } from '../Css'; +import type { ComponentProps } from 'react'; +import { cn } from '../components/ui/utils'; +import './SharedAtoms.css'; -const css = stylesheet({ - link: { - $nest: { - '&:hover': { - textDecoration: 'underline', - }, - }, - color: color.theme, - textDecoration: 'none', - wordBreak: 'break-all', // Links do not need to break at words. - }, -}); - -export const ExternalLink: React.FC< - DetailedHTMLProps, HTMLAnchorElement> -> = (props) => ( - +export const ExternalLink = ({ className, ...props }: ComponentProps<'a'>) => ( + ); -export const AutoLink: React.FC< - DetailedHTMLProps, HTMLAnchorElement> -> = (props) => - props.href && props.href.startsWith('#') ? ( - +export const AutoLink = ({ className, ...props }: ComponentProps<'a'>) => + props.href?.startsWith('#') ? ( + ) : ( - + ); diff --git a/frontend/src/atoms/HelpButton.test.tsx b/frontend/src/atoms/HelpButton.test.tsx index d7e2f7b5ca2..8266f7dd60c 100644 --- a/frontend/src/atoms/HelpButton.test.tsx +++ b/frontend/src/atoms/HelpButton.test.tsx @@ -1,11 +1,11 @@ -/** - * Copyright 2026 The Kubeflow Authors +/* + * Copyright 2018 The Kubeflow Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, @@ -14,12 +14,68 @@ * limitations under the License. */ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { HelpButton } from './HelpButton'; +import { ExternalLink } from './ExternalLink'; +import { ThemeProvider } from '../components/shell/ThemeProvider'; -describe('HelpButton', () => { - it('gives the icon-only button an accessible name', () => { - render(); - expect(screen.getByRole('button', { name: 'Help' })).toBeInTheDocument(); - }); +beforeEach(() => { + localStorage.clear(); + vi.stubGlobal('matchMedia', (media: string) => ({ + media, + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })); +}); +afterEach(() => vi.unstubAllGlobals()); + +function example() { + render( + + + Read the{' '} + + required permissions + + . +

+ } + /> +
, + ); +} + +it('opens rich help by keyboard, exposes its link, and restores focus on Escape', async () => { + const user = userEvent.setup(); + example(); + const trigger = screen.getByRole('button', { name: 'About service accounts' }); + await user.tab(); + expect(trigger).toHaveFocus(); + await user.keyboard('{Enter}'); + const popup = await screen.findByRole('dialog', { name: 'About service accounts' }); + expect(popup.closest('.kfp-theme')).toHaveClass('dark'); + await waitFor(() => + expect(within(popup).getByRole('button', { name: 'Close help' })).toHaveFocus(), + ); + await user.tab(); + const link = within(popup).getByRole('link', { name: 'required permissions' }); + expect(link).toHaveFocus(); + expect(link).toHaveAttribute('href', 'https://example.test/permissions'); + await user.keyboard('{Escape}'); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + await waitFor(() => expect(trigger).toHaveFocus()); +}); + +it('retains pointer help and an explicit close control', async () => { + const user = userEvent.setup(); + example(); + await user.hover(screen.getByRole('button', { name: 'About service accounts' })); + const popup = await screen.findByRole('dialog', { name: 'About service accounts' }); + await user.click(within(popup).getByRole('button', { name: 'Close help' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); }); diff --git a/frontend/src/atoms/HelpButton.tsx b/frontend/src/atoms/HelpButton.tsx index 4eb5dbbc1a1..0a3e6086422 100644 --- a/frontend/src/atoms/HelpButton.tsx +++ b/frontend/src/atoms/HelpButton.tsx @@ -14,20 +14,45 @@ * limitations under the License. */ -import HelpIcon from '@mui/icons-material/Help'; -import React, { ReactNode } from 'react'; -import { CardTooltip } from './CardTooltip'; -import { IconButton } from '@mui/material'; +import type { ReactNode } from 'react'; +import { Popover } from '@base-ui/react/popover'; +import { CircleHelp, X } from 'lucide-react'; +import { Button } from '../components/ui/button'; +import { useTheme } from '../components/shell/ThemeProvider'; +import './SharedAtoms.css'; interface HelpButtonProps { helpText?: ReactNode; + label?: string; } -export const HelpButton: React.FC = ({ helpText }) => { + +export function HelpButton({ helpText, label = 'Help' }: HelpButtonProps) { + const { themeClassName } = useTheme(); return ( - - - - - + + } + > + + + + + +
+ {label} + } + > + + +
+ }>{helpText} +
+
+
+
); -}; +} diff --git a/frontend/src/atoms/IconWithTooltip.test.tsx b/frontend/src/atoms/IconWithTooltip.test.tsx deleted file mode 100644 index 51a51d793a1..00000000000 --- a/frontend/src/atoms/IconWithTooltip.test.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2019 The Kubeflow Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import IconWithTooltip from './IconWithTooltip'; -import TestIcon from '@mui/icons-material/Help'; -import { render } from '@testing-library/react'; - -describe('IconWithTooltip', () => { - it('renders without height or weight', () => { - const { asFragment } = render( - , - ); - expect(asFragment()).toMatchSnapshot(); - }); - - it('renders with height and weight', () => { - const { asFragment } = render( - , - ); - expect(asFragment()).toMatchSnapshot(); - }); -}); diff --git a/frontend/src/atoms/IconWithTooltip.tsx b/frontend/src/atoms/IconWithTooltip.tsx deleted file mode 100644 index e6a5ae9a293..00000000000 --- a/frontend/src/atoms/IconWithTooltip.tsx +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright 2019 The Kubeflow Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import * as React from 'react'; -import { SvgIconProps } from '@mui/material/SvgIcon'; -import { Tooltip } from '@mui/material'; - -interface IconWithTooltipProps { - Icon: React.ComponentType; - height?: number; - iconColor: string; - tooltip: string; - width?: number; -} - -const IconWithTooltip = (props: IconWithTooltipProps) => { - const { height, Icon, iconColor, tooltip, width } = props; - - return ( - - - - ); -}; - -export default IconWithTooltip; diff --git a/frontend/src/atoms/Input.test.tsx b/frontend/src/atoms/Input.test.tsx deleted file mode 100644 index bdc9a182a2d..00000000000 --- a/frontend/src/atoms/Input.test.tsx +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright 2018 The Kubeflow Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { render } from '@testing-library/react'; -import { vi } from 'vitest'; -import Input from './Input'; - -describe('Input', () => { - const handleChange = vi.fn(); - const value = 'some input value'; - - it('renders with the right styles by default', () => { - const { asFragment } = render( - , - ); - expect(asFragment()).toMatchSnapshot(); - }); - - it('accepts height and width as prop overrides', () => { - const { asFragment } = render( - , - ); - expect(asFragment()).toMatchSnapshot(); - }); -}); diff --git a/frontend/src/atoms/Input.tsx b/frontend/src/atoms/Input.tsx deleted file mode 100644 index e6f95e4e7bb..00000000000 --- a/frontend/src/atoms/Input.tsx +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright 2018 The Kubeflow Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { OutlinedTextFieldProps } from '@mui/material/TextField'; -import { commonCss } from '../Css'; - -import { TextField } from '@mui/material'; - -interface InputProps extends OutlinedTextFieldProps { - height?: number | string; - maxWidth?: number | string; - width?: number; -} - -const Input = (props: InputProps) => { - const { height, maxWidth, variant, width, ...rest } = props; - return ( - - {props.children} - - ); -}; - -export default Input; diff --git a/frontend/src/atoms/MD2Tabs.test.tsx b/frontend/src/atoms/MD2Tabs.test.tsx deleted file mode 100644 index 500ead712f4..00000000000 --- a/frontend/src/atoms/MD2Tabs.test.tsx +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright 2018 The Kubeflow Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { fireEvent, render, screen } from '@testing-library/react'; -import { vi } from 'vitest'; -import { logger } from '../lib/Utils'; -import MD2Tabs from './MD2Tabs'; - -describe('MD2Tabs', () => { - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - }); - it('renders with the right styles by default', () => { - const { asFragment } = render(); - expect(asFragment()).toMatchSnapshot(); - }); - - it('does not throw when clicking an unselected tab without onSwitch handler', () => { - render(); - expect(() => fireEvent.click(screen.getByRole('button', { name: 'tab2' }))).not.toThrow(); - }); - - it('calls the onSwitch function if an unselected button is clicked', () => { - const switchHandler = vi.fn(); - render(); - fireEvent.click(screen.getByRole('button', { name: 'tab2' })); - expect(switchHandler).toHaveBeenCalledWith(1); - }); - - it('does not call the onSwitch function if the already selected button is clicked', () => { - const switchHandler = vi.fn(); - render(); - fireEvent.click(screen.getByRole('button', { name: 'tab2' })); - expect(switchHandler).not.toHaveBeenCalled(); - }); - - it('gracefully handles an out of bound selectedTab value', () => { - const errorSpy = vi.spyOn(logger, 'error').mockImplementation(() => {}); - const { asFragment } = render(); - expect(errorSpy).toHaveBeenCalledWith('Out of bound index passed for selected tab'); - expect(asFragment()).toMatchSnapshot(); - }); - - it('centers the indicator under each selected label when switching tabs', () => { - vi.useFakeTimers(); - const tabs = ['Graph', 'Detail', 'Pipeline Spec']; - const { container, rerender } = render(); - const root = container.firstElementChild as HTMLElement; - const indicator = root.firstElementChild as HTMLElement; - vi.spyOn(root, 'getBoundingClientRect').mockReturnValue({ left: 220 } as DOMRect); - const labelBounds = [ - { left: 255, width: 40.96875 }, - { left: 338.96875, width: 38.609375 }, - { left: 419.578125, width: 90.71875 }, - ]; - tabs.forEach((tab, index) => { - vi.spyOn(screen.getByText(tab), 'getBoundingClientRect').mockReturnValue( - labelBounds[index] as DOMRect, - ); - }); - - tabs.forEach((_, selectedTab) => { - rerender(); - vi.runOnlyPendingTimers(); - const { left, width } = labelBounds[selectedTab]; - const indicatorLeft = parseFloat(indicator.style.left); - const indicatorWidth = parseFloat(indicator.style.width); - expect(indicatorWidth).toBe(width + 5); - expect(indicatorLeft + indicatorWidth / 2).toBe(left - 220 + width / 2); - }); - }); - - it('recalculates indicator styles when props are updated', () => { - vi.useFakeTimers(); - const updateSpy = vi.spyOn(MD2Tabs.prototype as any, '_updateIndicator'); - const { rerender } = render(); - vi.runOnlyPendingTimers(); - rerender(); - vi.runOnlyPendingTimers(); - expect(updateSpy.mock.calls.length).toBeGreaterThanOrEqual(2); - }); -}); diff --git a/frontend/src/atoms/MD2Tabs.tsx b/frontend/src/atoms/MD2Tabs.tsx deleted file mode 100644 index 7236dce1274..00000000000 --- a/frontend/src/atoms/MD2Tabs.tsx +++ /dev/null @@ -1,148 +0,0 @@ -/* - * Copyright 2018 The Kubeflow Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import * as React from 'react'; -import Separator from './Separator'; -import { color, fontsize } from '../Css'; -import { classes, stylesheet } from 'typestyle'; -import { logger } from '../lib/Utils'; - -import { Button, Tooltip } from '@mui/material'; - -interface MD2TabsProps { - onSwitch?: (tab: number) => void; - selectedTab: number; - tabs: (string | { header: string; tooltip: string })[]; -} - -const css = stylesheet({ - active: { - color: `${color.theme} !important`, - fontWeight: 'bold', - }, - button: { - $nest: { - '&:hover': { - backgroundColor: 'initial', - }, - }, - borderRadius: '4px 4px 0 0', - color: color.inactive, - fontSize: fontsize.base, - height: 33, - minHeight: 0, - padding: '0 15px', - textTransform: 'none', - }, - indicator: { - backgroundColor: 'initial', - borderBottom: '3px solid ' + color.theme, - borderLeft: '3px solid transparent', - borderRight: '3px solid transparent', - bottom: 0, - left: 35, - position: 'absolute', - transition: 'width 0.3s, left 0.3s', - width: 50, - }, - tabs: { - borderBottom: '1px solid ' + color.separator, - height: 33, - position: 'relative', - whiteSpace: 'nowrap', - }, -}); - -class MD2Tabs extends React.Component { - private _rootRef = React.createRef(); - private _indicatorRef = React.createRef(); - private _tabRefs = this.props.tabs.map((_tab) => React.createRef()); - private _timeoutHandle: ReturnType | undefined; - - public render(): React.JSX.Element { - const selected = this._getSelectedIndex(); - const switchHandler = this.props.onSwitch || (() => null); - const tabs = this.props.tabs.map((tab) => { - if (typeof tab === 'string') { - return { header: tab, tooltip: '' }; - } - return tab; - }); - return ( -
-
- - {tabs.map((tab, i) => ( - - - - ))} -
- ); - } - - public componentDidMount(): void { - this._timeoutHandle = setTimeout(this._updateIndicator.bind(this)); - } - - public componentDidUpdate(): void { - this._timeoutHandle = setTimeout(this._updateIndicator.bind(this)); - } - - public componentWillUnmount(): void { - if (this._timeoutHandle) { - clearTimeout(this._timeoutHandle); - } - } - - private _getSelectedIndex(): number { - let selected = this.props.selectedTab; - if (this.props.tabs[selected] === undefined) { - logger.error('Out of bound index passed for selected tab'); - selected = 0; - } - return selected; - } - - private _updateIndicator(): void { - const selected = this._getSelectedIndex(); - const activeLabelElement = this._tabRefs[selected].current as HTMLSpanElement; - if (!activeLabelElement) { - return; - } - const leftOffset = - activeLabelElement.getBoundingClientRect().left - - this._rootRef.current.getBoundingClientRect().left; - - const tabIndicator = this._indicatorRef.current; - // Center the indicator's extra 5px of width around the label. - tabIndicator.style.left = leftOffset - 2.5 + 'px'; - tabIndicator.style.width = activeLabelElement.getBoundingClientRect().width + 5 + 'px'; - tabIndicator.style.display = 'block'; - } -} - -export default MD2Tabs; diff --git a/frontend/src/atoms/Separator.test.tsx b/frontend/src/atoms/Separator.test.tsx deleted file mode 100644 index 7ca013bfc26..00000000000 --- a/frontend/src/atoms/Separator.test.tsx +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright 2018 The Kubeflow Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { render } from '@testing-library/react'; -import Separator from './Separator'; - -describe('Separator', () => { - it('renders with the right styles by default', () => { - const { asFragment } = render(); - expect(asFragment()).toMatchSnapshot(); - }); - - it('renders with the specified orientation', () => { - const { asFragment } = render(); - expect(asFragment()).toMatchSnapshot(); - }); - - it('renders with the specified units', () => { - const { asFragment } = render(); - expect(asFragment()).toMatchSnapshot(); - }); -}); diff --git a/frontend/src/atoms/Separator.tsx b/frontend/src/atoms/Separator.tsx deleted file mode 100644 index 9e40725a278..00000000000 --- a/frontend/src/atoms/Separator.tsx +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright 2018 The Kubeflow Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -type Orientation = 'horizontal' | 'vertical'; -interface SeparatorProps { - orientation?: Orientation; - units?: number; -} - -const style = (orientation: Orientation, units: number) => { - return orientation === 'horizontal' - ? { - display: 'inline-block', - minWidth: units, - width: units, - } - : { - display: 'block', - flexShrink: 0, - height: units, - minHeight: units, - }; -}; - -const Separator = (props: SeparatorProps) => ( - -); - -export default Separator; diff --git a/frontend/src/atoms/SharedAtoms.css b/frontend/src/atoms/SharedAtoms.css new file mode 100644 index 00000000000..e1051b485b6 --- /dev/null +++ b/frontend/src/atoms/SharedAtoms.css @@ -0,0 +1,56 @@ +.kfp-external-link { + color: var(--primary); + text-decoration: none; + overflow-wrap: anywhere; +} +.kfp-external-link:hover { + text-decoration: underline; +} +.kfp-external-link:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 3px; + border-radius: 2px; +} +.kfp-busy-button { + position: relative; +} +.kfp-atom-spinner { + animation: kfp-atom-spin 1s linear infinite; +} +.kfp-help-positioner { + z-index: 1500; +} +.kfp-help-popup { + max-width: min(360px, calc(100vw - 32px)); + padding: 16px; + border: 1px solid var(--border-strong); + border-radius: 12px; + color: var(--foreground); + background: var(--card); + box-shadow: 0 8px 24px rgb(0 0 0 / 15%); + font-size: 13px; + line-height: 1.6; + overflow-wrap: anywhere; +} +.kfp-help-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 8px; +} +.kfp-help-heading h2 { + margin: 0; + font-size: 14px; + font-weight: 600; +} +@keyframes kfp-atom-spin { + to { + transform: rotate(360deg); + } +} +@media (prefers-reduced-motion: reduce) { + .kfp-atom-spinner { + animation: none; + } +} diff --git a/frontend/src/atoms/__snapshots__/BusyButton.test.tsx.snap b/frontend/src/atoms/__snapshots__/BusyButton.test.tsx.snap index 2cc18a34abe..c735881fd41 100644 --- a/frontend/src/atoms/__snapshots__/BusyButton.test.tsx.snap +++ b/frontend/src/atoms/__snapshots__/BusyButton.test.tsx.snap @@ -3,16 +3,14 @@ exports[`BusyButton > renders a primary outlined buton 1`] = ` `; @@ -20,20 +18,36 @@ exports[`BusyButton > renders a primary outlined buton 1`] = ` exports[`BusyButton > renders disabled 1`] = ` `; @@ -74,44 +100,59 @@ exports[`BusyButton > renders with a title and icon 1`] = ` exports[`BusyButton > renders with a title and icon, and busy 1`] = ` `; @@ -119,16 +160,14 @@ exports[`BusyButton > renders with a title and icon, and busy 1`] = ` exports[`BusyButton > renders with just a title 1`] = ` `; diff --git a/frontend/src/atoms/__snapshots__/Hr.test.tsx.snap b/frontend/src/atoms/__snapshots__/Hr.test.tsx.snap deleted file mode 100644 index 88983ed7b92..00000000000 --- a/frontend/src/atoms/__snapshots__/Hr.test.tsx.snap +++ /dev/null @@ -1,9 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`Hr > renders with the right styles 1`] = ` - -
-
-`; diff --git a/frontend/src/atoms/__snapshots__/IconWithTooltip.test.tsx.snap b/frontend/src/atoms/__snapshots__/IconWithTooltip.test.tsx.snap deleted file mode 100644 index 10a6430636f..00000000000 --- a/frontend/src/atoms/__snapshots__/IconWithTooltip.test.tsx.snap +++ /dev/null @@ -1,39 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`IconWithTooltip > renders with height and weight 1`] = ` - - - -`; - -exports[`IconWithTooltip > renders without height or weight 1`] = ` - - - -`; diff --git a/frontend/src/atoms/__snapshots__/Input.test.tsx.snap b/frontend/src/atoms/__snapshots__/Input.test.tsx.snap deleted file mode 100644 index 02c9b94b310..00000000000 --- a/frontend/src/atoms/__snapshots__/Input.test.tsx.snap +++ /dev/null @@ -1,73 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`Input > accepts height and width as prop overrides 1`] = ` - -
-
- - -
-
-
-`; - -exports[`Input > renders with the right styles by default 1`] = ` - -
-
- - -
-
-
-`; diff --git a/frontend/src/atoms/__snapshots__/MD2Tabs.test.tsx.snap b/frontend/src/atoms/__snapshots__/MD2Tabs.test.tsx.snap deleted file mode 100644 index c373a91a248..00000000000 --- a/frontend/src/atoms/__snapshots__/MD2Tabs.test.tsx.snap +++ /dev/null @@ -1,87 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`MD2Tabs > gracefully handles an out of bound selectedTab value 1`] = ` - -
-
- - - -
- -`; - -exports[`MD2Tabs > renders with the right styles by default 1`] = ` - -
-
- - - -
- -`; diff --git a/frontend/src/atoms/__snapshots__/Separator.test.tsx.snap b/frontend/src/atoms/__snapshots__/Separator.test.tsx.snap deleted file mode 100644 index 2d379e07292..00000000000 --- a/frontend/src/atoms/__snapshots__/Separator.test.tsx.snap +++ /dev/null @@ -1,25 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`Separator > renders with the right styles by default 1`] = ` - - - -`; - -exports[`Separator > renders with the specified orientation 1`] = ` - - - -`; - -exports[`Separator > renders with the specified units 1`] = ` - - - -`; diff --git a/frontend/src/components/ArtifactPreview.css b/frontend/src/components/ArtifactPreview.css new file mode 100644 index 00000000000..8e66beff9bf --- /dev/null +++ b/frontend/src/components/ArtifactPreview.css @@ -0,0 +1,62 @@ +.kfp-theme .kfp-artifact-preview-root { + min-width: 0; + width: 100%; +} +.kfp-theme .kfp-artifact-preview-topDiv { + display: flex; + min-width: 0; +} +.kfp-theme .kfp-artifact-preview-downloadLink { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--primary); + font-family: var(--kfp-font-mono); + font-size: 12px; +} +.kfp-theme .kfp-artifact-preview-fullUri { + margin: 8px 0; + font-size: 12px; +} +.kfp-theme .kfp-artifact-preview-fullUri summary { + cursor: pointer; + color: var(--primary); +} +.kfp-theme .kfp-artifact-preview-fullUri button { + margin: 8px 8px 0 0; +} +.kfp-theme .kfp-artifact-preview-uriText { + display: block; + margin-top: 8px; + padding: 8px; + background: var(--muted); + color: var(--foreground); + white-space: pre-wrap; + overflow-wrap: anywhere; + user-select: text; + font-family: var(--kfp-font-mono); +} +.kfp-theme .kfp-artifact-preview-preview { + max-height: 250px; + overflow: auto; + margin-top: 12px; + padding: 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--muted); + color: var(--foreground); +} +.kfp-theme .kfp-artifact-preview-preview pre { + margin: 0; + font-family: var(--kfp-font-mono); + font-size: 12px; +} +.kfp-theme .kfp-artifact-preview-loading { + display: flex; + gap: 8px; + align-items: center; + margin-top: 12px; + color: var(--muted-foreground); +} diff --git a/frontend/src/components/ArtifactPreview.tsx b/frontend/src/components/ArtifactPreview.tsx index b09e002fd2f..25a977fc708 100644 --- a/frontend/src/components/ArtifactPreview.tsx +++ b/frontend/src/components/ArtifactPreview.tsx @@ -15,17 +15,17 @@ */ import React from 'react'; -import { Button, CircularProgress } from '@mui/material'; +import { LoaderCircle } from 'lucide-react'; +import { Button } from './ui/button'; import { useQuery } from '@tanstack/react-query'; import { ExternalLink } from 'src/atoms/ExternalLink'; -import { color } from 'src/Css'; import { queryKeys } from 'src/hooks/queryKeys'; import { Apis } from 'src/lib/Apis'; import { StoragePath } from 'src/lib/StoragePath'; import { parseArtifactFileLocation } from 'src/lib/v2/ArtifactFileUtils'; -import { stylesheet } from 'typestyle'; -import Banner from './Banner'; -import { ValueComponentProps } from './DetailsTable'; +import { InspectionNotice } from './inspection/InspectionNotice'; +import './ArtifactPreview.css'; +import type { ValueComponentProps } from 'src/lib/DetailsTableTypes'; import { logger } from 'src/lib/Utils'; export type ArtifactPreviewValue = @@ -34,45 +34,6 @@ export type ArtifactPreviewValue = uri: string; }; -const css = stylesheet({ - root: { - minWidth: 0, - width: '100%', - }, - preview: { - maxHeight: 250, - overflowY: 'auto', - padding: 3, - backgroundColor: color.lightGrey, - }, - topDiv: { - display: 'flex', - minWidth: 0, - }, - downloadLink: { - display: 'block', - minWidth: 0, - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - }, - fullUri: { - fontSize: '0.85em', - $nest: { - '& summary': { cursor: 'pointer', color: color.theme }, - }, - }, - uriText: { - display: 'block', - marginTop: 8, - padding: 8, - backgroundColor: color.lightGrey, - whiteSpace: 'pre-wrap', - overflowWrap: 'anywhere', - userSelect: 'text', - }, -}); - function FullArtifactUri({ uri }: { uri: string }) { const [copyStatus, setCopyStatus] = React.useState(''); const copyUri = async () => { @@ -85,10 +46,10 @@ function FullArtifactUri({ uri }: { uri: string }) { }; return ( -
+
Full URI - {uri} - {copyStatus} @@ -142,7 +103,10 @@ const ArtifactPreview: React.FC = ({ if (!storage) { return ( - + ); } @@ -155,29 +119,44 @@ const ArtifactPreview: React.FC = ({ }); return ( -
-
- +
+
+ {linkText}
{uri && } {!previewRequested && ( - )} {previewRequested && isFetching && ( - + + )} {previewRequested && isError && !isFetching && ( <> - - @@ -186,7 +165,7 @@ const ArtifactPreview: React.FC = ({ isSuccess && !isFetching && (data ? ( -
+
{data}
diff --git a/frontend/src/components/Banner.test.tsx b/frontend/src/components/Banner.test.tsx deleted file mode 100644 index b4a33c8d43d..00000000000 --- a/frontend/src/components/Banner.test.tsx +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright 2018 The Kubeflow Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { vi } from 'vitest'; -import Banner from './Banner'; - -describe('Banner', () => { - it('defaults to error mode', () => { - const { asFragment } = render(); - expect(asFragment()).toMatchSnapshot(); - }); - - it('uses error mode when instructed', () => { - const { asFragment } = render(); - expect(asFragment()).toMatchSnapshot(); - }); - - it('uses warning mode when instructed', () => { - const { asFragment } = render(); - expect(asFragment()).toMatchSnapshot(); - }); - - it('uses info mode when instructed', () => { - const { asFragment } = render(); - expect(asFragment()).toMatchSnapshot(); - }); - - it('shows "Details" button and has dialog when there is additional info', () => { - const { asFragment } = render(); - expect(asFragment()).toMatchSnapshot(); - }); - - it('shows "Refresh" button when passed a refresh function', () => { - const { asFragment } = render( - { - /* do nothing */ - }} - />, - ); - expect(asFragment()).toMatchSnapshot(); - }); - - it('does not show "Refresh" button if mode is "info"', () => { - render( undefined} />); - expect(screen.queryByRole('button', { name: 'Refresh' })).toBeNull(); - }); - - it('shows troubleshooting link instructed by prop', () => { - render(); - expect(screen.getByRole('link', { name: 'Troubleshooting guide' })).toHaveAttribute( - 'href', - 'https://www.kubeflow.org/docs/pipelines/troubleshooting', - ); - }); - - it('does not show troubleshooting link if warning', () => { - render(); - expect(screen.queryByRole('link', { name: 'Troubleshooting guide' })).toBeNull(); - }); - - it('opens details dialog when button is clicked', () => { - render(); - fireEvent.click(screen.getByRole('button', { name: 'Details' })); - expect(screen.getByRole('dialog')).toBeInTheDocument(); - }); - - it('closes details dialog when cancel button is clicked', async () => { - render(); - fireEvent.click(screen.getByRole('button', { name: 'Details' })); - const dismissButton = screen.getByRole('button', { name: 'Dismiss' }); - fireEvent.click(dismissButton); - await waitFor( - () => { - expect(screen.queryByRole('dialog')).toBeNull(); - }, - { timeout: 10000 }, - ); - }, 15000); - - it('calls refresh callback', () => { - const spy = vi.fn(); - render(); - fireEvent.click(screen.getByRole('button', { name: 'Refresh' })); - expect(spy).toHaveBeenCalled(); - }); -}); diff --git a/frontend/src/components/Banner.tsx b/frontend/src/components/Banner.tsx deleted file mode 100644 index ebdc5fcd831..00000000000 --- a/frontend/src/components/Banner.tsx +++ /dev/null @@ -1,199 +0,0 @@ -/* - * Copyright 2018 The Kubeflow Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import * as React from 'react'; -import ErrorIcon from '@mui/icons-material/Error'; -import WarningIcon from '@mui/icons-material/Warning'; -import InfoIcon from '@mui/icons-material/Info'; -import { classes, stylesheet } from 'typestyle'; - -import { color, commonCss, spacing } from '../Css'; - -import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material'; - -export type Mode = 'error' | 'warning' | 'info'; - -export const css = stylesheet({ - banner: { - border: `1px solid ${color.divider}`, - boxSizing: 'border-box', - justifyContent: 'space-between', - margin: spacing.units(-1), - minHeight: '50px', - padding: spacing.units(-4), - }, - bannerLeftAlign: { - marginLeft: 0, - }, - button: { - color: color.secondaryText, - maxHeight: '32px', - minWidth: '75px', - }, - detailsButton: { - backgroundColor: color.background, - }, - icon: { - height: '18px', - padding: spacing.units(-4), - width: '18px', - }, - message: { - alignItems: 'center', - display: 'flex', - }, - refreshButton: { - backgroundColor: color.background, - }, - troubleShootingLink: { - alignItems: 'center', - color: color.theme, - fontWeight: 'bold', - padding: spacing.units(-4), - }, -}); - -export interface BannerProps { - additionalInfo?: string; - message?: string; - mode?: Mode; - showTroubleshootingGuideLink?: boolean; - refresh?: () => void; - isLeftAlign?: boolean; -} - -interface BannerState { - dialogOpen: boolean; -} - -class Banner extends React.Component { - constructor(props: any) { - super(props); - - this.state = { - dialogOpen: false, - }; - } - - public render(): React.JSX.Element { - // Default to error styles. - let bannerModeCss = stylesheet({ - mode: { backgroundColor: color.errorBg, color: color.errorText }, - }); - let bannerIcon = ; - let dialogTitle = 'An error occurred'; - let showTroubleshootingGuideLink = false; - let showRefreshButton = true; - let isLeftAlign = this.props.isLeftAlign || false; - - switch (this.props.mode) { - case 'error': - bannerModeCss = stylesheet({ - mode: { backgroundColor: color.errorBg, color: color.errorText }, - }); - bannerIcon = ; - dialogTitle = 'An error occurred'; - showTroubleshootingGuideLink = this.props.showTroubleshootingGuideLink || false; - break; - case 'warning': - bannerModeCss = stylesheet({ - mode: { backgroundColor: color.warningBg, color: color.warningText }, - }); - bannerIcon = ; - dialogTitle = 'Warning'; - break; - case 'info': - bannerModeCss = stylesheet({ - mode: { backgroundColor: color.infoBg, color: color.infoText }, - }); - bannerIcon = ; - dialogTitle = 'Info'; - showRefreshButton = false; - break; - default: - // Already set defaults above. - break; - } - - return ( -
-
- {bannerIcon} - {this.props.message} -
-
- {showTroubleshootingGuideLink && ( - - Troubleshooting guide - - )} - {this.props.additionalInfo && ( - - )} - {showRefreshButton && this.props.refresh && ( - - )} -
- - {this.props.additionalInfo && ( - - {dialogTitle} - {this.props.additionalInfo} - - - - - )} -
- ); - } - - private _dialogClosed(): void { - this.setState({ dialogOpen: false }); - } - - private _showAdditionalInfo(): void { - this.setState({ dialogOpen: true }); - } - - private _refresh(): void { - this.props.refresh!(); - } -} - -export default Banner; diff --git a/frontend/src/components/CollapseButton.test.tsx b/frontend/src/components/CollapseButton.test.tsx deleted file mode 100644 index 037615853f8..00000000000 --- a/frontend/src/components/CollapseButton.test.tsx +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright 2018 The Kubeflow Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { fireEvent, render, screen } from '@testing-library/react'; -import { vi } from 'vitest'; -import CollapseButton from './CollapseButton'; - -describe('CollapseButton', () => { - const compareComponent = { - collapseSectionsUpdate: vi.fn(), - state: { - collapseSections: {}, - }, - } as any; - - afterEach(() => { - compareComponent.state.collapseSections = {}; - compareComponent.collapseSectionsUpdate.mockClear(); - }); - - it('initial render', () => { - const { asFragment } = render( - , - ); - expect(asFragment()).toMatchSnapshot(); - }); - - it('renders the button collapsed if in collapsedSections', () => { - compareComponent.state.collapseSections.testSection = true; - const { asFragment } = render( - , - ); - expect(asFragment()).toMatchSnapshot(); - }); - - it('collapses given section when clicked', () => { - render( - , - ); - fireEvent.click(screen.getByRole('button', { name: 'testSection' })); - expect(compareComponent.collapseSectionsUpdate).toHaveBeenCalledWith({ testSection: true }); - }); - - it('expands given section when clicked if it is collapsed', () => { - compareComponent.state.collapseSections.testSection = true; - render( - , - ); - fireEvent.click(screen.getByRole('button', { name: 'testSection' })); - expect(compareComponent.collapseSectionsUpdate).toHaveBeenCalledWith({ testSection: false }); - }); -}); diff --git a/frontend/src/components/CollapseButton.tsx b/frontend/src/components/CollapseButton.tsx deleted file mode 100644 index 4005535851a..00000000000 --- a/frontend/src/components/CollapseButton.tsx +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright 2018 The Kubeflow Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import * as React from 'react'; -import ExpandedIcon from '@mui/icons-material/ArrowDropUp'; -import { stylesheet, classes } from 'typestyle'; -import { color, fontsize } from '../Css'; -import { Button } from '@mui/material'; - -const css = stylesheet({ - collapseBtn: { - color: color.strong, - fontSize: fontsize.title, - fontWeight: 'bold', - padding: 5, - }, - collapsed: { - transform: 'rotate(180deg)', - }, - icon: { - marginRight: 5, - transition: 'transform 0.3s', - }, -}); - -interface CollapseButtonProps { - collapseSections: { [key: string]: boolean }; - collapseSectionsUpdate: (collapseSections: { [key: string]: boolean }) => void; - sectionName: string; -} - -class CollapseButton extends React.Component { - public render(): React.JSX.Element { - const { collapseSections, collapseSectionsUpdate } = this.props; - const sectionName = this.props.sectionName; - return ( -
- -
- ); - } -} - -export default CollapseButton; diff --git a/frontend/src/components/CollapseButtonSingle.test.tsx b/frontend/src/components/CollapseButtonSingle.test.tsx deleted file mode 100644 index 929148ee4fc..00000000000 --- a/frontend/src/components/CollapseButtonSingle.test.tsx +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright 2022 The Kubeflow Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { render, screen, fireEvent } from '@testing-library/react'; -import CollapseButtonSingle from './CollapseButtonSingle'; - -describe('CollapseButtonSingle', () => { - const collapseSectionUpdateSpy = vi.fn(); - - it('Collapes an expanded section when clicked', () => { - render( - , - ); - - fireEvent.click(screen.getByText('testSection')); - expect(collapseSectionUpdateSpy).toHaveBeenCalledWith(true); - }); - - it('Expands a collapsed section when clicked', () => { - render( - , - ); - - fireEvent.click(screen.getByText('testSection')); - expect(collapseSectionUpdateSpy).toHaveBeenCalledWith(false); - }); -}); diff --git a/frontend/src/components/CollapseButtonSingle.tsx b/frontend/src/components/CollapseButtonSingle.tsx deleted file mode 100644 index 84a8e2dba7d..00000000000 --- a/frontend/src/components/CollapseButtonSingle.tsx +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright 2022 The Kubeflow Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import ExpandedIcon from '@mui/icons-material/ArrowDropUp'; -import { stylesheet, classes } from 'typestyle'; -import { color, fontsize } from '../Css'; -import { Button } from '@mui/material'; - -const css = stylesheet({ - collapseBtn: { - color: color.strong, - fontSize: fontsize.title, - fontWeight: 'bold', - padding: 5, - }, - collapsed: { - transform: 'rotate(180deg)', - }, - icon: { - marginRight: 5, - transition: 'transform 0.3s', - }, -}); - -interface CollapseButtonSingleProps { - collapseSection: boolean; - collapseSectionUpdate: (collapseSection: boolean) => void; - sectionName: string; -} - -function CollapseButtonSingle(props: CollapseButtonSingleProps) { - const { collapseSection, collapseSectionUpdate, sectionName } = props; - - return ( -
- -
- ); -} - -export default CollapseButtonSingle; diff --git a/frontend/src/components/CompareTable.css b/frontend/src/components/CompareTable.css new file mode 100644 index 00000000000..82b4ac70b9d --- /dev/null +++ b/frontend/src/components/CompareTable.css @@ -0,0 +1,48 @@ +.kfp-compare-table-scroll { + max-width: 100%; + overflow-x: auto; +} +.kfp-compare-table { + width: 100%; + border-collapse: collapse; + text-align: left; + color: var(--foreground, #15171e); + background: var(--card, #fff); +} +.kfp-compare-table th, +.kfp-compare-table td { + min-width: 180px; + padding: 12px 16px; + border-bottom: 1px solid var(--border, #e3e5ec); + border-right: 1px solid var(--border, #e3e5ec); + vertical-align: top; + overflow-wrap: anywhere; +} +.kfp-compare-table th { + font-weight: 500; +} +.kfp-compare-table thead th { + background: var(--background, #f5f6f9); + font-size: 13px; +} +.kfp-compare-table tbody th { + width: 180px; + font-family: var(--kfp-font-mono, monospace); + font-size: 12px; +} +.kfp-compare-table td { + font-size: 13px; +} +.kfp-compare-table tr[data-different] { + background: var(--status-warning-soft, #fff4dc); +} +.kfp-compare-table tr[data-different] td { + font-weight: 600; +} +.kfp-compare-table a { + color: var(--primary, #2563d9); + text-decoration: none; +} +.kfp-compare-table a:hover { + text-decoration: underline; +} diff --git a/frontend/src/components/CompareTable.test.tsx b/frontend/src/components/CompareTable.test.tsx index e0233488a19..f54d144add4 100644 --- a/frontend/src/components/CompareTable.test.tsx +++ b/frontend/src/components/CompareTable.test.tsx @@ -39,8 +39,8 @@ describe('CompareTable', () => { }); it('renders no data', () => { - const { asFragment } = render(); - expect(asFragment()).toMatchSnapshot(); + render(); + expect(screen.queryByRole('table')).toBeNull(); }); it('logs error if ylabels and rows have different lengths', () => { @@ -51,8 +51,10 @@ describe('CompareTable', () => { }); it('renders one row with three columns', () => { - const { asFragment } = render(); - expect(asFragment()).toMatchSnapshot(); + render(); + expect(screen.getByRole('table', { name: 'Run comparison' })).toBeInTheDocument(); + expect(screen.getAllByRole('columnheader')).toHaveLength(4); + expect(screen.getAllByRole('rowheader')).toHaveLength(3); }); }); @@ -99,3 +101,38 @@ describe('CompareTable xParentLabels', () => { screen.getByText(xParentLabels[1].label); }); }); + +it('distinguishes missing values, zero, and empty strings while announcing differences', () => { + render( + , + ); + expect(screen.getByRole('cell', { name: '0' })).toBeInTheDocument(); + expect(screen.getByLabelText('Not provided')).toHaveTextContent('—'); + expect(screen.getByLabelText('Empty string')).toHaveTextContent('“”'); + expect( + screen.getByRole('rowheader', { name: 'value (values differ)' }).closest('tr'), + ).toHaveAttribute('data-different', 'true'); + expect(screen.getByRole('rowheader', { name: 'same' }).closest('tr')).not.toHaveAttribute( + 'data-different', + ); +}); + +it('renders an explicit empty string without requiring a missing-cell override', () => { + render( + , + ); + expect(screen.getByLabelText('Empty string')).toBeVisible(); + expect(screen.getByLabelText('Not provided')).toBeVisible(); + expect(screen.getByRole('cell', { name: '0' })).toBeVisible(); +}); diff --git a/frontend/src/components/CompareTable.tsx b/frontend/src/components/CompareTable.tsx index 5e8fb9117bf..96824363ef0 100644 --- a/frontend/src/components/CompareTable.tsx +++ b/frontend/src/components/CompareTable.tsx @@ -15,39 +15,9 @@ */ import * as React from 'react'; +import { Link } from 'react-router'; import { logger } from '../lib/Utils'; -import { stylesheet, classes } from 'typestyle'; -import { color } from '../Css'; - -const borderStyle = `1px solid ${color.divider}`; - -const css = stylesheet({ - cell: { - border: borderStyle, - borderCollapse: 'collapse', - padding: 5, - }, - labelCell: { - backgroundColor: color.lightGrey, - fontWeight: 'bold', - maxWidth: 200, - minWidth: 50, - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - }, - root: { - border: borderStyle, - borderCollapse: 'collapse', - }, - row: { - $nest: { - '&:hover': { - backgroundColor: '#f7f7f7', - }, - }, - }, -}); +import './CompareTable.css'; export interface xParentLabel { label: string; @@ -59,75 +29,90 @@ export interface CompareTableProps { xLabels: string[]; yLabels: string[]; xParentLabels?: xParentLabel[]; + label?: string; + xLinks?: string[]; + missingCells?: boolean[][]; } class CompareTable extends React.PureComponent { public render(): React.JSX.Element | null { - const { rows, xLabels, yLabels, xParentLabels } = this.props; + const { + rows, + xLabels, + yLabels, + xParentLabels, + xLinks, + missingCells, + label = 'Run comparison', + } = this.props; if (rows.length !== yLabels.length) { logger.error( `Number of rows (${rows.length}) should match the number of Y labels (${yLabels.length}).`, ); } - - const xParentLabelsLength = - xParentLabels && - xParentLabels.reduce((length, xParentLabel) => length + xParentLabel.colSpan, 0); - if (xParentLabels && xParentLabelsLength !== xLabels.length) { + const parentLength = xParentLabels?.reduce((length, parent) => length + parent.colSpan, 0); + if (xParentLabels && parentLength !== xLabels.length) { logger.error( - `Number of columns with data (${xLabels.length}) should match the aggregated length of parent columns (${xParentLabelsLength}).`, + `Number of columns with data (${xLabels.length}) should match the aggregated length of parent columns (${parentLength}).`, ); } - - if (!rows || rows.length === 0) { - return null; - } - + if (!rows.length) return null; return ( - - - {xParentLabels && xParentLabelsLength === xLabels.length && ( - - - ))} - - )} - - - ))} - - {rows.map((row, i) => ( - - {/* Y label */} - - - {/* Row cells */} - {row.map((cell, j) => ( - +
+
- {/* X parent labels row */} - {xParentLabels.map((parentLabel, i) => ( - - {parentLabel.label} -
- {/* X labels row */} - {xLabels.map((label, i) => ( - - {label} -
- {yLabels[i]} - - {cell} -
+ + {xParentLabels && parentLength === xLabels.length && ( + + + {xParentLabels.map((parent, index) => ( + + ))} + + )} + + + {xLabels.map((name, index) => ( + ))} - ))} - -
Run + {parent.label} +
Name + {xLinks?.[index] ? {name} : name} +
+ + + {rows.map((row, rowIndex) => { + const absent = (column: number) => + missingCells?.[rowIndex]?.[column] ?? row[column] === undefined; + const signatures = xLabels.map((_, column) => + JSON.stringify([absent(column), row[column]]), + ); + const differs = new Set(signatures).size > 1; + return ( + + + {yLabels[rowIndex]} + + {xLabels.map((_, column) => ( + + {absent(column) ? ( + — + ) : row[column] === '' ? ( + “” + ) : ( + row[column] + )} + + ))} + + ); + })} + + +
); } } diff --git a/frontend/src/components/CustomTable.modern.test.tsx b/frontend/src/components/CustomTable.modern.test.tsx new file mode 100644 index 00000000000..a62316b29c4 --- /dev/null +++ b/frontend/src/components/CustomTable.modern.test.tsx @@ -0,0 +1,121 @@ +/* + * Copyright 2026 The Kubeflow Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { useState } from 'react'; +import CustomTable from './CustomTable'; +import { RunsTable } from './tables/RunsTable'; +import type { ListRequest } from 'src/lib/Apis'; + +function Harness({ reload }: { reload: (request: ListRequest) => Promise }) { + const [selectedIds, setSelectedIds] = useState([]); + return ( + } + /> + ); +} +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + localStorage.clear(); +}); + +it('resets the request page token when filtering from a later page', async () => { + const reload = vi.fn(async () => 'next-page'); + render(); + await waitFor(() => expect(screen.getByRole('table')).toHaveAttribute('aria-busy', 'false')); + fireEvent.click(screen.getByRole('button', { name: 'Next page' })); + await waitFor(() => + expect(reload).toHaveBeenLastCalledWith(expect.objectContaining({ pageToken: 'next-page' })), + ); + await waitFor(() => expect(screen.getByRole('table')).toHaveAttribute('aria-busy', 'false')); + fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'Training' } }); + await waitFor(() => + expect(reload).toHaveBeenLastCalledWith( + expect.objectContaining({ pageToken: '', filter: expect.stringContaining('Training') }), + ), + ); + expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled(); +}); + +it('keeps filter, selection and page size through an explicit controller reload', async () => { + const reload = vi.fn(async () => ''); + render(); + await waitFor(() => expect(screen.getByRole('table')).toHaveAttribute('aria-busy', 'false')); + fireEvent.click(screen.getByRole('checkbox', { name: 'Select run Training' })); + expect( + screen.getByRole('checkbox', { name: 'Select all runs on this page' }), + ).toBePartiallyChecked(); + fireEvent.change(screen.getByRole('combobox'), { target: { value: '20' } }); + await waitFor(() => + expect(reload).toHaveBeenLastCalledWith( + expect.objectContaining({ pageSize: 20, pageToken: '' }), + ), + ); + expect(screen.getByRole('checkbox', { name: 'Select run Training' })).toBeChecked(); +}); + +it('keeps paging usable when preference storage cannot be read or written', async () => { + vi.spyOn(localStorage, 'getItem').mockImplementation(() => { + throw new Error('denied'); + }); + vi.spyOn(localStorage, 'setItem').mockImplementation(() => { + throw new Error('denied'); + }); + const reload = vi.fn(async () => ''); + render(); + await waitFor(() => expect(screen.getByRole('table')).toHaveAttribute('aria-busy', 'false')); + expect(screen.getByRole('combobox')).toHaveValue('20'); + fireEvent.change(screen.getByRole('combobox'), { target: { value: '50' } }); + await waitFor(() => + expect(reload).toHaveBeenLastCalledWith(expect.objectContaining({ pageSize: 50 })), + ); + expect(screen.getByRole('combobox')).toHaveValue('50'); +}); + +it('does not let an older sort response reset newer pagination', async () => { + let finishOld: (value: string) => void = () => {}; + const reload = vi.fn(async () => ''); + render(); + await waitFor(() => expect(screen.getByRole('table')).toHaveAttribute('aria-busy', 'false')); + reload.mockImplementationOnce( + () => + new Promise((resolve) => { + finishOld = resolve; + }), + ); + fireEvent.click(screen.getByRole('button', { name: 'Run' })); + fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'Training' } }); + await waitFor(() => + expect(reload).toHaveBeenLastCalledWith( + expect.objectContaining({ filter: expect.stringContaining('Training') }), + ), + ); + await act(async () => { + finishOld('stale-next-page'); + }); + expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled(); +}); diff --git a/frontend/src/components/CustomTable.test.tsx b/frontend/src/components/CustomTable.test.tsx index bbe42e524e5..31050c93f54 100644 --- a/frontend/src/components/CustomTable.test.tsx +++ b/frontend/src/components/CustomTable.test.tsx @@ -20,7 +20,7 @@ import { vi } from 'vitest'; import CustomTable, { Column, ExpandState, Row } from './CustomTable'; import TestUtils, { flushPromisesInAct, invokeAndFlush } from '../TestUtils'; import { V2PredicateOperation } from '../apisv2/filter'; -import { logger } from 'src/lib/Utils'; +import { ResourceTable } from './tables/ResourceTable'; type CustomTableProps = React.ComponentProps; @@ -75,6 +75,9 @@ class CustomTableWrapper { const baseProps: CustomTableProps = { columns: [], + renderTable: (table) => ( + row.id} /> + ), reload: async () => '', rows: [], }; @@ -115,25 +118,17 @@ function renderTable(overrides: Partial = {}): CustomTableWrap } function getHeaderCheckbox(): HTMLElement { - const wrapper = screen.getByTestId('select-all-checkbox'); - return within(wrapper).getByRole('checkbox'); + return screen.getByRole('checkbox', { name: 'Select all resources on this page' }); } function getRowsPerPageCombobox(): HTMLElement { - const footer = screen.getByText('Rows per page:').closest('div'); - if (!footer) { - throw new Error('Unable to locate table footer containing the rows-per-page selector'); - } - return within(footer).getByRole('combobox'); + return screen.getByRole('combobox', { name: 'Rows per page' }); } async function selectRowsPerPage(pageSize: number): Promise { + await waitFor(() => expect(getRowsPerPageCombobox()).toBeEnabled()); await act(async () => { - fireEvent.mouseDown(getRowsPerPageCombobox()); - }); - const option = await screen.findByRole('option', { name: String(pageSize) }); - await act(async () => { - fireEvent.click(option); + fireEvent.change(getRowsPerPageCombobox(), { target: { value: String(pageSize) } }); await TestUtils.flushPromises(); }); } @@ -179,55 +174,52 @@ describe('CustomTable', () => { wrapper.unmount(); }); - it('renders some columns with equal widths without rows', async () => { + it('renders named column headers in the supplied order without rows', async () => { const wrapper = renderTable({ columns: [{ label: 'col1' }, { label: 'col2' }] }); await flushPromisesInAct(); - const col1 = screen.getByText('col1').closest('div[style]'); - const col2 = screen.getByText('col2').closest('div[style]'); - expect(col1).toHaveStyle({ width: '50%' }); - expect(col2).toHaveStyle({ width: '50%' }); + expect( + screen + .getAllByRole('columnheader') + .map((header) => header.textContent) + .filter(Boolean), + ).toEqual(['col1', 'col2']); wrapper.unmount(); }); it('renders without the checkboxes if disableSelection is true', async () => { const wrapper = renderTable({ rows, columns, disableSelection: true }); await flushPromisesInAct(); - expect(screen.queryByTestId('select-all-checkbox')).toBeNull(); + expect( + screen.queryByRole('checkbox', { name: 'Select all resources on this page' }), + ).toBeNull(); wrapper.unmount(); }); - it('renders some columns with descending sort order on first column', async () => { + it('renders descending sort order on the initial column', async () => { const wrapper = renderTable({ columns: [{ label: 'col1', sortKey: 'col1sortkey' }, { label: 'col2' }], initialSortOrder: 'desc', }); await flushPromisesInAct(); - const col1Label = screen.getByText('col1'); - expect(col1Label.closest('.MuiTableSortLabel-root')).toHaveClass('Mui-active'); - const sortIcon = document.querySelector('.MuiTableSortLabel-icon'); - expect(sortIcon).not.toBeNull(); - expect(sortIcon).toHaveClass('MuiTableSortLabel-iconDirectionDesc'); + expect(screen.getByRole('columnheader', { name: 'col1' })).toHaveAttribute( + 'aria-sort', + 'descending', + ); + expect(screen.getByRole('columnheader', { name: 'col2' })).not.toHaveAttribute('aria-sort'); wrapper.unmount(); }); - it('renders columns with specified widths', async () => { - const wrapper = renderTable({ - columns: [ - { - flex: 3, - label: 'col1', - }, - { - flex: 1, - label: 'col2', - }, - ], - }); + it('forwards supplied column metadata to the explicit renderer', async () => { + const suppliedColumns = [ + { flex: 3, label: 'col1' }, + { flex: 1, label: 'col2' }, + ]; + const adapter = vi.fn(baseProps.renderTable); + const wrapper = renderTable({ columns: suppliedColumns, renderTable: adapter }); await flushPromisesInAct(); - const col1 = screen.getByText('col1').closest('div[style]'); - const col2 = screen.getByText('col2').closest('div[style]'); - expect(col1).toHaveStyle({ width: '75%' }); - expect(col2).toHaveStyle({ width: '25%' }); + expect(adapter.mock.lastCall?.[0].columns).toBe(suppliedColumns); + expect(screen.getByRole('columnheader', { name: 'col1' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'col2' })).toBeInTheDocument(); wrapper.unmount(); }); @@ -238,12 +230,129 @@ describe('CustomTable', () => { expect(reload).toHaveBeenLastCalledWith({ filter: '', orderAscending: false, - pageSize: 10, + pageSize: 20, pageToken: '', sortBy: '', }); }); + it('restores next-page navigation when a direct refresh recovers a page token', async () => { + let nextToken = ''; + const reload = vi.fn(async () => nextToken); + const wrapper = renderTable({ rows, columns, reload }); + await waitFor(() => expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled()); + nextToken = 'after-retry'; + await act(async () => { + await wrapper.instance().reload(); + }); + expect(screen.getByRole('button', { name: 'Next page' })).toBeEnabled(); + nextToken = ''; + fireEvent.click(screen.getByRole('button', { name: 'Next page' })); + await waitFor(() => + expect(reload).toHaveBeenLastCalledWith( + expect.objectContaining({ pageToken: 'after-retry' }), + ), + ); + }); + + it('refreshes paging tokens without losing the current page or previous-page token', async () => { + let nextToken = ''; + const reload = vi.fn(async (request: Parameters[0]) => + request.pageToken ? nextToken : 'page-one', + ); + const wrapper = renderTable({ rows, columns, reload }); + await waitFor(() => expect(wrapper.state('tokenList')).toEqual(['', 'page-one'])); + fireEvent.click(screen.getByRole('button', { name: 'Next page' })); + await waitFor(() => expect(wrapper.state('currentPage')).toBe(1)); + expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled(); + nextToken = 'new-page-two'; + await act(async () => { + await wrapper.instance().reload(); + }); + expect(wrapper.state('currentPage')).toBe(1); + expect(wrapper.state('tokenList')).toEqual(['', 'page-one', 'new-page-two']); + fireEvent.click(screen.getByRole('button', { name: 'Next page' })); + await waitFor(() => + expect(reload).toHaveBeenLastCalledWith( + expect.objectContaining({ pageToken: 'new-page-two' }), + ), + ); + }); + + it('does not let an older direct refresh overwrite a newer page token', async () => { + const reload = vi.fn(async () => ''); + const wrapper = renderTable({ rows, columns, reload }); + await waitFor(() => expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled()); + let finishOlder!: (token: string) => void; + const olderResult = new Promise((resolve) => { + finishOlder = resolve; + }); + reload.mockImplementationOnce(() => olderResult).mockResolvedValueOnce('latest-token'); + let olderRefresh!: Promise; + act(() => { + olderRefresh = wrapper.instance().reload(); + }); + await act(async () => { + await wrapper.instance().reload(); + }); + await act(async () => { + finishOlder('stale-token'); + await olderRefresh; + }); + expect(wrapper.state('tokenList')).toEqual(['', 'latest-token']); + expect(screen.getByRole('button', { name: 'Next page' })).toBeEnabled(); + }); + + it('does not let an older page request overwrite a newer filter reset', async () => { + const reload = vi.fn(async () => 'page-one'); + const wrapper = renderTable({ rows, columns, reload }); + await waitFor(() => expect(wrapper.state('tokenList')).toEqual(['', 'page-one'])); + let finishPage!: (token: string) => void; + reload.mockImplementationOnce( + () => + new Promise((resolve) => { + finishPage = resolve; + }), + ); + fireEvent.click(screen.getByRole('button', { name: 'Next page' })); + reload.mockResolvedValueOnce('filtered-next'); + await act(async () => { + await wrapper.instance()._requestFilter('new'); + }); + await act(async () => { + finishPage('stale-next'); + }); + expect(wrapper.state('currentPage')).toBe(0); + expect(wrapper.state('tokenList')).toEqual(['', 'filtered-next']); + }); + + it('does not let an older filter reset overwrite a newer direct refresh', async () => { + const reload = vi.fn(async () => 'page-one'); + const wrapper = renderTable({ rows, columns, reload }); + await waitFor(() => expect(wrapper.state('tokenList')).toEqual(['', 'page-one'])); + let finishFilter!: (token: string) => void; + reload.mockImplementationOnce( + () => + new Promise((resolve) => { + finishFilter = resolve; + }), + ); + let filtering!: Promise; + act(() => { + filtering = wrapper.instance()._requestFilter('old'); + }); + reload.mockResolvedValueOnce('latest-next'); + await act(async () => { + await wrapper.instance().reload(); + }); + await act(async () => { + finishFilter('stale-next'); + await filtering; + }); + expect(wrapper.state('currentPage')).toBe(0); + expect(wrapper.state('tokenList')).toEqual(['', 'latest-next']); + }); + it('calls reload function with sort key of clicked column, while keeping same page', async () => { const testColumns = [ { @@ -263,7 +372,7 @@ describe('CustomTable', () => { expect(reload).toHaveBeenLastCalledWith({ filter: '', orderAscending: false, - pageSize: 10, + pageSize: 20, pageToken: '', sortBy: 'col1sortkey desc', }); @@ -273,13 +382,37 @@ describe('CustomTable', () => { expect(reload).toHaveBeenLastCalledWith({ filter: '', orderAscending: true, - pageSize: 10, + pageSize: 20, pageToken: '', sortBy: 'col2sortkey', }), ); }); + it('accepts explicit sort direction without toggling the current field', async () => { + const reload = vi.fn(async () => ''); + renderTable({ + columns: [{ label: 'Name', sortKey: 'name' }], + reload, + renderTable: (table) => , + }); + await waitFor(() => expect(reload).toHaveBeenCalled()); + reload.mockClear(); + fireEvent.click(screen.getByRole('button', { name: 'Newest' })); + await waitFor(() => + expect(reload).toHaveBeenLastCalledWith( + expect.objectContaining({ sortBy: 'name desc', orderAscending: false, pageToken: '' }), + ), + ); + reload.mockClear(); + fireEvent.click(screen.getByRole('button', { name: 'Newest' })); + await waitFor(() => + expect(reload).toHaveBeenLastCalledWith( + expect.objectContaining({ sortBy: 'name desc', orderAscending: false }), + ), + ); + }); + it('calls reload function with same sort key in reverse order if same column is clicked twice', async () => { const testColumns = [ { @@ -299,7 +432,7 @@ describe('CustomTable', () => { expect(reload).toHaveBeenLastCalledWith({ filter: '', orderAscending: false, - pageSize: 10, + pageSize: 20, pageToken: '', sortBy: 'col1sortkey desc', }); @@ -309,7 +442,7 @@ describe('CustomTable', () => { expect(reload).toHaveBeenLastCalledWith({ filter: '', orderAscending: true, - pageSize: 10, + pageSize: 20, pageToken: '', sortBy: 'col2sortkey', }), @@ -320,7 +453,7 @@ describe('CustomTable', () => { expect(reload).toHaveBeenLastCalledWith({ filter: '', orderAscending: false, - pageSize: 10, + pageSize: 20, pageToken: '', sortBy: 'col2sortkey desc', }), @@ -347,39 +480,18 @@ describe('CustomTable', () => { expect(reload).toHaveBeenCalledTimes(previousCallCount); }); - it('does not render sort icon for columns without sort key', async () => { + it('offers sorting only for columns with a sort key', async () => { renderTable({ columns: [{ label: 'sortable', sortKey: 'sortableKey' }, { label: 'unsortable' }], rows, }); await flushPromisesInAct(); - - const sortableHeader = screen.getByText('sortable').closest('.MuiTableSortLabel-root'); - const unsortableHeader = screen.getByText('unsortable').closest('.MuiTableSortLabel-root'); - - expect(sortableHeader?.querySelector('.MuiTableSortLabel-icon')).toBeTruthy(); - expect(unsortableHeader?.querySelector('.MuiTableSortLabel-icon')).toBeNull(); - }); - - it('logs error if row has more cells than columns', () => { - const loggerSpy = vi.spyOn(logger, 'error').mockImplementation(() => undefined); - const wrapper = renderTable({ rows }); - expect(loggerSpy).toHaveBeenCalledWith( - 'Rows must have the same number of cells defined in columns', - ); - wrapper.unmount(); - loggerSpy.mockRestore(); - }); - - it('logs error if row has fewer cells than columns', () => { - const loggerSpy = vi.spyOn(logger, 'error').mockImplementation(() => undefined); - const testColumns = [{ label: 'col1' }, { label: 'col2' }, { label: 'col3' }]; - const wrapper = renderTable({ rows, columns: testColumns }); - expect(loggerSpy).toHaveBeenCalledWith( - 'Rows must have the same number of cells defined in columns', - ); - wrapper.unmount(); - loggerSpy.mockRestore(); + expect( + within(screen.getByRole('columnheader', { name: 'sortable' })).getByRole('button'), + ).toBeInTheDocument(); + expect( + within(screen.getByRole('columnheader', { name: 'unsortable' })).queryByRole('button'), + ).toBeNull(); }); it('renders some rows', async () => { @@ -495,8 +607,8 @@ describe('CustomTable', () => { const wrapper = renderTable({ rows, columns, reload: spy }); await flushPromisesInAct(); expect(wrapper.state()).toHaveProperty('maxPageIndex', 0); - const prevBtn = screen.getByTestId('prev-page-btn'); - const nextBtn = screen.getByTestId('next-page-btn'); + const prevBtn = screen.getByRole('button', { name: 'Previous page' }); + const nextBtn = screen.getByRole('button', { name: 'Next page' }); expect(prevBtn).toBeDisabled(); expect(nextBtn).toBeDisabled(); wrapper.unmount(); @@ -519,8 +631,8 @@ describe('CustomTable', () => { const spy = vi.fn(() => reloadResult); const wrapper = renderTable({ rows, columns, reload: spy }); await flushPromisesInAct(); - const prevBtn = screen.getByTestId('prev-page-btn'); - const nextBtn = screen.getByTestId('next-page-btn'); + const prevBtn = screen.getByRole('button', { name: 'Previous page' }); + const nextBtn = screen.getByRole('button', { name: 'Next page' }); expect(wrapper.state()).toHaveProperty('maxPageIndex', Number.MAX_SAFE_INTEGER); expect(prevBtn).toBeDisabled(); expect(nextBtn).not.toBeDisabled(); @@ -532,13 +644,13 @@ describe('CustomTable', () => { const spy = vi.fn(() => reloadResult); const wrapper = renderTable({ rows, columns, reload: spy }); await flushPromisesInAct(); - const nextBtn = screen.getByTestId('next-page-btn'); + const nextBtn = screen.getByRole('button', { name: 'Next page' }); fireEvent.click(nextBtn); await waitFor(() => expect(spy).toHaveBeenLastCalledWith({ filter: '', orderAscending: false, - pageSize: 10, + pageSize: 20, pageToken: 'some token', sortBy: '', }), @@ -551,19 +663,19 @@ describe('CustomTable', () => { const spy = vi.fn(() => reloadResult); const wrapper = renderTable({ rows: [], columns, reload: spy }); await flushPromisesInAct(); - const nextBtn = screen.getByTestId('next-page-btn'); + const nextBtn = screen.getByRole('button', { name: 'Next page' }); fireEvent.click(nextBtn); await flushPromisesInAct(); expect(spy).toHaveBeenLastCalledWith({ filter: '', orderAscending: false, - pageSize: 10, + pageSize: 20, pageToken: 'some token', sortBy: '', }); expect(wrapper.state()).toHaveProperty('currentPage', 1); wrapper.rerender({ ...baseProps, rows: [rows[1]], columns, reload: spy }); - const prevBtn = screen.getByTestId('prev-page-btn'); + const prevBtn = screen.getByRole('button', { name: 'Previous page' }); expect(prevBtn).not.toBeDisabled(); wrapper.unmount(); }); @@ -573,8 +685,8 @@ describe('CustomTable', () => { const spy = vi.fn(() => reloadResult); const wrapper = renderTable({ rows: [], columns, reload: spy }); await flushPromisesInAct(); - const prevBtn = screen.getByTestId('prev-page-btn'); - const nextBtn = screen.getByTestId('next-page-btn'); + const prevBtn = screen.getByRole('button', { name: 'Previous page' }); + const nextBtn = screen.getByRole('button', { name: 'Next page' }); fireEvent.click(nextBtn); await flushPromisesInAct(); fireEvent.click(prevBtn); @@ -583,13 +695,13 @@ describe('CustomTable', () => { expect(spy).toHaveBeenLastCalledWith({ filter: '', orderAscending: false, - pageSize: 10, + pageSize: 20, pageToken: '', sortBy: '', }), ); wrapper.rerender({ ...baseProps, rows, columns, reload: spy }); - const prevBtnAfterUpdate = screen.getByTestId('prev-page-btn'); + const prevBtnAfterUpdate = screen.getByRole('button', { name: 'Previous page' }); expect(prevBtnAfterUpdate).toBeDisabled(); await flushPromisesInAct(); wrapper.unmount(); @@ -632,7 +744,7 @@ describe('CustomTable', () => { const row = { ...rows[0], expandState: ExpandState.COLLAPSED }; const wrapper = renderTable({ rows: [row], columns, getExpandComponent: () => null }); await flushPromisesInAct(); - expect(screen.getByLabelText('Expand')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Expand resource row1' })).toBeInTheDocument(); wrapper.unmount(); }); @@ -645,16 +757,20 @@ describe('CustomTable', () => { disableSelection: true, }); await flushPromisesInAct(); - expect(screen.queryByTestId('select-all-checkbox')).toBeNull(); + expect( + screen.queryByRole('checkbox', { name: 'Select all resources on this page' }), + ).toBeNull(); wrapper.unmount(); }); - it('renders an expanded row', async () => { + it('forwards expanded row state to the renderer', async () => { const row = { ...rows[0], expandState: ExpandState.EXPANDED }; - const wrapper = renderTable({ rows: [row], columns }); + const wrapper = renderTable({ rows: [row], columns, getExpandComponent: () => null }); await flushPromisesInAct(); - const tableRow = screen.getByTestId('table-row'); - expect(tableRow.closest('[class*="expandedContainer"]')).not.toBeNull(); + expect(screen.getByRole('button', { name: 'Collapse resource row1' })).toHaveAttribute( + 'aria-expanded', + 'true', + ); wrapper.unmount(); }); @@ -674,30 +790,39 @@ describe('CustomTable', () => { const row = { ...rows[0], expandState: ExpandState.EXPANDED }; const toggleSpy = vi.fn(); renderTable({ - rows: [row, row, row], + rows: [row, { ...row, id: 'row2' }, { ...row, id: 'row3' }], columns, getExpandComponent: () => Hello World, toggleExpansion: toggleSpy, }); await flushPromisesInAct(); - const expandButtons = screen.getAllByLabelText('Expand'); - fireEvent.click(expandButtons[1]); + fireEvent.click(screen.getByRole('button', { name: 'Collapse resource row2' })); expect(toggleSpy).toHaveBeenCalledWith(1); }); it('renders a table with sorting disabled', async () => { - const wrapper = renderTable({ rows, columns, disableSorting: true }); + const wrapper = renderTable({ + rows, + columns: columns.map((column) => ({ ...column, sortKey: column.label })), + disableSorting: true, + }); await flushPromisesInAct(); - expect(screen.queryByLabelText('Sort')).toBeNull(); + expect( + within(screen.getByRole('columnheader', { name: 'col1' })).queryByRole('button'), + ).toBeNull(); + expect( + within(screen.getByRole('columnheader', { name: 'col2' })).queryByRole('button'), + ).toBeNull(); expect(screen.getAllByTestId('table-row')).toHaveLength(2); wrapper.unmount(); }); it('updates the filter string in state when the filter box input changes', async () => { const wrapper = renderTable({ rows, columns }); - await invokeAndFlush(() => - wrapper.instance().handleFilterChange({ target: { value: 'test filter' } }), - ); + await flushPromisesInAct(); + fireEvent.change(screen.getByRole('searchbox', { name: 'Filter' }), { + target: { value: 'test filter' }, + }); expect(wrapper.state('filterString')).toEqual('test filter'); wrapper.unmount(); }); @@ -724,7 +849,7 @@ describe('CustomTable', () => { expect(reload).toHaveBeenLastCalledWith({ filter: expectedEncodedFilter, orderAscending: false, - pageSize: 10, + pageSize: 20, pageToken: '', sortBy: '', }); @@ -761,7 +886,7 @@ describe('CustomTable', () => { expect(reload).toHaveBeenLastCalledWith({ filter: expectedEncodedFilter, orderAscending: false, - pageSize: 10, + pageSize: 20, pageToken: '', sortBy: '', }), @@ -812,3 +937,42 @@ describe('CustomTable', () => { window.location.hash = ''; }); }); + +it('reloads changed predicates without remounting or losing the current filter and sort', async () => { + const reload = vi.fn().mockResolvedValue('next-token'); + const props = { + ...baseProps, + reload, + initialFilterString: 'saved', + initialSortColumn: 'name', + initialSortOrder: 'asc' as const, + }; + const wrapper = renderTable(props); + await flushPromisesInAct(); + const instance = wrapper.instance(); + reload.mockClear(); + const predicate = { + key: 'type', + operation: V2PredicateOperation.IN, + int_values: { values: [6] }, + }; + wrapper.rerender({ ...props, filterPredicates: [predicate] }); + await flushPromisesInAct(); + expect(wrapper.instance()).toBe(instance); + expect(reload).toHaveBeenCalledTimes(1); + expect(reload.mock.lastCall?.[0]).toMatchObject({ + pageToken: '', + sortBy: 'name', + orderAscending: true, + }); + expect(JSON.parse(decodeURIComponent(reload.mock.lastCall?.[0].filter))).toEqual({ + predicates: [ + { key: 'name', operation: V2PredicateOperation.IS_SUBSTRING, string_value: 'saved' }, + predicate, + ], + }); + wrapper.rerender({ ...props, filterPredicates: [{ ...predicate }] }); + await flushPromisesInAct(); + expect(reload).toHaveBeenCalledTimes(1); + expect(wrapper.state('filterString')).toBe('saved'); +}); diff --git a/frontend/src/components/CustomTable.tsx b/frontend/src/components/CustomTable.tsx index 288816af733..8f951681c86 100644 --- a/frontend/src/components/CustomTable.tsx +++ b/frontend/src/components/CustomTable.tsx @@ -15,31 +15,11 @@ */ import * as React from 'react'; -import ArrowRight from '@mui/icons-material/ArrowRight'; -import ChevronLeft from '@mui/icons-material/ChevronLeft'; -import ChevronRight from '@mui/icons-material/ChevronRight'; -import FilterIcon from '@mui/icons-material/FilterList'; -import Input from '../atoms/Input'; -import Separator from '../atoms/Separator'; import { ListRequest } from '../lib/Apis'; -import { classes, stylesheet } from 'typestyle'; -import { fonts, fontsize, dimension, commonCss, color, padding, zIndex } from '../Css'; import { LocalStorage } from '../lib/LocalStorage'; -import { logger } from '../lib/Utils'; import { debounce } from 'lodash'; -import { - InputAdornment, - Checkbox, - CircularProgress, - IconButton, - MenuItem, - Radio, - TableSortLabel, - TextField, - Tooltip, -} from '@mui/material'; -import { CustomTableRow } from './CustomTableRow'; -import { V2Filter, V2PredicateOperation } from 'src/apisv2/filter'; +import type { V2Predicate } from 'src/apisv2/filter'; +import { encodeNameFilter } from 'src/lib/ApiFilter'; export enum ExpandState { COLLAPSED, @@ -48,6 +28,8 @@ export enum ExpandState { } export interface Column { + /** Fixed metadata width; leave primary names flexible. */ + width?: React.CSSProperties['width']; flex?: number; label: string; sortKey?: string; @@ -66,129 +48,49 @@ export interface Row { otherFields: any[]; } -const rowHeight = 40; - -export const css = stylesheet({ - cell: { - $nest: { - '&:not(:nth-child(2))': { - color: color.inactive, - }, - }, - alignSelf: 'center', - borderBottom: 'initial', - color: color.foreground, - fontFamily: fonts.secondary, - fontSize: fontsize.base, - letterSpacing: 0.25, - marginRight: 20, - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - }, - columnName: { - color: '#1F1F1F', - fontSize: fontsize.small, - fontWeight: 'bold', - letterSpacing: 0.25, - marginRight: 20, - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - }, - emptyMessage: { - padding: 20, - textAlign: 'center', - }, - expandButton: { - marginRight: 10, - padding: 3, - transition: 'transform 0.3s', - }, - expandButtonExpanded: { - transform: 'rotate(90deg)', - }, - expandButtonPlaceholder: { - width: 54, - }, - expandableContainer: { - transition: 'margin 0.2s', - }, - expandedContainer: { - borderRadius: 10, - boxShadow: '0 1px 2px 0 rgba(60,64,67,0.30), 0 1px 3px 1px rgba(60,64,67,0.15)', - margin: '16px 2px', - }, - expandedRow: { - borderBottom: '1px solid transparent !important', - boxSizing: 'border-box', - height: '40px !important', - }, - filterBorderRadius: { - borderRadius: 8, - }, - filterBox: { - margin: '16px 0', - }, - footer: { - borderBottom: '1px solid ' + color.divider, - fontFamily: fonts.secondary, - height: 40, - textAlign: 'right', - }, - header: { - borderBottom: 'solid 1px ' + color.divider, - color: color.strong, - display: 'flex', - flex: '0 0 40px', - lineHeight: '40px', // must declare px - }, - noLeftPadding: { - paddingLeft: 0, - }, - noMargin: { - margin: 0, - }, - row: { - $nest: { - '&:hover': { - backgroundColor: '#f3f3f3', - }, - '&:hover a': { - color: color.linkLight, - cursor: 'pointer', - }, - }, - borderBottom: '1px solid #ddd', - display: 'flex', - flexShrink: 0, - height: rowHeight, - outline: 'none', - }, - rowsPerPage: { - color: color.strong, - height: dimension.xsmall, - minWidth: dimension.base, - }, - selected: { - backgroundColor: color.activeBg, - }, - selectionToggle: { - marginRight: 12, - overflow: 'initial', // Resets overflow from 'hidden' - }, - verticalAlignInitial: { - verticalAlign: 'initial', - }, -}); +export interface CustomTableRenderModel { + columns: Column[]; + rows: Row[]; + selectedIds: string[]; + filter: string; + filterLabel: string; + filterActions?: React.ReactNode; + onFilterChange: (value: string) => void; + sortBy: string; + sortOrder: 'asc' | 'desc'; + onSort: (key: string, order?: 'asc' | 'desc') => void; + onSelect: (id: string) => void; + onSelectAll: (checked: boolean) => void; + isBusy: boolean; + emptyMessage?: string; + errorMessage?: string; + pageSize: number; + onPageSizeChange: (size: number) => void; + canPrevious: boolean; + canNext: boolean; + onPrevious: () => void; + onNext: () => void; + disablePaging?: boolean; + disableSelection?: boolean; + disableSorting?: boolean; + noFilterBox?: boolean; + useRadioButtons?: boolean; + disableAdditionalSelection?: boolean; + getExpandedContent?: (index: number) => React.ReactNode; + onToggleExpansion?: (index: number) => void; +} interface CustomTableProps { columns: Column[]; + renderTable: (model: CustomTableRenderModel) => React.ReactNode; disablePaging?: boolean; disableSelection?: boolean; disableSorting?: boolean; emptyMessage?: string; + errorMessage?: string; filterLabel?: string; + /** Changes reload the first page while preserving the current name filter and sort. */ + filterPredicates?: V2Predicate[]; filterActions?: React.ReactNode; getExpandComponent?: (index: number) => React.ReactNode; initialSortColumn?: string; @@ -220,7 +122,7 @@ interface CustomTableState { export default class CustomTable extends React.Component { private _isMounted = true; - /** Suppresses stale `isBusy` updates when reload() overlaps (e.g. React StrictMode remounts). */ + /** Suppresses stale paging and busy updates when requests overlap or the table remounts. */ private _activeReloadGeneration = 0; private _debouncedFilterRequest = debounce( @@ -234,9 +136,10 @@ export default class CustomTable extends React.Component): void { - if (this.props.disableSelection === true) { - // This should be impossible to reach - return; - } - const selectedIds = event.target.checked ? this.props.rows.map((v) => v.id) : []; + private selectAll(checked: boolean): void { + if (this.props.disableSelection) return; + const selectedIds = checked ? this.props.rows.map((v) => v.id) : []; if (this.props.updateSelection) { this.props.updateSelection(selectedIds); } } - public handleClick(e: React.MouseEvent, id: string): void { + private selectRow(id: string): void { if (this.props.disableSelection === true) { return; } @@ -278,12 +178,6 @@ export default class CustomTable extends React.Component total + (c.flex || 1), 0); - const widths = this.props.columns.map((c) => ((c.flex || 1) / totalFlex) * 100); - return ( -
- {/* Filter/Search bar */} - {!this.props.noFilterBox && ( -
- - - - ), - }} - /> - {this.props.filterActions && ( -
{this.props.filterActions}
- )} -
- )} - {/* Header */} -
- { - // Called as function to avoid breaking shallow rendering tests. - HeaderRowSelectionSection({ - disableSelection: this.props.disableSelection, - indeterminate: !!numSelected && numSelected < this.props.rows.length, - isSelected: !!numSelected && numSelected === this.props.rows.length, - onSelectAll: this.handleSelectAllClick.bind(this), - showExpandButton: !!this.props.getExpandComponent, - useRadioButtons: this.props.useRadioButtons, - disableAdditionalSelection: this.props.disableAdditionalSelection, - }) - } - {this.props.columns.map((col, i) => { - const isColumnSortable = !!this.props.columns[i].sortKey; - const isCurrentSortColumn = sortBy === this.props.columns[i].sortKey; - return ( -
- {this.props.disableSorting === true && col.label} - {!this.props.disableSorting && ( - - this._requestSort(this.props.columns[i].sortKey)} - > - {col.label} - - - )} -
- ); - })} -
- {/* Body */} -
- {/* Busy experience */} - {this.state.isBusy && ( - -
- - - )} - - {/* Empty experience */} - {this.props.rows.length === 0 && !!this.props.emptyMessage && !this.state.isBusy && ( -
{this.props.emptyMessage}
- )} - {this.props.rows.map((row, i) => { - if (row.otherFields.length !== this.props.columns.length) { - logger.error('Rows must have the same number of cells defined in columns'); - return null; - } - const selected = this.isSelected(row.id); - return ( -
-
this.handleClick(e, row.id)} - > - { - // Called as function to avoid breaking shallow rendering tests. - BodyRowSelectionSection({ - disableSelection: this.props.disableSelection, - expandState: row.expandState, - isSelected: selected, - onExpand: (e) => this._expandButtonToggled(e, i), - showExpandButton: !!this.props.getExpandComponent, - useRadioButtons: this.props.useRadioButtons, - disableAdditionalSelection: this.props.disableAdditionalSelection, - }) - } - -
- {row.expandState === ExpandState.EXPANDED && this.props.getExpandComponent && ( -
{this.props.getExpandComponent(i)}
- )} -
- ); - })} -
- {/* Footer */} - {!this.props.disablePaging && ( -
- Rows per page: - - {[10, 20, 50, 100].map((size, i) => ( - - {size} - - ))} - - - this._pageChanged(-1)} - disabled={!this.state.currentPage} - size='large' - > - - - this._pageChanged(1)} - disabled={this.state.currentPage >= this.state.maxPageIndex} - size='large' - > - - -
- )} -
+ <> + {this.props.renderTable({ + columns: this.props.columns, + rows: this.props.rows, + selectedIds: this.props.selectedIds || [], + filter: filterString, + filterActions: this.props.filterActions, + filterLabel: this.props.filterLabel || 'Filter', + onFilterChange: this.changeFilter, + sortBy, + sortOrder, + onSort: (key, order) => this._requestSort(key, order), + onSelect: (id) => this.selectRow(id), + onSelectAll: (checked) => this.selectAll(checked), + isBusy: this.state.isBusy, + emptyMessage: this.props.emptyMessage, + errorMessage: this.props.errorMessage, + pageSize, + onPageSizeChange: (size) => this.changePageSize(size), + canPrevious: this.state.currentPage > 0, + canNext: this.state.currentPage < this.state.maxPageIndex, + onPrevious: () => this._pageChanged(-1), + onNext: () => this._pageChanged(1), + disablePaging: this.props.disablePaging, + disableSelection: this.props.disableSelection, + disableSorting: this.props.disableSorting, + noFilterBox: this.props.noFilterBox, + useRadioButtons: this.props.useRadioButtons, + disableAdditionalSelection: this.props.disableAdditionalSelection, + getExpandedContent: this.props.getExpandComponent, + onToggleExpansion: this.props.toggleExpansion, + })} + ); } - public async reload(loadRequest?: ListRequest): Promise { + public reload(loadRequest?: ListRequest): Promise { + return this._reload(loadRequest, loadRequest ? 'unchanged' : 'refresh'); + } + + private async _reload( + loadRequest: ListRequest | undefined, + paging: 'unchanged' | 'refresh' | 'reset' | number, + ): Promise { // Override the current state with incoming request const request: ListRequest = Object.assign( { @@ -516,6 +263,7 @@ export default class CustomTable extends React.Component { - const value = event.target.value; + private changeFilter = (value: string) => { if (this.props.setFilterString) { this.props.setFilterString(value || ''); } @@ -553,23 +320,9 @@ export default class CustomTable extends React.Component { - const filterStringEncoded = filterString ? this._createAndEncodeFilterV2(filterString) : ''; + const filterStringEncoded = encodeNameFilter(filterString || '', this.props.filterPredicates); this.setStateSafe({ filterStringEncoded }); - this._resetToFirstPage(await this.reload({ filter: filterStringEncoded })); - } - - private _createAndEncodeFilterV2(filterString: string): string { - const filter: V2Filter = { - predicates: [ - { - // TODO: remove this hardcoding once more sophisticated filtering is supported - key: 'name', - operation: V2PredicateOperation.IS_SUBSTRING, - string_value: filterString, - }, - ], - }; - return encodeURIComponent(JSON.stringify(filter)); + await this._reload({ filter: filterStringEncoded, pageToken: '' }, 'reset'); } private setStateSafe(newState: Partial, cb?: () => void): void { @@ -578,52 +331,28 @@ export default class CustomTable extends React.Component { - this._resetToFirstPage( - await this.reload({ pageToken: '', orderAscending: sortOrder === 'asc', sortBy }), - ); + await this._reload({ pageToken: '', orderAscending: sortOrder === 'asc', sortBy }, 'reset'); }); } } private async _pageChanged(offset: number): Promise { let newCurrentPage = this.state.currentPage + offset; - let maxPageIndex = this.state.maxPageIndex; newCurrentPage = Math.max(0, newCurrentPage); newCurrentPage = Math.min(this.state.maxPageIndex, newCurrentPage); - - const reloadGeneration = this._activeReloadGeneration + 1; - const newPageToken = await this.reload({ - pageToken: this.state.tokenList[newCurrentPage], - }); - if (!this._isMounted || reloadGeneration !== this._activeReloadGeneration) { - return; - } - - if (newPageToken) { - // If we're using the greatest yet known page, then the pageToken will be new. - if (newCurrentPage + 1 === this.state.tokenList.length) { - this.state.tokenList.push(newPageToken); - } - } else { - maxPageIndex = newCurrentPage; - } - - this.setStateSafe({ currentPage: newCurrentPage, maxPageIndex }); + await this._reload({ pageToken: this.state.tokenList[newCurrentPage] }, newCurrentPage); } - private async _requestRowsPerPage( - event: React.ChangeEvent, - ): Promise { - const pageSize = Number(event.target.value); + private async changePageSize(pageSize: number): Promise { LocalStorage.saveTablePageSize(pageSize, this._getPageId()); - this._resetToFirstPage(await this.reload({ pageSize, pageToken: '' })); + await this._reload({ pageSize, pageToken: '' }, 'reset'); } private _getPageId(): string | undefined { @@ -665,113 +394,4 @@ export default class CustomTable extends React.Component - {/* If using checkboxes */} - {disableSelection !== true && useRadioButtons !== true && ( - - )} - {/* If using radio buttons */} - {disableSelection !== true && useRadioButtons && ( - // Placeholder for radio button horizontal space. - - )} - {showExpandButton && } -
- ); -} - -interface BodyRowSelectionSectionProps extends SelectionSectionCommonProps { - expandState?: ExpandState; - onExpand: React.MouseEventHandler; - disableAdditionalSelection?: boolean; -} -function BodyRowSelectionSection({ - disableSelection, - expandState, - isSelected, - onExpand, - showExpandButton, - useRadioButtons, - disableAdditionalSelection, -}: BodyRowSelectionSectionProps): React.JSX.Element { - return ( - <> - {/* Expansion toggle button */} - {(disableSelection !== true || showExpandButton) && expandState !== ExpandState.NONE && ( -
- {/* If using checkboxes */} - {disableSelection !== true && useRadioButtons !== true && ( - - )} - {/* If using radio buttons */} - {disableSelection !== true && useRadioButtons && ( - - )} - {showExpandButton && ( - - - - )} -
- )} - - {/* Placeholder for non-expandable rows */} - {expandState === ExpandState.NONE &&
} - - ); } diff --git a/frontend/src/components/CustomTableNameColumn.test.tsx b/frontend/src/components/CustomTableNameColumn.test.tsx index 10b255677d1..2b2cf5a30e3 100644 --- a/frontend/src/components/CustomTableNameColumn.test.tsx +++ b/frontend/src/components/CustomTableNameColumn.test.tsx @@ -14,18 +14,22 @@ * limitations under the License. */ -import { render, screen } from '@testing-library/react'; -import { NameWithTooltip } from './CustomTableNameColumn'; +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Link } from 'react-router'; +import { NameWithTooltip, NameTooltip } from './CustomTableNameColumn'; describe('NameWithTooltip', () => { - it('renders display_name when available', () => { + it('reveals the resource name on keyboard focus while retaining its display name', async () => { render( , ); - expect(screen.getByText('My Pipeline')).toBeInTheDocument(); + await userEvent.tab(); + expect(screen.getByText('My Pipeline')).toHaveFocus(); + expect(await screen.findByRole('tooltip')).toHaveTextContent('Name: pipeline-123'); }); it('falls back to name when display_name is not available', () => { @@ -54,3 +58,27 @@ describe('NameWithTooltip', () => { expect(screen.queryByText('internal-name')).toBeNull(); }); }); + +it('shows the full linked experiment name on focus and preserves navigation', async () => { + render( + + + Experiment + + , + ); + await userEvent.tab(); + expect(await screen.findByRole('tooltip')).toHaveTextContent('Full experiment name'); + expect(screen.getByRole('link', { name: 'Experiment' })).toHaveAttribute( + 'href', + '/experiments/id', + ); +}); + +it('reveals the full resource name for touch input', async () => { + render( + , + ); + fireEvent.pointerDown(screen.getByText('Short'), { pointerType: 'touch' }); + expect(await screen.findByRole('tooltip')).toHaveTextContent('Name: full-resource-name'); +}); diff --git a/frontend/src/components/CustomTableNameColumn.tsx b/frontend/src/components/CustomTableNameColumn.tsx index 851024d63d6..4e33f8567bc 100644 --- a/frontend/src/components/CustomTableNameColumn.tsx +++ b/frontend/src/components/CustomTableNameColumn.tsx @@ -15,8 +15,34 @@ */ import { CustomRendererProps } from './CustomTable'; -import React from 'react'; -import { Tooltip } from '@mui/material'; +import React, { type ReactElement, useId, useState } from 'react'; +import { Tooltip } from '@base-ui/react/tooltip'; + +export function NameTooltip({ name, children }: { name: string; children: ReactElement }) { + const [trigger, setTrigger] = useState(null); + const [open, setOpen] = useState(false); + const triggerId = useId(); + return ( + + { + if (event.pointerType === 'touch') setOpen(true); + }} + /> + ('.kfp-theme') ?? undefined}> + + + {name} + + + + + ); +} /** * Common name custom renderer that shows a tooltip when hovered. The tooltip helps if there isn't @@ -29,8 +55,10 @@ export const NameWithTooltip: React.FC< }> > = (props: CustomRendererProps<{ display_name?: string; name?: string }>) => { return ( - - {props.value?.display_name || props.value?.name || ''} - + + + {props.value?.display_name || props.value?.name || ''} + + ); }; diff --git a/frontend/src/components/CustomTableRow.test.tsx b/frontend/src/components/CustomTableRow.test.tsx deleted file mode 100644 index 30bc01929bd..00000000000 --- a/frontend/src/components/CustomTableRow.test.tsx +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright 2019 The Kubeflow Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { render } from '@testing-library/react'; -import { Column, Row } from './CustomTable'; -import { flushPromisesInAct } from '../TestUtils'; -import { CustomTableRow } from './CustomTableRow'; - -describe('CustomTable', () => { - const props = { - columns: [], - row: [], - }; - - const columns: Column[] = [ - { - customRenderer: undefined, - label: 'col1', - }, - { - customRenderer: undefined, - label: 'col2', - }, - ]; - - const row: Row = { - id: 'row', - otherFields: ['cell1', 'cell2'], - }; - - it('renders some rows using a custom renderer', async () => { - columns[0].customRenderer = () => (this is custom output) as any; - const { asFragment } = render(); - await flushPromisesInAct(); - expect(asFragment()).toMatchSnapshot(); - columns[0].customRenderer = undefined; - }); - - it('displays warning icon with tooltip if row has error', async () => { - row.error = 'dummy error'; - const { asFragment } = render(); - await flushPromisesInAct(); - expect(asFragment()).toMatchSnapshot(); - row.error = undefined; - }); -}); diff --git a/frontend/src/components/CustomTableRow.tsx b/frontend/src/components/CustomTableRow.tsx deleted file mode 100644 index 0f204a96798..00000000000 --- a/frontend/src/components/CustomTableRow.tsx +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright 2019 The Kubeflow Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import * as React from 'react'; -import WarningIcon from '@mui/icons-material/WarningRounded'; -import { Row, Column } from './CustomTable'; -import { color, fonts, fontsize } from '../Css'; -import { stylesheet } from 'typestyle'; -import { Tooltip } from '@mui/material'; - -export const css = stylesheet({ - cell: { - $nest: { - '&:not(:nth-child(2))': { - color: color.inactive, - }, - }, - alignSelf: 'center', - borderBottom: 'initial', - color: color.foreground, - fontFamily: fonts.secondary, - fontSize: fontsize.base, - letterSpacing: 0.25, - marginRight: 20, - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - }, - icon: { - color: color.alert, - height: 18, - paddingRight: 4, - verticalAlign: 'sub', - width: 18, - }, - row: { - $nest: { - '&:hover': { - backgroundColor: '#f3f3f3', - }, - '&:hover a': { - color: color.linkLight, - }, - }, - borderBottom: '1px solid #ddd', - display: 'flex', - flexShrink: 0, - height: 40, - outline: 'none', - }, -}); - -interface CustomTableRowProps { - row: Row; - columns: Column[]; -} - -function calculateColumnWidths(columns: Column[]): number[] { - const totalFlex = columns.reduce((total, c) => total + (c.flex || 1), 0); - return columns.map((c) => ((c.flex || 1) / totalFlex) * 100); -} - -// tslint:disable-next-line:variable-name -export const CustomTableRow: React.FC = (props: CustomTableRowProps) => { - const { row, columns } = props; - const widths = calculateColumnWidths(columns); - return ( - - {row.otherFields.map((cell, i) => ( -
- {i === 0 && row.error && ( - - - - )} - {columns[i].customRenderer - ? columns[i].customRenderer!({ value: cell, id: row.id }) - : cell} -
- ))} -
- ); -}; diff --git a/frontend/src/components/DetailsTable.test.tsx b/frontend/src/components/DetailsTable.test.tsx deleted file mode 100644 index 2e439f7d286..00000000000 --- a/frontend/src/components/DetailsTable.test.tsx +++ /dev/null @@ -1,522 +0,0 @@ -/* - * Copyright 2018 The Kubeflow Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import * as React from 'react'; - -import DetailsTable from './DetailsTable'; -import { render } from '@testing-library/react'; - -vi.mock('./Editor', () => ({ - default: ({ value }: { value: string }) =>
{value}
, -})); - -describe('DetailsTable', () => { - it('shows no rows', () => { - const { container } = render(); - expect(container).toMatchInlineSnapshot(` -
-
-
- `); - }); - - it('shows one row', () => { - const { container } = render(); - expect(container).toMatchInlineSnapshot(` -
-
-
- - key - - - value - -
-
-
- `); - }); - - it('shows a row with a title', () => { - const { container } = render(); - expect(container).toMatchInlineSnapshot(` -
-
- some title -
-
-
- - key - - - value - -
-
-
- `); - }); - - it('shows key and value for large values', () => { - const { container } = render( - , - ); - expect(container).toMatchInlineSnapshot(` -
-
-
- - key - - - Lorem Ipsum is simply dummy text of the printing and typesetting - industry. Lorem Ipsum has been the industry's standard dummy text ever - since the 1500s, when an unknown printer took a galley of type and - scrambled it to make a type specimen book. It has survived not only five - centuries, but also the leap into electronic typesetting, remaining - essentially unchanged. It was popularised in the 1960s with the release - of Letraset sheets containing Lorem Ipsum passages, and more recently - with desktop publishing software like Aldus PageMaker including versions - of Lorem Ipsum. - -
-
-
- `); - }); - - it('shows key and value in row', () => { - const { container } = render(); - expect(container).toMatchInlineSnapshot(` -
-
-
- - key - - - value - -
-
-
- `); - }); - - it('shows key and JSON value in row', () => { - const { container } = render( - , - ); - expect(container).toMatchInlineSnapshot(` -
-
-
- - key - -
-              [
-        {
-          "jsonKey": "jsonValue"
-        }
-      ]
-            
-
-
-
- `); - }); - - it('does render arrays as JSON', () => { - const { container } = render(); - expect(container).toMatchInlineSnapshot(` -
-
-
- - key - -
-              []
-            
-
-
-
- `); - }); - - it('does render empty object as JSON', () => { - const { container } = render(); - expect(container).toMatchInlineSnapshot(` -
-
-
- - key - -
-              {}
-            
-
-
-
- `); - }); - - it('does not render nulls as JSON', () => { - const { container } = render(); - expect(container).toMatchInlineSnapshot(` -
-
-
- - key - - - null - -
-
-
- `); - }); - - it('does not render numbers as JSON', () => { - const { container } = render(); - expect(container).toMatchInlineSnapshot(` -
-
-
- - key - - - 10 - -
-
-
- `); - }); - - it('does not render strings as JSON', () => { - const { container } = render(); - expect(container).toMatchInlineSnapshot(` -
-
-
- - key - - - "some string" - -
-
-
- `); - }); - - it('does not render booleans as JSON', () => { - const { container } = render( - , - ); - expect(container).toMatchInlineSnapshot(` -
-
-
- - key1 - - - true - -
-
- - key2 - - - false - -
-
-
- `); - }); - - it('shows keys and values for multiple rows', () => { - const { container } = render( - , - ); - expect(container).toMatchInlineSnapshot(` -
-
-
- - key1 - - - value1 - -
-
- - key2 - -
-              [
-        {
-          "jsonKey": "jsonValue2"
-        }
-      ]
-            
-
-
- - key3 - - - value3 - -
-
- - key4 - - - value4 - -
-
- - key5 - -
-              {
-        "jsonKey": {
-          "nestedJsonKey": "jsonValue"
-        }
-      }
-            
-
-
- - key6 - - - value6 - -
-
- - key6 - - - value7 - -
-
- - key - - - [object Object] - -
-
-
- `); - }); - - it('does render values with the provided valueComponent', () => { - const ValueComponent: React.FC = ({ value, ...rest }) => ( -
- {JSON.stringify(value)} - - ); - const { getByTestId } = render( - , - ); - expect(getByTestId('value-component')).toMatchInlineSnapshot(` - - {"key":"foobar","bucket":"bucket","endpoint":"s3.amazonaws.com"} - - `); - }); -}); diff --git a/frontend/src/components/DetailsTable.tsx b/frontend/src/components/DetailsTable.tsx deleted file mode 100644 index 6ba2646a2ca..00000000000 --- a/frontend/src/components/DetailsTable.tsx +++ /dev/null @@ -1,127 +0,0 @@ -/* - * Copyright 2018 The Kubeflow Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import * as React from 'react'; -import { stylesheet } from 'typestyle'; -import { color, commonCss, spacing } from '../Css'; -import { KeyValue } from '../lib/DetailsTableTypes'; -import Editor from './Editor'; - -export const css = stylesheet({ - key: { - color: color.strong, - flex: '0 0 50%', - fontWeight: 'bold', - maxWidth: 300, - }, - row: { - borderBottom: `1px solid ${color.divider}`, - display: 'flex', - padding: `${spacing.units(-5)}px ${spacing.units(-6)}px`, - }, - valueJson: { - flexGrow: 1, - }, - valueText: { - // flexGrow expands value text to full width. - flexGrow: 1, - // For current use-cases, value text shouldn't be very long. It will be not readable when it's long. - // Therefore, it's easier we just show it completely in the UX. - overflow: 'hidden', - // Sometimes, urls will be unbreakable for a long string. overflow-wrap: break-word - // allows breaking an url at middle of a word so it will not overflow and get hidden. - overflowWrap: 'break-word', - }, -}); - -export interface ValueComponentProps { - value?: string | T; - [key: string]: any; -} - -interface DetailsTableProps { - fields: Array>; - title?: string; - valueComponent?: React.FC>; - valueComponentProps?: { [key: string]: any }; -} - -function isString(x: any): x is string { - return typeof x === 'string'; -} - -const DetailsTable = (props: DetailsTableProps) => { - const { fields, title, valueComponent: ValueComponent, valueComponentProps } = props; - return ( - - {!!title &&
{title}
} -
- {fields.map((f, i) => { - const [key, value] = f; - - // only try to parse json if value is a string - if (isString(value)) { - try { - const parsedJson = JSON.parse(value); - // Nulls, booleans, strings, and numbers can all be parsed as JSON, but we don't care - // about rendering. Note that `typeOf null` returns 'object' - if (parsedJson === null || typeof parsedJson !== 'object') { - throw new Error( - 'Parsed JSON was neither an array nor an object. Using default renderer', - ); - } - return ( -
- {key} - -
- ); - } catch (err) { - // do nothing - } - } - // If a ValueComponent and a value is provided, render the value with - // the ValueComponent. Otherwise render the value as a string (empty string if null or undefined). - return ( -
- {key} - - {ValueComponent && value ? ( - - ) : ( - `${value || ''}` - )} - -
- ); - })} -
-
- ); -}; - -export default DetailsTable; diff --git a/frontend/src/components/Editor.css b/frontend/src/components/Editor.css new file mode 100644 index 00000000000..e3c12538d4b --- /dev/null +++ b/frontend/src/components/Editor.css @@ -0,0 +1,58 @@ +/* Ace keeps its session, workers and editing behavior; the inherited palette owns its colors. */ +.kfp-theme .ace_editor.ace-github { + color: var(--foreground); + background: var(--card); + font-family: var(--kfp-font-mono); + line-height: 1.6; +} +.kfp-theme .ace_editor.ace-github .ace_gutter { + color: var(--muted-foreground); + background: var(--muted); +} +.kfp-theme .ace_editor.ace-github .ace_print-margin { + background: var(--border); +} +.kfp-theme .ace_editor.ace-github .ace_cursor { + color: var(--foreground); +} +.kfp-theme .ace_editor.ace-github .ace_marker-layer .ace_selection { + background: var(--primary-soft); +} +.kfp-theme .ace_editor.ace-github .ace_marker-layer .ace_active-line, +.kfp-theme .ace_editor.ace-github .ace_gutter-active-line { + background: var(--muted); +} +.kfp-theme .ace_editor.ace-github .ace_marker-layer .ace_selected-word { + border-color: var(--primary); + background: var(--primary-soft); +} +.kfp-theme .ace_editor.ace-github .ace_comment { + color: var(--muted-foreground); +} +.kfp-theme .ace_editor.ace-github .ace_keyword, +.kfp-theme .ace_editor.ace-github .ace_storage, +.kfp-theme .ace_editor.ace-github .ace_variable { + color: var(--primary); +} +.kfp-theme .ace_editor.ace-github .ace_string { + color: var(--status-succeeded); +} +.kfp-theme .ace_editor.ace-github .ace_constant.ace_numeric, +.kfp-theme .ace_editor.ace-github .ace_constant.ace_language, +.kfp-theme .ace_editor.ace-github .ace_constant.ace_buildin { + color: var(--status-warning); +} +.kfp-theme .ace_editor.ace-github .ace_support.ace_function { + color: var(--primary); +} +.kfp-theme .ace_editor.ace-github .ace_invalid { + color: var(--status-failed-foreground); + background: var(--status-failed); +} +.kfp-theme .ace_editor.ace-github .ace_fold { + border-color: var(--foreground); + background: var(--primary-soft); +} +.kfp-theme .ace_editor.ace-github .ace_scroller { + background: transparent; +} diff --git a/frontend/src/components/Editor.test.tsx b/frontend/src/components/Editor.test.tsx index 38ded1c99ee..faa714cfcef 100644 --- a/frontend/src/components/Editor.test.tsx +++ b/frontend/src/components/Editor.test.tsx @@ -17,6 +17,9 @@ import * as React from 'react'; import { render } from '@testing-library/react'; import Editor from './Editor'; +import { config } from 'ace-builds'; +import yamlWorkerUrl from 'ace-builds/src-min-noconflict/worker-yaml.js?url'; +import jsonWorkerUrl from 'ace-builds/src-min-noconflict/worker-json.js?url'; /* These tests mimic https://github.com/securingsincity/react-ace/blob/master/tests/src/ace.spec.js @@ -25,6 +28,11 @@ import Editor from './Editor'; */ describe('Editor', () => { + it('resolves YAML and JSON workers to bundled asset URLs', () => { + expect(config.moduleUrl('ace/mode/yaml_worker', 'worker')).toBe(yamlWorkerUrl); + expect(config.moduleUrl('ace/mode/json_worker', 'worker')).toBe(jsonWorkerUrl); + }); + // Ace renders a large, environment-dependent DOM tree. Snapshot tests are brittle // and create noisy diffs during dependency upgrades. Assert key behaviors instead. const getPlaceholderNode = (container: HTMLElement) => @@ -56,6 +64,24 @@ describe('Editor', () => { expect(placeholderNode?.innerHTML).toBe(placeholder); }); + it('keeps the same editor session and read-only value across palette changes', () => { + const ref = React.createRef(); + const renderEditor = (dark: boolean) => ( +
+ +
+ ); + const { container, rerender } = render(renderEditor(false)); + const editor = ref.current!.editor; + editor.selection.moveCursorTo(0, 5); + rerender(renderEditor(true)); + expect(ref.current!.editor).toBe(editor); + expect(editor.getValue()).toBe('name: example'); + expect(editor.getReadOnly()).toBe(true); + expect(editor.getCursorPosition()).toEqual({ row: 0, column: 5 }); + expect(container.querySelector('.ace_editor')).toHaveClass('ace-github'); + }); + it('has its value set to the provided value', () => { const value = 'I am a value.'; const ref = React.createRef(); diff --git a/frontend/src/components/Editor.tsx b/frontend/src/components/Editor.tsx index 13e9a6c9c31..f3e7224127b 100644 --- a/frontend/src/components/Editor.tsx +++ b/frontend/src/components/Editor.tsx @@ -16,10 +16,17 @@ // ReactAce initializes the Ace global before its extensions execute. import AceEditor from 'react-ace'; -import 'ace-builds/src-noconflict/ext-language_tools'; +import { config } from 'ace-builds'; +import yamlWorkerUrl from 'ace-builds/src-min-noconflict/worker-yaml.js?url'; +import jsonWorkerUrl from 'ace-builds/src-min-noconflict/worker-json.js?url'; import 'ace-builds/src-noconflict/mode-json'; import 'ace-builds/src-noconflict/mode-yaml'; import 'ace-builds/src-noconflict/theme-github'; +import './Editor.css'; + +// Explicit asset URLs let Vite package workers and preserve prefixed deployments. +config.setModuleUrl('ace/mode/yaml_worker', yamlWorkerUrl); +config.setModuleUrl('ace/mode/json_worker', jsonWorkerUrl); // Modified AceEditor that supports HTML within provided placeholder. This is // important because it allows for the usage of multi-line placeholders. diff --git a/frontend/src/components/ExperimentList.test.tsx b/frontend/src/components/ExperimentList.test.tsx index 8287c9aede7..84e33fb6421 100644 --- a/frontend/src/components/ExperimentList.test.tsx +++ b/frontend/src/components/ExperimentList.test.tsx @@ -90,7 +90,7 @@ describe('ExperimentList', () => { await screen.findByText('experiment with id: testexperiment1'); expect(listExperimentsSpy).toHaveBeenLastCalledWith( '', - 10, + 20, ExperimentSortKeys.CREATED_AT + ' desc', encodeURIComponent( JSON.stringify({ @@ -115,7 +115,7 @@ describe('ExperimentList', () => { await screen.findByText('experiment with id: testexperiment1'); expect(listExperimentsSpy).toHaveBeenLastCalledWith( '', - 10, + 20, ExperimentSortKeys.CREATED_AT + ' desc', encodeURIComponent( JSON.stringify({ @@ -200,7 +200,7 @@ describe('ExperimentList', () => { }); expect(listExperimentsSpy).toHaveBeenLastCalledWith( '', - 10, + 20, ExperimentSortKeys.CREATED_AT + ' desc', '', undefined, @@ -232,16 +232,24 @@ describe('ExperimentList', () => { it('loads runs for a given experiment id when it is expanded', async () => { mockNExperiments(1); - renderExperimentList(generateProps()); + renderExperimentList({ + ...generateProps(), + scopeControl: ( +
+ Active / Archived +
+ ), + }); await screen.findByText('experiment with id: testexperiment1'); listRunsSpy.mockClear(); await user.click(screen.getAllByRole('button', { name: /expand/i })[0]); await waitFor(() => expect(listRunsSpy).toHaveBeenCalled()); + expect(screen.getAllByRole('group', { name: 'Experiment scope' })).toHaveLength(1); expect(listRunsSpy).toHaveBeenLastCalledWith( undefined, 'testexperiment1', - '', - 10, + undefined, + 5, 'created_at desc', encodeURIComponent( JSON.stringify({ @@ -270,8 +278,8 @@ describe('ExperimentList', () => { expect(listRunsSpy).toHaveBeenLastCalledWith( undefined, 'testexperiment1', - '', - 10, + undefined, + 5, 'created_at desc', encodeURIComponent( JSON.stringify({ diff --git a/frontend/src/components/ExperimentList.tsx b/frontend/src/components/ExperimentList.tsx index 981c9b01e20..55e08bee76b 100644 --- a/frontend/src/components/ExperimentList.tsx +++ b/frontend/src/components/ExperimentList.tsx @@ -16,7 +16,9 @@ import { NavigationProps } from 'src/lib/Navigation'; import CustomTable, { Column, CustomRendererProps, Row, ExpandState } from './CustomTable'; +import { NameTooltip } from './CustomTableNameColumn'; import * as React from 'react'; +import { ResourceTable } from 'src/components/tables/ResourceTable'; import { Link } from 'react-router'; import { V2ListExperimentsResponse, @@ -25,16 +27,15 @@ import { } from 'src/apisv2/experiment'; import { errorToMessage } from 'src/lib/Utils'; import { RoutePage, RouteParams } from './Router'; -import { commonCss } from 'src/Css'; import { Apis, ExperimentSortKeys, ListRequest } from 'src/lib/Apis'; -import { V2RunStorageState } from 'src/apisv2/run'; import { V2Filter, V2PredicateOperation } from 'src/apisv2/filter'; -import RunList from 'src/pages/RunList'; +import { ExperimentRunPreview } from './runs/ExperimentRunPreview'; import { produce as immerProduce } from 'immer'; -import { Tooltip } from '@mui/material'; +import 'src/components/runs/ExperimentWorkflows.css'; export interface ExperimentListProps extends NavigationProps { namespace?: string; + scopeControl?: React.ReactNode; storageState?: V2ExperimentStorageState; onError: (message: string, error: Error) => void; } @@ -70,6 +71,7 @@ export class ExperimentList extends React.PureComponent ( + + )} columns={columns} rows={rows} ref={this._tableRef} @@ -93,7 +105,7 @@ export class ExperimentList extends React.PureComponent
@@ -110,15 +122,18 @@ export class ExperimentList extends React.PureComponent, ) => { return ( - + e.stopPropagation()} - to={RoutePage.EXPERIMENT_DETAILS.replace(':' + RouteParams.experimentId, props.id)} + to={RoutePage.EXPERIMENT_DETAILS.replace( + ':' + RouteParams.experimentId, + encodeURIComponent(props.id), + )} > {props.value} - + ); }; @@ -187,21 +202,11 @@ export class ExperimentList extends React.PureComponent null }; return ( - ); } diff --git a/frontend/src/components/LazyEditor.tsx b/frontend/src/components/LazyEditor.tsx new file mode 100644 index 00000000000..8098ee9975d --- /dev/null +++ b/frontend/src/components/LazyEditor.tsx @@ -0,0 +1,36 @@ +/* + * Copyright 2026 The Kubeflow Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { lazy, Suspense } from 'react'; +import type { IAceEditorProps } from 'react-ace'; + +const Editor = lazy(() => import('./Editor')); + +// Most routes never display source code or a structured parameter editor. +// Keep Ace and its modes out of their initial download and evaluation path. +export default function LazyEditor(props: IAceEditorProps) { + return ( + + Loading editor… +
+ } + > + + + ); +} diff --git a/frontend/src/components/LogViewer.css b/frontend/src/components/LogViewer.css new file mode 100644 index 00000000000..47b2ad1832d --- /dev/null +++ b/frontend/src/components/LogViewer.css @@ -0,0 +1,44 @@ +/* Keep padding outside the measured viewport and the row height at 15px. */ +.kfp-log-viewer { + background: var(--card, #fff); + color: var(--foreground, #15171e); + font-family: var(--kfp-font-mono, ui-monospace, monospace); + font-size: 12px; + line-height: 15px; + /* The inner react-virtualized container is overflow: visible for long lines. */ + overflow: auto !important; + white-space: pre; +} +.kfp-log-line, +.kfp-log-text { + display: flex; +} +.kfp-log-line:hover { + background: var(--muted, #f0f1f5); +} +.kfp-log-number { + color: var(--muted-foreground, #626979); + flex: 40px 0 0; + padding-right: 10px; + text-align: right; + user-select: none; +} +.kfp-log-line[data-level='error'] { + background: var(--status-failed-soft, #fbe8e7); + color: var(--status-failed, #c9302c); +} +.kfp-log-line[data-level='warning'] { + background: var(--status-warning-soft, #fdf2df); + color: var(--status-warning, #975b08); +} +.kfp-log-line[data-level] .kfp-log-number { + color: inherit; +} +.kfp-log-link { + color: inherit; + text-decoration: underline; + text-underline-offset: 2px; +} +.kfp-log-link:hover { + text-decoration-thickness: 2px; +} diff --git a/frontend/src/components/LogViewer.tsx b/frontend/src/components/LogViewer.tsx index 20b0d631a70..fdaa8ac13ab 100644 --- a/frontend/src/components/LogViewer.tsx +++ b/frontend/src/components/LogViewer.tsx @@ -16,52 +16,10 @@ import * as React from 'react'; import { List, AutoSizer, ListRowProps } from 'react-virtualized'; -import { fontsize, fonts } from '../Css'; -import { stylesheet } from 'typestyle'; +import './LogViewer.css'; import { OverscanIndicesGetter } from 'react-virtualized/dist/es/Grid'; -const css = stylesheet({ - a: { - $nest: { - '&:hover': { - color: '#bcdeff', - }, - '&:visited': { - color: '#bc9fff', - }, - }, - color: '#96cbfe', - }, - line: { - $nest: { - '&:hover': { - backgroundColor: '#333', - }, - }, - display: 'flex', - }, - number: { - color: '#999', - flex: '40px 0 0', - paddingRight: 10, - textAlign: 'right', - userSelect: 'none', - }, - root: { - // We cannot easily add padding here without breaking react-virtualized size calculation, for - // details: https://github.com/bvaughn/react-virtualized/issues/992 - // Specifically, a complex solution was proposed in https://github.com/bvaughn/react-virtualized/issues/992#issuecomment-371145943. - // We may consider that later. - backgroundColor: '#222', - color: '#fff', - fontFamily: fonts.code, - fontSize: fontsize.small, - // This override and listContainerStyleOverride workarounds to allow horizontal scroll. - // Reference: https://github.com/bvaughn/react-virtualized/issues/1248 - overflow: 'auto !important', - whiteSpace: 'pre', - }, -}); +export const LOG_ROW_HEIGHT = 15; const listContainerStyleOverride = { overflow: 'visible', @@ -69,6 +27,7 @@ const listContainerStyleOverride = { interface LogViewerProps { logLines: string[]; + label?: string; } // Use the same amount of overscan above and below visible rows. @@ -125,13 +84,13 @@ class LogViewer extends React.Component { {({ height, width }) => ( { const { style, key, index } = props; const line = this.props.logLines[index]; return ( -
+
); @@ -180,10 +139,8 @@ class LogViewer extends React.Component { const LogLine: React.FC<{ index: number; line: string }> = ({ index, line }) => ( <> - - {index + 1} - - + {index + 1} + {parseLine(line).map((piece, p) => ( {piece} ))} @@ -193,21 +150,11 @@ const LogLine: React.FC<{ index: number; line: string }> = ({ index, line }) => // improve performance when rerendering, because we render a lot of logs const MemoedLogLine = React.memo(LogLine); -function getLineStyle(line: string): React.CSSProperties { - const lineLowerCase = line.toLowerCase(); - if (lineLowerCase.indexOf('error') > -1 || lineLowerCase.indexOf('fail') > -1) { - return { - backgroundColor: '#700000', - color: 'white', - }; - } else if (lineLowerCase.indexOf('warn') > -1) { - return { - backgroundColor: '#545400', - color: 'white', - }; - } else { - return {}; - } +function getLineLevel(line: string): 'error' | 'warning' | undefined { + const lower = line.toLowerCase(); + if (lower.includes('error') || lower.includes('fail')) return 'error'; + if (lower.includes('warn')) return 'warning'; + return undefined; } function parseLine(line: string): React.ReactNode[] { @@ -222,7 +169,7 @@ function parseLine(line: string): React.ReactNode[] { nodes.push({line.substr(lastMatch, match.index)}); // Append URL via an anchor element nodes.push( - + {match[0]} , ); diff --git a/frontend/src/components/NativeArtifactLineage.css b/frontend/src/components/NativeArtifactLineage.css new file mode 100644 index 00000000000..8eaade4b749 --- /dev/null +++ b/frontend/src/components/NativeArtifactLineage.css @@ -0,0 +1,177 @@ +.kfp-theme .kfp-native-lineage { + min-width: 0; + color: var(--foreground); +} +.kfp-theme .kfp-lineage-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 12px 0; +} +.kfp-theme .kfp-lineage-history, +.kfp-theme .kfp-lineage-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; +} +.kfp-theme .kfp-lineage-history-item { + max-width: 220px; + padding: 8px 4px; + border: 0; + background: transparent; + color: var(--primary); + font-size: 13px; + cursor: pointer; +} +.kfp-theme .kfp-lineage-history-item[aria-current] { + color: var(--foreground); + font-weight: 600; + cursor: default; +} +.kfp-theme .kfp-lineage-history-item span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.kfp-theme .kfp-lineage-status { + margin: 0; + padding: 8px 0; + color: var(--muted-foreground); + font-size: 12px; +} +.kfp-theme .kfp-lineage-artifact { + display: block; + width: 100%; + min-height: 108px; + padding: 0; + text-align: left; + border: 1px solid var(--border-strong); + border-radius: 8px; + background: var(--card); + color: var(--foreground); + cursor: pointer; +} +.kfp-theme .kfp-lineage-artifact[aria-current] { + border: 2px solid var(--primary); + box-shadow: 0 0 0 3px var(--primary-soft); + cursor: default; +} +.kfp-theme .kfp-lineage-artifact:hover { + border-color: var(--primary); +} +.kfp-theme .kfp-native-lineage :is(button, a):focus-visible { + outline: 2px solid var(--primary); + outline-offset: 3px; +} +.kfp-theme .kfp-lineage-kind { + display: block; + padding: 10px 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + border-bottom: 1px solid var(--border); + color: var(--primary); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.kfp-theme .kfp-lineage-artifact-name { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 14px; + font-size: 14px; + font-weight: 500; + overflow-wrap: anywhere; +} +.kfp-theme .kfp-lineage-artifact-name::before { + content: ''; + flex: 0 0 12px; + height: 12px; + border-radius: 3px; + background: var(--primary-soft); + border: 1px solid var(--primary); +} +.kfp-theme .kfp-lineage-branch { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + column-gap: 48px; + align-items: start; + margin-bottom: 24px; +} +.kfp-theme .kfp-lineage-task { + min-height: 108px; + overflow: hidden; + border: 1px solid var(--border-strong); + border-radius: 8px; + background: var(--primary-soft); +} +.kfp-theme .kfp-lineage-task-name { + min-height: 52px; + padding: 12px 14px; + font-size: 14px; + font-weight: 500; + overflow-wrap: anywhere; +} +.kfp-theme .kfp-lineage-task-name a { + color: var(--foreground); +} +.kfp-theme .kfp-lineage-port { + margin: 6px 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--muted-foreground); + font-size: 11px; +} +.kfp-theme .kfp-lineage-adjacent { + margin-bottom: 20px; +} +.kfp-theme .kfp-lineage-columns { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + column-gap: 48px; + align-items: start; +} +.kfp-theme .kfp-lineage-column-labels { + margin-bottom: 24px; + color: var(--muted-foreground); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} +.kfp-theme .kfp-lineage-column-labels [data-selected] { + color: var(--primary); +} +.kfp-theme .kfp-lineage-unknown { + grid-column: 1 / -1; + margin-top: 16px; +} +.kfp-theme .kfp-lineage-help { + margin: 0; + padding: 12px 0; + color: var(--muted-foreground); + font-size: 12px; +} +.kfp-theme .kfp-lineage-canvas { + overflow-x: auto; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--background); +} +.kfp-theme .kfp-lineage-canvas:focus-visible { + outline: 2px solid var(--primary); + outline-offset: -2px; +} +.kfp-theme .kfp-lineage-canvas-inner { + position: relative; + min-width: 1000px; + min-height: 420px; + padding: 24px 28px 40px; +} diff --git a/frontend/src/components/NativeArtifactLineage.tsx b/frontend/src/components/NativeArtifactLineage.tsx index ed096384fff..f1e3d51aad7 100644 --- a/frontend/src/components/NativeArtifactLineage.tsx +++ b/frontend/src/components/NativeArtifactLineage.tsx @@ -9,10 +9,10 @@ // limitations under the License. import * as React from 'react'; -import { Alert, Box, Button, ButtonBase, Typography } from '@mui/material'; -import ArrowBack from '@mui/icons-material/ArrowBack'; -import Refresh from '@mui/icons-material/Refresh'; -import RestartAlt from '@mui/icons-material/RestartAlt'; +import { ArrowLeft, RefreshCw, RotateCcw } from 'lucide-react'; +import { Button } from './ui/button'; +import { Alert } from './ui/alert'; +import './NativeArtifactLineage.css'; import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query'; import { Link } from 'react-router'; import { V2ArtifactTask } from 'src/apisv2/artifact'; @@ -25,19 +25,6 @@ import { RoutePageFactory } from 'src/components/Router'; import NativeLineageCanvas, { LineageEdge } from './NativeLineageCanvas'; const PAGE_SIZE = 5; -const controlStyle = { - border: 0, - background: 'transparent', - boxShadow: 'none', - textTransform: 'none', - fontSize: 12, -} as const; -const labelStyle = { - fontSize: 10, - fontWeight: 600, - letterSpacing: '.08em', - textTransform: 'uppercase', -} as const; function useArtifact(id: string, namespace: string) { return useQuery({ @@ -97,29 +84,30 @@ function RelationshipPages({ return ( <> {query.isPending && ( - +

Loading relationships… - +

)} {query.isError && ( - + Some relationships could not be loaded. - )} {children(rows)} {!query.isPending && !query.isError && !rows.length && ( - No recorded relationships. +

No recorded relationships.

)} {repeated && ( - The service repeated a page token. Refresh to try again. + The service repeated a page token. Refresh to try again. )} {query.hasNextPage && ( {query.isError && ( - +

Details unavailable.{' '} - - +

)} - +
); } @@ -244,90 +193,54 @@ function TaskBranch({ // First bounded page of visible tasks only: adjacent artifact nodes never recursively fetch. const [expanded, setExpanded] = React.useState(true); return ( - - - - +
+
+
{task.data?.type === PipelineTaskTaskType.ROOT ? 'Run boundary' : 'Task'} - - +
+
{runId ? ( {name} ) : ( - {name} + name )} - - - +
+
+

{producer ? 'Output' : 'Input'} · {relationship.key || '(unnamed port)'} - +

{task.isError && ( - +

Task details unavailable.{' '} - - +

)} - +
- +
{expanded && ( {(rows) => { @@ -339,7 +252,7 @@ function TaskBranch({ {adjacent.map((row) => { const artifactNode = `${nodeId}:artifact:${relationshipId(row)}`; return ( - +
{row.artifact_id ? ( <> ) : ( - Missing artifact identity +

Missing artifact identity

)} - +

{row.key || '(unnamed port)'} - - +

+
); })} {!adjacent.length && ( - +

No {producer ? 'inputs' : 'outputs'} loaded. - +

)} ); }}
)} - - +
+ ); } @@ -401,28 +304,16 @@ function HistoryItem({ }) { const artifact = useArtifact(id, namespace); return ( - - - {artifact.data?.name || id} - - + {artifact.data?.name || id} + ); } @@ -441,38 +332,21 @@ export default function NativeArtifactLineage({ if (id !== target) setHistory((previous) => [...previous, id]); }; return ( - - - +
+
+ +
- - +
+
- +
{[ 'Input artifacts', 'Producing tasks', @@ -536,29 +394,15 @@ export default function NativeArtifactLineage({ 'Consuming tasks', 'Output artifacts', ].map((label) => ( - +
{label} - +
))} - +
{(rows) => ( - - +
+
- +
{[true, false].map((producer) => ( - {rows .filter((row) => @@ -590,26 +433,26 @@ export default function NativeArtifactLineage({ targetNode={targetNode} /> ) : ( - Missing task identity +

Missing task identity

), )} -
+ ))} {rows.some( (row) => !isInputArtifactTaskType(row.type) && !isOutputArtifactTaskType(row.type), ) && ( - + Some relationships have an unspecified direction and are not drawn. )} - +
)}
- +

Select an artifact to follow its lineage. Task names open Run Details. Only the loaded neighborhood is shown; use Load more for additional relationships. - - +

+
); } diff --git a/frontend/src/components/NativeLineageCanvas.tsx b/frontend/src/components/NativeLineageCanvas.tsx index d9eb0074dfb..ca4112d112b 100644 --- a/frontend/src/components/NativeLineageCanvas.tsx +++ b/frontend/src/components/NativeLineageCanvas.tsx @@ -9,7 +9,7 @@ // limitations under the License. import * as React from 'react'; -import { Box } from '@mui/material'; +import './NativeArtifactLineage.css'; interface Edge { id: string; @@ -86,22 +86,8 @@ export default function NativeLineageCanvas({ children }: { children: React.Reac }, []); return ( - - +
+
- {children} - - +
{children}
+
+
); } diff --git a/frontend/src/components/PipelinesDialogV2.test.tsx b/frontend/src/components/PipelineSelectorDialog.test.tsx similarity index 52% rename from frontend/src/components/PipelinesDialogV2.test.tsx rename to frontend/src/components/PipelineSelectorDialog.test.tsx index 82e2e6babf9..c0805b8c8a7 100644 --- a/frontend/src/components/PipelinesDialogV2.test.tsx +++ b/frontend/src/components/PipelineSelectorDialog.test.tsx @@ -14,9 +14,12 @@ * limitations under the License. */ -import { render, screen } from '@testing-library/react'; +import { act, render as renderDOM, screen, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import userEvent from '@testing-library/user-event'; +import { ThemeProvider } from './shell/ThemeProvider'; import { SpyInstance } from 'vitest'; -import PipelinesDialogV2, { PipelinesDialogV2Props } from './PipelinesDialogV2'; +import PipelineSelectorDialog, { PipelineSelectorDialogProps } from './PipelineSelectorDialog'; import { PageProps } from 'src/pages/Page'; import { Apis, PipelineSortKeys } from 'src/lib/Apis'; import { V2Pipeline, V2ListPipelinesResponse } from 'src/apisv2/pipeline'; @@ -24,11 +27,14 @@ import { flushPromisesInAct } from 'src/TestUtils'; import { BuildInfoContext } from 'src/lib/BuildInfo'; import { NameWithTooltip } from 'src/components/CustomTableNameColumn'; -function generateProps(): PipelinesDialogV2Props { +function render(element: ReactNode) { + return renderDOM({element}); +} + +function generateProps(): PipelineSelectorDialogProps { return { ...generatePageProps(), open: true, - selectorDialog: '', onClose: vi.fn(), namespace: 'ns', pipelineSelectorColumns: [ @@ -74,6 +80,12 @@ describe('PipelinesDialog', () => { beforeEach(() => { vi.clearAllMocks(); + vi.stubGlobal('matchMedia', (media: string) => ({ + media, + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })); listPipelineSpy = vi .spyOn(Apis.pipelineServiceApiV2, 'listPipelines') .mockImplementation((...args) => { @@ -87,17 +99,18 @@ describe('PipelinesDialog', () => { afterEach(async () => { vi.resetAllMocks(); + vi.unstubAllGlobals(); }); it('it renders correctly in multi user mode', async () => { const tree = render( - + , ); await flushPromisesInAct(); - expect(listPipelineSpy).toHaveBeenCalledWith('ns', '', 10, 'created_at desc', ''); + expect(listPipelineSpy).toHaveBeenCalledWith('ns', '', 20, 'created_at desc', ''); // Verify the display names are shown instead of the names screen.getByText('old mock pipeline name'); screen.getByText('new mock pipeline name'); @@ -106,14 +119,69 @@ describe('PipelinesDialog', () => { it('it renders correctly in single user mode', async () => { const tree = render( - + , ); await flushPromisesInAct(); - expect(listPipelineSpy).toHaveBeenCalledWith(undefined, '', 10, 'created_at desc', ''); + expect(listPipelineSpy).toHaveBeenCalledWith(undefined, '', 20, 'created_at desc', ''); // Verify the display names are shown instead of the names screen.getByText('old mock pipeline name'); screen.getByText('new mock pipeline name'); }); + it('keeps the latest pipeline choice when detail reads finish out of order', async () => { + let finishOld!: (pipeline: V2Pipeline) => void; + vi.spyOn(Apis.pipelineServiceApiV2, 'getPipeline') + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishOld = resolve; + }), + ) + .mockResolvedValueOnce(newPipeline); + const props = generateProps(); + render(); + await userEvent.click( + await screen.findByRole('radio', { name: 'Select resource old mock pipeline name' }), + ); + await userEvent.click( + screen.getByRole('radio', { name: 'Select resource new mock pipeline name' }), + ); + const useButton = screen.getByRole('button', { name: 'Use this pipeline' }); + await waitFor(() => expect(useButton).toBeEnabled()); + await act(async () => { + finishOld(oldPipeline); + }); + await userEvent.click(useButton); + expect(props.onClose).toHaveBeenCalledWith(true, newPipeline); + }); + + it('invalidates pending detail reads when switching from private to shared pipelines', async () => { + let finish!: (pipeline: V2Pipeline) => void; + vi.spyOn(Apis.pipelineServiceApiV2, 'getPipeline').mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + render( + + + , + ); + await userEvent.click( + await screen.findByRole('radio', { name: 'Select resource old mock pipeline name' }), + ); + await userEvent.click(screen.getByRole('tab', { name: 'Shared' })); + await waitFor(() => + expect(listPipelineSpy).toHaveBeenLastCalledWith(undefined, '', 20, 'created_at desc', ''), + ); + await act(async () => { + finish(oldPipeline); + }); + expect(screen.getByRole('button', { name: 'Use this pipeline' })).toBeDisabled(); + expect( + screen.getByRole('radio', { name: 'Select resource old mock pipeline name' }), + ).not.toBeChecked(); + }); }); diff --git a/frontend/src/components/PipelinesDialogV2.tsx b/frontend/src/components/PipelineSelectorDialog.tsx similarity index 57% rename from frontend/src/components/PipelinesDialogV2.tsx rename to frontend/src/components/PipelineSelectorDialog.tsx index 5d75207ede6..d28b62643fb 100644 --- a/frontend/src/components/PipelinesDialogV2.tsx +++ b/frontend/src/components/PipelineSelectorDialog.tsx @@ -15,37 +15,49 @@ */ import * as React from 'react'; -import { classes } from 'typestyle'; -import { padding, commonCss } from 'src/Css'; import ResourceSelector from 'src/pages/ResourceSelector'; import { Apis, PipelineSortKeys } from 'src/lib/Apis'; import { Column } from './CustomTable'; import { V2Pipeline } from 'src/apisv2/pipeline'; import Buttons from 'src/lib/Buttons'; import { PageProps } from 'src/pages/Page'; -import MD2Tabs from 'src/atoms/MD2Tabs'; -import Toolbar, { ToolbarActionMap } from 'src/components/Toolbar'; -import { PipelineTabsHeaders, PipelineTabsTooltips } from 'src/pages/PrivateAndSharedPipelines'; +import { InspectionTabs } from './inspection/InspectionTabs'; +import type { ToolbarActionMap } from 'src/lib/PageChromeTypes'; +import { Toolbar } from './shell/PageChrome'; +import { PipelineTabsHeaders, PipelineTabsTooltips } from 'src/pages/PipelinesPage'; import { BuildInfoContext } from 'src/lib/BuildInfo'; import { convertPipelineToResource } from 'src/lib/ResourceConverter'; -import { Button, Dialog, DialogActions, DialogContent } from '@mui/material'; +import { Button } from './ui/button'; +import { ModalDialog } from './ui/dialog'; +import { errorToMessage } from 'src/lib/Utils'; enum NamespacedAndSharedTab { NAMESPACED = 0, SHARED = 1, } -export interface PipelinesDialogV2Props extends PageProps { +export interface PipelineSelectorDialogProps extends PageProps { open: boolean; - selectorDialog: string; onClose: (confirmed: boolean, selectedPipeline?: V2Pipeline) => void; namespace: string | undefined; // use context or make it optional? pipelineSelectorColumns: Column[]; toolbarActionMap?: ToolbarActionMap; } -const PipelinesDialogV2: React.FC = (props): React.JSX.Element | null => { +function PipelineSelectorDialog(props: PipelineSelectorDialogProps) { + return ; +} + +function PipelineSelectionSession(props: PipelineSelectorDialogProps) { + const selectionGeneration = React.useRef(0); + // External sync: invalidate pending selection reads when this namespace session unmounts. + React.useEffect( + () => () => { + selectionGeneration.current++; + }, + [], + ); const buildInfo = React.useContext(BuildInfoContext); const [view, setView] = React.useState(NamespacedAndSharedTab.NAMESPACED); const [unconfirmedSelectedPipeline, setUnconfirmedSelectedPipeline] = @@ -55,6 +67,7 @@ const PipelinesDialogV2: React.FC = (props): React.JSX.E return ( = (props): React.JSX.E emptyMessage='No pipelines found. Upload a pipeline and then try again.' initialSortColumn={PipelineSortKeys.CREATED_AT} selectionChanged={async (selectedId: string) => { - const selectedPipeline = await Apis.pipelineServiceApiV2.getPipeline(selectedId); - setUnconfirmedSelectedPipeline(selectedPipeline); + const generation = ++selectionGeneration.current; + setUnconfirmedSelectedPipeline(undefined); + try { + const selectedPipeline = await Apis.pipelineServiceApiV2.getPipeline(selectedId); + if (generation === selectionGeneration.current) + setUnconfirmedSelectedPipeline(selectedPipeline); + } catch (error) { + const message = await errorToMessage(error); + if (generation === selectionGeneration.current) + props.updateDialog({ + title: 'Unable to select pipeline', + content: message, + buttons: [{ text: 'Dismiss' }], + }); + } }} /> ); @@ -89,33 +115,38 @@ const PipelinesDialogV2: React.FC = (props): React.JSX.E function getTabs(): React.JSX.Element | null { if (!buildInfo?.apiServerMultiUser) { - return null; + return getPipelinesList(); } return ( - + ariaLabel='Pipeline scope' + > + {getPipelinesList()} + ); } function tabSwitched(newTab: NamespacedAndSharedTab): void { + selectionGeneration.current++; setUnconfirmedSelectedPipeline(undefined); setView(newTab); } function closeAndResetState(): void { + selectionGeneration.current++; props.onClose(false); setUnconfirmedSelectedPipeline(undefined); setView(NamespacedAndSharedTab.NAMESPACED); @@ -126,44 +157,42 @@ const PipelinesDialogV2: React.FC = (props): React.JSX.E if (props.toolbarActionMap) { actions = props.toolbarActionMap; } - return ; + return ; }; + if (!props.open) return null; + return ( - closeAndResetState()} - PaperProps={{ id: 'pipelineSelectorDialog' }} + id='pipelineSelectorDialog' + size='lg' + title='Choose a pipeline' + onClose={closeAndResetState} + actions={ + <> + + + + } > - - {getToolbar()} -
- {getTabs()} - - {view === NamespacedAndSharedTab.NAMESPACED && getPipelinesList()} - {view === NamespacedAndSharedTab.SHARED && getPipelinesList()} -
-
- - - - -
+ {getToolbar()} + {getTabs()} + ); -}; +} -export default PipelinesDialogV2; +export default PipelineSelectorDialog; diff --git a/frontend/src/components/PipelineSpecTabContent.test.tsx b/frontend/src/components/PipelineSpecTabContent.test.tsx index 1b3c554cfe9..1061b734584 100644 --- a/frontend/src/components/PipelineSpecTabContent.test.tsx +++ b/frontend/src/components/PipelineSpecTabContent.test.tsx @@ -29,38 +29,38 @@ vi.mock('./Editor', () => ({ })); describe('PipelineSpecTabContent', () => { - it('converts JSON template to YAML and passes to editor', () => { + it('converts JSON template to YAML and passes to editor', async () => { const jsonTemplate = JSON.stringify({ key: 'value', nested: { a: 1 } }); - const { getByTestId } = render(); - const editor = getByTestId('editor-mock'); + const { findByTestId } = render(); + const editor = await findByTestId('editor-mock'); expect(editor.textContent).toContain('key: value'); expect(editor.textContent).toContain('nested:'); expect(editor.textContent).toContain('a: 1'); }); - it('preserves YAML template format through round-trip', () => { + it('preserves YAML template format through round-trip', async () => { const yamlTemplate = 'key: value\nnested:\n a: 1\n'; - const { getByTestId } = render(); - const editor = getByTestId('editor-mock'); + const { findByTestId } = render(); + const editor = await findByTestId('editor-mock'); expect(editor.textContent).toContain('key: value'); expect(editor.textContent).toContain('nested:'); }); - it('renders an empty editor when no template is provided', () => { + it('renders an empty editor when no template is provided', async () => { // js-yaml 5 throws on input holding no document, so an absent template // would otherwise fail during render rather than showing an empty editor. - const { getByTestId } = render(); - expect(getByTestId('editor-mock').textContent).toBe(''); + const { findByTestId } = render(); + expect((await findByTestId('editor-mock')).textContent).toBe(''); }); - it('renders an empty editor for an empty template string', () => { - const { getByTestId } = render(); - expect(getByTestId('editor-mock').textContent).toBe(''); + it('renders an empty editor for an empty template string', async () => { + const { findByTestId } = render(); + expect((await findByTestId('editor-mock')).textContent).toBe(''); }); - it('passes yaml mode and readOnly to the editor', () => { - const { getByTestId } = render(); - const editor = getByTestId('editor-mock'); + it('passes yaml mode and readOnly to the editor', async () => { + const { findByTestId } = render(); + const editor = await findByTestId('editor-mock'); expect(editor).toHaveAttribute('data-mode', 'yaml'); expect(editor).toHaveAttribute('data-readonly', 'true'); }); diff --git a/frontend/src/components/PipelineSpecTabContent.tsx b/frontend/src/components/PipelineSpecTabContent.tsx index e4e5510b37c..586bd2e6cd7 100644 --- a/frontend/src/components/PipelineSpecTabContent.tsx +++ b/frontend/src/components/PipelineSpecTabContent.tsx @@ -14,10 +14,11 @@ * limitations under the License. */ +import { useMemo } from 'react'; import { dump } from 'js-yaml'; import { loadYaml } from 'src/lib/YamlLoad'; import { isSafari } from 'src/lib/Utils'; -import Editor from './Editor'; +import Editor from './LazyEditor'; interface PipelineSpecTabContentProps { templateString: string; @@ -26,9 +27,11 @@ interface PipelineSpecTabContentProps { const editorHeightWidth = isSafari() ? '640px' : '100%'; export function PipelineSpecTabContent(props: PipelineSpecTabContentProps) { + const value = useMemo(() => dump(loadYaml(props.templateString || '')), [props.templateString]); return ( {children}, + }); +} + +beforeEach(() => { + localStorage.clear(); + vi.stubGlobal('matchMedia', (media: string) => ({ + matches: false, + media, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })); +}); +afterEach(() => { + vi.unstubAllGlobals(); + localStorage.clear(); +}); + describe('PlotCard', () => { const config: ViewerConfig = { type: PlotType.CONFUSION_MATRIX, @@ -28,14 +51,13 @@ describe('PlotCard', () => { it('renders nothing when there are no configs', () => { const { container } = render(); - expect(container.firstChild).toBeNull(); + expect(container.querySelector('.kfp-plot-card')).toBeNull(); }); it('renders a confusion matrix plot card', () => { - const { asFragment } = render( - , - ); - expect(asFragment()).toMatchSnapshot(); + render(); + expect(screen.getByRole('heading', { name: 'test title' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Expand test title' })).toBeInTheDocument(); }); it('opens the fullscreen dialog', () => { @@ -52,15 +74,14 @@ describe('PlotCard', () => { await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); }); - it('closes the fullscreen dialog when the backdrop is clicked', async () => { - render(); - fireEvent.click(screen.getByTestId('pop-out-button')); - // MUI backdrop is third-party internal DOM — querySelector retained - const backdrop = document.querySelector('[class*="MuiBackdrop-root"]'); - if (!backdrop) { - throw new Error('Backdrop not found'); - } - fireEvent.click(backdrop); + it('closes the fullscreen dialog with Escape and restores focus to its trigger', async () => { + const user = userEvent.setup(); + render(); + const trigger = screen.getByRole('button', { name: 'Expand test title' }); + await user.click(trigger); + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + await user.keyboard('{Escape}'); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + await waitFor(() => expect(trigger).toHaveFocus()); }); }); diff --git a/frontend/src/components/PlotCard.tsx b/frontend/src/components/PlotCard.tsx index 8b7ae2f2fe7..23d91d0dd0e 100644 --- a/frontend/src/components/PlotCard.tsx +++ b/frontend/src/components/PlotCard.tsx @@ -15,68 +15,12 @@ */ import * as React from 'react'; -import CloseIcon from '@mui/icons-material/Close'; -import PopOutIcon from '@mui/icons-material/Launch'; -import Separator from '../atoms/Separator'; -import ViewerContainer, { componentMap } from '../components/viewers/ViewerContainer'; -import { ViewerConfig } from '../components/viewers/Viewer'; -import { color, fontsize } from '../Css'; -import { stylesheet, classes } from 'typestyle'; - -import { Button, Dialog, Paper, Tooltip } from '@mui/material'; - -const css = stylesheet({ - dialogTitle: { - color: color.strong, - fontSize: fontsize.large, - width: '100%', - }, - fullscreenCloseBtn: { - minHeight: 0, - minWidth: 0, - padding: 3, - }, - fullscreenDialog: { - alignItems: 'center', - justifyContent: 'center', - minHeight: '80%', - minWidth: '80%', - padding: 20, - }, - fullscreenViewerContainer: { - alignItems: 'center', - boxSizing: 'border-box', - display: 'flex', - flexFlow: 'column', - flexGrow: 1, - height: '100%', - justifyContent: 'center', - margin: 20, - overflow: 'auto', - width: '100%', - }, - plotCard: { - flexShrink: 0, - margin: 20, - minWidth: 250, - padding: 20, - width: 'auto', - }, - plotHeader: { - display: 'flex', - justifyContent: 'space-between', - overflow: 'hidden', - paddingBottom: 10, - }, - plotTitle: { - color: color.strong, - fontSize: 12, - fontWeight: 'bold', - }, - popoutIcon: { - fontSize: 18, - }, -}); +import { Maximize2, X } from 'lucide-react'; +import ViewerContainer, { componentMap } from './viewers/ViewerContainer'; +import { ViewerConfig } from './viewers/Viewer'; +import { Button } from './ui/button'; +import { ModalDialog } from './ui/dialog'; +import './viewers/ComparisonViewers.css'; export interface PlotCardProps { title: string; @@ -89,13 +33,7 @@ interface PlotCardState { } class PlotCard extends React.Component { - constructor(props: any) { - super(props); - - this.state = { - fullscreenDialogOpen: false, - }; - } + public state = { fullscreenDialogOpen: false }; public shouldComponentUpdate(nextProps: PlotCardProps, nextState: PlotCardState): boolean { return ( @@ -105,57 +43,55 @@ class PlotCard extends React.Component { } public render(): React.JSX.Element | null { - const { title, configs, maxDimension, ...otherProps } = this.props; - - if (!configs || !configs.length) { - return null; - } - + const { title, configs, maxDimension } = this.props; + if (!configs?.length) return null; return ( -
- -
-
- {title} -
-
+
+
+

{title}

+ +
+
+ +
+ {this.state.fullscreenDialogOpen && ( + + {componentMap[configs[0].type].prototype.getDisplayName()} + {title && <> · {title}} + + } + onClose={() => this.setState({ fullscreenDialogOpen: false })} + actions={ + } + > +
+
-
- - - - this.setState({ fullscreenDialogOpen: false })} - > -
- - {componentMap[configs[0].type].prototype.getDisplayName()} - - ({title}) -
-
- -
-
-
+ + )} + ); } } diff --git a/frontend/src/components/PodYaml.test.tsx b/frontend/src/components/PodYaml.test.tsx deleted file mode 100644 index 1aeec78b53b..00000000000 --- a/frontend/src/components/PodYaml.test.tsx +++ /dev/null @@ -1,184 +0,0 @@ -/** - * Copyright 2021 The Kubeflow Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { FC } from 'react'; -import { PodInfo, PodEvents } from './PodYaml'; -import { render, act, fireEvent } from '@testing-library/react'; -import { Apis } from 'src/lib/Apis'; -import TestUtils from 'src/TestUtils'; - -// Original ./Editor uses a complex external editor inside, we use a simple mock -// for testing instead. -vi.mock('./Editor', () => ({ - default: ({ value }: { value: string }) =>
{value}
, -})); - -afterEach(async () => { - vi.resetAllMocks(); - vi.restoreAllMocks(); -}); - -describe('PodInfo', () => { - let podInfoSpy: any; - beforeEach(() => { - podInfoSpy = vi.spyOn(Apis, 'getPodInfo'); - }); - - it('renders Editor with pod yaml', async () => { - podInfoSpy.mockImplementation(() => - Promise.resolve({ - kind: 'Pod', - metadata: { - name: 'test-pod', - }, - }), - ); - const { container } = render(); - // Renders nothing when loading - expect(container).toMatchInlineSnapshot(`
`); - - await act(TestUtils.flushPromises); - expect(container).toMatchInlineSnapshot(` -
-
-          kind: Pod
-      metadata:
-        name: test-pod
-
-        
-
- `); - }); - - it('renders pod yaml putting spec to the last section', async () => { - podInfoSpy.mockImplementation(() => - Promise.resolve({ - kind: 'Pod', - spec: { - property: 'value', - }, - status: { - property: 'value2', - }, - }), - ); - const { container } = render(); - await act(TestUtils.flushPromises); - expect(container).toMatchInlineSnapshot(` -
-
-          kind: Pod
-      status:
-        property: value2
-      spec:
-        property: value
-
-        
-
- `); - }); - - it('shows a warning banner when request fails', async () => { - podInfoSpy.mockImplementation(() => Promise.reject('Pod not found')); - const { getByText } = render(); - await act(TestUtils.flushPromises); - getByText( - 'Failed to retrieve pod info. Possible reasons include cluster autoscaling, pod preemption or pod cleaned up by time to live configuration', - ); - }); - - it('can be retried when request fails', async () => { - // Network was bad initially - podInfoSpy.mockImplementation(() => Promise.reject('Network failed')); - const { getByText } = render(); - await act(TestUtils.flushPromises); - - // Now network gets healthy - podInfoSpy.mockImplementation(() => - Promise.resolve({ - kind: 'Pod', - }), - ); - const refreshButton = getByText('Refresh'); - fireEvent.click(refreshButton); - await act(TestUtils.flushPromises); - getByText('kind: Pod'); - }); - - it('refreshes automatically when pod name or namespace changes', async () => { - // Now network gets healthy - podInfoSpy.mockImplementation(() => - Promise.resolve({ - metadata: { name: 'pod-1' }, - }), - ); - const { getByText, rerender } = render(); - expect(podInfoSpy).toHaveBeenLastCalledWith('test-pod-1', 'test-ns'); - await act(TestUtils.flushPromises); - getByText(/pod-1/); - - podInfoSpy.mockImplementation(() => - Promise.resolve({ - metadata: { name: 'pod-2' }, - }), - ); - rerender(); - expect(podInfoSpy).toHaveBeenLastCalledWith('test-pod-2', 'test-ns'); - await act(TestUtils.flushPromises); - getByText(/pod-2/); - }); -}); - -// PodEvents is very similar to PodInfo, so we only test different parts here. -describe('PodEvents', () => { - let podEventsSpy: any; - beforeEach(() => { - podEventsSpy = vi.spyOn(Apis, 'getPodEvents'); - }); - - it('renders Editor with pod events yaml', async () => { - podEventsSpy.mockImplementation(() => - Promise.resolve({ - kind: 'EventList', - }), - ); - const { container } = render(); - await act(TestUtils.flushPromises); - expect(container).toMatchInlineSnapshot(` -
-
-          kind: EventList
-
-        
-
- `); - }); - - it('shows a warning banner when request fails', async () => { - podEventsSpy.mockImplementation(() => Promise.reject('Pod not found')); - const { getByText } = render(); - await act(TestUtils.flushPromises); - getByText( - 'Failed to retrieve pod events. Possible reasons include cluster autoscaling, pod preemption or pod cleaned up by time to live configuration', - ); - }); -}); diff --git a/frontend/src/components/PodYaml.tsx b/frontend/src/components/PodYaml.tsx deleted file mode 100644 index 50c0917ac5b..00000000000 --- a/frontend/src/components/PodYaml.tsx +++ /dev/null @@ -1,141 +0,0 @@ -/** - * Copyright 2021 The Kubeflow Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import * as JsYaml from 'js-yaml'; -import React from 'react'; -import { Apis, JSONObject } from 'src/lib/Apis'; -import { errorToMessage, isServiceError, serviceErrorToString } from 'src/lib/Utils'; -import Banner from './Banner'; -import Editor from './Editor'; - -async function getPodYaml(name: string, namespace: string): Promise { - const response = await Apis.getPodInfo(name, namespace); - return JsYaml.dump(reorderPodJson(response), { skipInvalid: true }); -} -export const PodInfo: React.FC<{ name: string; namespace: string }> = ({ name, namespace }) => { - return ( - - ); -}; - -async function getPodEventsYaml(name: string, namespace: string): Promise { - const response = await Apis.getPodEvents(name, namespace); - return JsYaml.dump(response, { skipInvalid: true }); -} -export const PodEvents: React.FC<{ - name: string; - namespace: string; -}> = ({ name, namespace }) => { - return ( - - ); -}; - -const PodYaml: React.FC<{ - name: string; - namespace: string; - errorMessage: string; - getYaml: (name: string, namespace: string) => Promise; -}> = ({ name, namespace, getYaml, errorMessage }) => { - const [yaml, setYaml] = React.useState(undefined); - const [error, setError] = React.useState< - | { - message: string; - additionalInfo: string; - } - | undefined - >(undefined); - const [refreshes, setRefresh] = React.useState(0); - - React.useEffect(() => { - let aborted = false; - async function load() { - setYaml(undefined); - setError(undefined); - try { - const yaml = await getYaml(name, namespace); - if (!aborted) { - setYaml(yaml); - } - } catch (err) { - if (!aborted) { - const additionalInfo = isServiceError(err) - ? serviceErrorToString(err) - : (await errorToMessage(err)) || 'Unknown error'; - setError({ - message: errorMessage, - additionalInfo, - }); - } - } - } - load(); - return () => { - aborted = true; - }; - }, [name, namespace, refreshes, getYaml, errorMessage]); // When refreshes change, request is fetched again. - - return ( - <> - {error && ( - setRefresh((refreshes) => refreshes + 1)} - /> - )} - {!error && yaml && ( - - )} - - ); -}; - -function reorderPodJson(jsonData: JSONObject): JSONObject { - const orderedData = { ...jsonData }; - if (jsonData.spec) { - // Deleting spec property and add it again moves spec to the last property in the JSON object. - delete orderedData.spec; - orderedData.spec = jsonData.spec; - } - return orderedData; -} - -export const TEST_ONLY = { - PodYaml, -}; diff --git a/frontend/src/components/PrivateSharedSelector.test.tsx b/frontend/src/components/PrivateSharedSelector.test.tsx index ba46e758625..2c149b21955 100644 --- a/frontend/src/components/PrivateSharedSelector.test.tsx +++ b/frontend/src/components/PrivateSharedSelector.test.tsx @@ -17,7 +17,7 @@ import { render, screen, fireEvent } from '@testing-library/react'; import { BuildInfoContext } from '../lib/BuildInfo'; import PrivateSharedSelector, { PrivateSharedSelectorProps } from './PrivateSharedSelector'; -import { PipelineTabsHeaders } from '../pages/PrivateAndSharedPipelines'; +import { PipelineTabsHeaders } from '../pages/PipelinesPage'; function generateProps(): PrivateSharedSelectorProps { return { @@ -27,8 +27,12 @@ function generateProps(): PrivateSharedSelectorProps { describe('PrivateSharedSelector', () => { it('it renders correctly', async () => { - const tree = render(); - expect(tree).toMatchSnapshot(); + render(); + expect(screen.getByRole('radio', { name: 'Private' })).toBeChecked(); + expect(screen.getByRole('radio', { name: 'Shared' })).not.toBeChecked(); + expect( + screen.getByRole('group', { name: 'Select if the new pipeline will be private or shared.' }), + ).toBeVisible(); }); it('it changes checked input on click', async () => { @@ -50,4 +54,13 @@ describe('PrivateSharedSelector', () => { expect(sharedInput.checked).toBe(true); expect(props.onChange).toHaveBeenCalledWith(false); }); + it('preserves the controlled visibility choice when remounted', () => { + const onChange = vi.fn(); + const view = render(); + expect(screen.getByRole('radio', { name: 'Shared' })).toBeChecked(); + fireEvent.click(screen.getByRole('radio', { name: 'Private' })); + expect(onChange).toHaveBeenCalledWith(true); + view.rerender(); + expect(screen.getByRole('radio', { name: 'Shared' })).toBeChecked(); + }); }); diff --git a/frontend/src/components/PrivateSharedSelector.tsx b/frontend/src/components/PrivateSharedSelector.tsx index 54eb9398308..f5bc4532f58 100644 --- a/frontend/src/components/PrivateSharedSelector.tsx +++ b/frontend/src/components/PrivateSharedSelector.tsx @@ -14,57 +14,55 @@ * limitations under the License. */ -import * as React from 'react'; -import { classes } from 'typestyle'; -import { commonCss, padding } from '../Css'; -import { PipelineTabsHeaders } from '../pages/PrivateAndSharedPipelines'; - -import { Radio, Tooltip, FormControlLabel } from '@mui/material'; +import { useId, useState } from 'react'; +import { PipelineTabsHeaders } from '../pages/PipelinesPage'; +import './pipelines/PipelineForms.css'; export interface PrivateSharedSelectorProps { onChange: (isPrivate: boolean) => void; + value?: boolean; } - export enum PipelineButtonTooltips { PRIVATE = 'Only people who have access to this namespace will be able to view and use this pipeline.', SHARED = 'Everyone in your organization will be able to view and use this pipeline.', } -const PrivateSharedSelector: React.FC = ( - props, -): React.JSX.Element | null => { - const [namespacedPipeline, setNamespacedPipeline] = React.useState(true); - - const updateSelection = (isPrivate: boolean) => { - setNamespacedPipeline(isPrivate); - props.onChange(isPrivate); +export default function PrivateSharedSelector({ onChange, value }: PrivateSharedSelectorProps) { + const id = useId(); + const [localValue, setLocalValue] = useState(true); + const isPrivate = value ?? localValue; + const select = (next: boolean) => { + if (value === undefined) setLocalValue(next); + onChange(next); }; - return ( - -
Select if the new pipeline will be private or shared.
-
- - } - onChange={() => updateSelection(true)} - /> - - - } - onChange={() => updateSelection(false)} - /> - -
-
+
+ Select if the new pipeline will be private or shared. + + +

+ {isPrivate ? PipelineButtonTooltips.PRIVATE : PipelineButtonTooltips.SHARED} +

+
); -}; - -export default PrivateSharedSelector; +} diff --git a/frontend/src/components/ReduceGraphSwitch.test.tsx b/frontend/src/components/ReduceGraphSwitch.test.tsx deleted file mode 100644 index 25be455b395..00000000000 --- a/frontend/src/components/ReduceGraphSwitch.test.tsx +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2026 The Kubeflow Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { render, screen, fireEvent } from '@testing-library/react'; -import { vi } from 'vitest'; -import ReduceGraphSwitch from './ReduceGraphSwitch'; - -describe('ReduceGraphSwitch', () => { - it('renders the switch with label', () => { - render(); - expect(screen.getByText('Simplify Graph')).toBeInTheDocument(); - }); - - it('renders the switch in unchecked state by default', () => { - render(); - const switchInput = screen.getByRole('checkbox'); - expect(switchInput).not.toBeChecked(); - }); - - it('renders the switch in checked state when checked prop is true', () => { - render(); - const switchInput = screen.getByRole('checkbox'); - expect(switchInput).toBeChecked(); - }); - - it('calls onChange when the switch is toggled', () => { - const handleChange = vi.fn(); - render(); - const switchInput = screen.getByRole('checkbox'); - fireEvent.click(switchInput); - expect(handleChange).toHaveBeenCalled(); - }); - - it('renders tooltip with transitive reduction help text on hover', async () => { - render(); - const label = screen.getByText('Simplify Graph'); - fireEvent.mouseOver(label); - const tooltipText = await screen.findByText(/transitive reduction/i); - expect(tooltipText).toBeInTheDocument(); - }); -}); diff --git a/frontend/src/components/ReduceGraphSwitch.tsx b/frontend/src/components/ReduceGraphSwitch.tsx deleted file mode 100644 index b2fb258c3e9..00000000000 --- a/frontend/src/components/ReduceGraphSwitch.tsx +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2020 Arrikto Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { SwitchProps } from '@mui/material/Switch'; -import { stylesheet } from 'typestyle'; -import { ExternalLink } from '../atoms/ExternalLink'; -import { CardTooltip } from '../atoms/CardTooltip'; - -import { Switch, FormGroup, FormControlLabel } from '@mui/material'; - -const css = stylesheet({ - reduceGraphPosition: { - position: 'absolute', - top: 0, - left: 0, - }, -}); - -interface ReduceGraphSwitchProps extends SwitchProps {} - -const ReduceGraphSwitch = (props: ReduceGraphSwitchProps) => { - return ( -
- -

- Enables a{' '} - - transitive reduction - {' '} - of the pipeline graph, hiding all the redundant edges. This option is just a - visualization helper and does not have any permanent effect on the pipeline itself. -

-

- Note that edges are removed regardless of their function in the pipeline, so the - reduced form might not provide enough details to understand how the pipeline really - behaves. -

-
- } - > - - } - label={'Simplify Graph'} - labelPlacement='end' - /> - - -
- ); -}; - -export default ReduceGraphSwitch; diff --git a/frontend/src/components/Router.test.tsx b/frontend/src/components/Router.test.tsx index bdcf5e9fd94..56b04e571a3 100644 --- a/frontend/src/components/Router.test.tsx +++ b/frontend/src/components/Router.test.tsx @@ -14,7 +14,8 @@ * limitations under the License. */ -import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { createMemoryRouter, HashRouter, @@ -24,21 +25,56 @@ import { } from 'react-router'; import { NavigationProps } from 'src/lib/Navigation'; import { NavigationErrorBoundary } from 'src/atoms/NavigationErrorBoundary'; -import { useEffect, useState } from 'react'; +import { lazy, useEffect, useState } from 'react'; import Router, { getSafeReturnPath, RouteConfig, RoutePage, RoutePageFactory } from './Router'; -import { Page } from '../pages/Page'; -import { ToolbarProps } from './Toolbar'; +import { Page, PageProps } from '../pages/Page'; +import { ToolbarProps } from 'src/lib/PageChromeTypes'; +import { BuildInfoContext } from 'src/lib/BuildInfo'; +import { Deployments, KFP_FLAGS } from 'src/lib/Flags'; -vi.mock('src/pages/RunDetailsRouter', () => ({ +vi.mock('src/pages/RunDetailsPage', () => ({ default: () =>
Run details
, })); -vi.mock('src/pages/AllRunsAndArchive', () => ({ +vi.mock('src/pages/RunsPage', () => ({ default: () =>
Runs page
, - AllRunsAndArchiveTab: { RUNS: 0, ARCHIVE: 1 }, + RunsPageTab: { RUNS: 0, ARCHIVE: 1 }, +})); + +vi.mock('src/pages/CreateRunPage', () => ({ + default: ({ location }: NavigationProps) =>
New run query: {location.search}
, +})); + +vi.mock('src/pages/CreateExperimentPage', () => ({ + default: () => ( + + ), })); describe('Router', () => { + const originalFlags = { ...KFP_FLAGS }; + + beforeEach(() => { + localStorage.clear(); + Object.assign(KFP_FLAGS, originalFlags); + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }); + vi.stubGlobal('matchMedia', (query: string) => ({ + matches: false, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })); + }); + + afterEach(() => { + Object.assign(KFP_FLAGS, originalFlags); + vi.unstubAllGlobals(); + localStorage.clear(); + }); + it('initial render', () => { const renderResult = render( @@ -46,26 +82,60 @@ describe('Router', () => { , ); expect(screen.getByText('404')).toBeVisible(); - expect(screen.getByText('Page Not Found: /does-not-exist')).toBeVisible(); + expect(screen.getByRole('heading', { name: 'Page not found' })).toBeVisible(); + expect(screen.getByRole('link', { name: 'Pipelines' })).toHaveAttribute('id', 'pipelinesBtn'); + expect(screen.getByRole('link', { name: 'Runs' })).toHaveAttribute('id', 'runsBtn'); + expect(screen.getByText('/does-not-exist', { selector: 'code' })).toBeVisible(); expect(renderResult.asFragment()).toMatchSnapshot(); }); + it('opens deferred creation and transfer routes without losing shell or query state', async () => { + const router = createMemoryRouter([{ path: '*', element: }], { + initialEntries: ['/runs'], + }); + render(); + const runsLink = screen.getByRole('link', { name: 'Runs', exact: true }); + await act(() => router.navigate('/runs/new?pipelineId=pipeline-1&experimentId=experiment-1')); + expect( + await screen.findByText('New run query: ?pipelineId=pipeline-1&experimentId=experiment-1'), + ).toBeVisible(); + expect(screen.getByRole('link', { name: 'Runs', exact: true })).toBe(runsLink); + await act(() => router.navigate(RoutePage.NEW_EXPERIMENT)); + expect(await screen.findByLabelText('Experiment name')).toBeVisible(); + await act(() => router.navigate(RoutePage.METADATA_TRANSFER)); + expect(await screen.findByText('Export / import', { selector: 'h1' })).toBeVisible(); + await act(() => router.navigate('/runs')); + expect(screen.getByText('Runs page')).toBeVisible(); + expect(screen.getByRole('link', { name: 'Runs', exact: true })).toBe(runsLink); + }); + it('updates the not-found pathname and recovers when navigating to a known route', async () => { const router = createMemoryRouter([{ path: '*', element: }], { initialEntries: ['/does-not-exist?view=graph'], }); render(); - expect(screen.getByText('Page Not Found: /does-not-exist')).toBeVisible(); + expect(screen.getByText('/does-not-exist', { selector: 'code' })).toBeVisible(); await act(() => router.navigate('/another-missing-page')); - expect(screen.getByText('Page Not Found: /another-missing-page')).toBeVisible(); - expect(screen.queryByText('Page Not Found: /does-not-exist')).not.toBeInTheDocument(); + expect(screen.getByText('/another-missing-page', { selector: 'code' })).toBeVisible(); + expect(screen.queryByText('/does-not-exist', { selector: 'code' })).not.toBeInTheDocument(); await act(() => router.navigate('/runs')); expect(screen.getByText('Runs page')).toBeVisible(); expect(screen.queryByText('404')).not.toBeInTheDocument(); }); + it('does not create an empty breadcrumb before a page supplies navigation metadata', () => { + render( + +

Choose a pipeline

}]} /> +
, + ); + expect(screen.getByText('Choose a pipeline')).toBeVisible(); + expect(screen.queryByRole('navigation', { name: 'Breadcrumbs' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Back', exact: true })).not.toBeInTheDocument(); + }); + it('does not share state between pages', async () => { class ApplePage extends Page<{}, {}> { public getInitialToolbarState(): ToolbarProps { @@ -100,7 +170,8 @@ describe('Router', () => { act(() => { router.navigate('/pear'); }); - await waitFor(() => expect(screen.getByTestId('page-title')).toHaveTextContent('')); + await waitFor(() => expect(screen.queryByTestId('page-title')).not.toBeInTheDocument()); + expect(screen.getByText('pear')).toBeVisible(); }); it('preserves Run Details state when only the query changes', async () => { @@ -161,6 +232,15 @@ describe('Router', () => { ); }); + it('encodes raw run IDs exactly once for direct and task links', () => { + expect(RoutePageFactory.runDetails('run/one%2F space')).toBe( + '/runs/details/run%2Fone%252F%20space', + ); + expect(RoutePageFactory.runDetailsTask('run/one%2F space', 'task/iteration 1')).toBe( + '/runs/details/run%2Fone%252F%20space?task=task%2Fiteration+1', + ); + }); + it('redirects legacy run execution links to canonical run details', async () => { const router = createMemoryRouter([{ path: '*', element: }], { initialEntries: ['/runs/details/run-1/execution/123?view=graph#node'], @@ -335,6 +415,47 @@ describe('Router', () => { expect(screen.getByRole('textbox', { name: 'Name' })).toHaveValue('second'); }); + it('keeps navigation available while a route loads and recovers from a rejected chunk', async () => { + let rejectChunk!: (reason: Error) => void; + const DeferredPage = lazy( + () => + new Promise<{ default: () => React.JSX.Element }>((_, reject) => { + rejectChunk = reject; + }), + ); + const router = createMemoryRouter( + [ + { + path: '*', + element: ( + +

Ready page

}, + ]} + /> +
+ ), + }, + ], + { initialEntries: ['/deferred'] }, + ); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + render(); + expect(screen.getByRole('status')).toHaveTextContent('Loading page…'); + expect(screen.getByRole('link', { name: 'Runs', exact: true })).toBeVisible(); + await act(async () => rejectChunk(new Error('Chunk unavailable'))); + expect(await screen.findByText('Something went wrong.')).toBeVisible(); + await act(() => router.navigate('/ready')); + expect(screen.getByText('Ready page')).toBeVisible(); + expect(screen.queryByText('Something went wrong.')).not.toBeInTheDocument(); + } finally { + consoleSpy.mockRestore(); + } + }); + it('recovers a captured render error after navigation', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const ErrorPage = () => { @@ -386,4 +507,199 @@ describe('Router', () => { expect(router.state.location.hash).toBe('#node'); expect(router.state.location.state).toEqual({ from: 'runs' }); }); + + it.each([ + ['/pipelines/details/id/version/version', 'Pipelines'], + ['/shared/pipelines', 'Pipelines'], + ['/experiments/details/id', 'Experiments'], + ['/archive/experiments', 'Experiments'], + ['/runs/new', 'Runs'], + ['/runs/details/id', 'Runs'], + ['/compare', 'Runs'], + ['/archive/runs', 'Runs'], + ['/recurringruns', 'Recurring runs'], + ['/recurringrun/details/id', 'Recurring runs'], + ['/artifacts/123', 'Artifacts'], + ])('keeps the %s destination in the %s navigation group', (path, label) => { + render( + + +
Route content
}]} /> +
+
, + ); + expect(screen.getByRole('link', { name: label })).toHaveAttribute('aria-current', 'page'); + expect(screen.getByText('Route content')).toBeInTheDocument(); + }); + + it.each([ + [undefined, RoutePage.PIPELINES, 'Pipelines landing'], + [Deployments.MARKETPLACE, RoutePage.START, 'Getting started landing'], + ])('retains the default route for deployment %s', async (deployment, expectedPath, title) => { + KFP_FLAGS.DEPLOYMENT = deployment; + const router = createMemoryRouter( + [ + { + path: '*', + element: ( +
Pipelines landing
}, + { path: RoutePage.START, Component: () =>
Getting started landing
}, + ]} + /> + ), + }, + ], + { initialEntries: ['/'] }, + ); + render(); + expect(await screen.findByText(title)).toBeVisible(); + expect(router.state.location.pathname).toBe(expectedPath); + if (deployment === Deployments.MARKETPLACE) { + expect(screen.getByRole('link', { name: 'Getting started' })).toHaveAttribute( + 'href', + RoutePage.START, + ); + } else { + expect(screen.queryByRole('link', { name: 'Getting started' })).not.toBeInTheDocument(); + } + }); + + it('keeps shared pipelines gated by the existing multi-user metadata', () => { + const configs = [ + { path: RoutePage.PIPELINES_SHARED, Component: () =>
Shared pipelines page
}, + ]; + const shell = (multiUser: boolean) => ( + + + + + + ); + const view = render(shell(false)); + expect(screen.queryByText('Shared pipelines page')).not.toBeInTheDocument(); + expect(screen.getByText('404')).toBeVisible(); + expect(screen.getByRole('heading', { name: 'Page not found' })).toBeVisible(); + view.rerender(shell(true)); + expect(screen.getByText('Shared pipelines page')).toBeVisible(); + }); + + it('preserves shell preferences and page chrome through resource and secondary routes', async () => { + const router = createMemoryRouter( + [ + { + path: '*', + element: ( +
Modern runs content
}, + { path: RoutePage.RUN_DETAILS, Component: () =>
Modern run details
}, + { path: RoutePage.PIPELINES, Component: () =>
Modern pipelines content
}, + { + path: RoutePage.START, + Component: () =>
Modern getting started content
, + }, + ]} + /> + ), + }, + ], + { initialEntries: [RoutePage.RUNS] }, + ); + render(); + await userEvent.click(screen.getByRole('switch', { name: 'Dark mode' })); + fireEvent.click(screen.getByRole('button', { name: 'Collapse navigation' })); + expect(screen.getByText('Modern runs content').closest('.kfp-modern-page')).not.toBeNull(); + await act(() => router.navigate('/runs/details/run-1?task=task-1')); + expect(screen.getByText('Modern run details').closest('.kfp-modern-page')).not.toBeNull(); + expect(router.state.location.search).toBe('?task=task-1'); + await act(() => router.navigate(RoutePage.PIPELINES)); + expect(screen.getByText('Modern pipelines content').closest('.kfp-modern-page')).not.toBeNull(); + await act(() => router.navigate(RoutePage.START)); + expect( + screen.getByText('Modern getting started content').closest('.kfp-modern-page'), + ).not.toBeNull(); + expect(screen.getByRole('button', { name: 'Theme: Dark' })).toBeVisible(); + expect(screen.getByRole('complementary')).toHaveAttribute('data-collapsed', 'true'); + }); + + it.each([RoutePage.RUNS, RoutePage.RUN_DETAILS])( + 'keeps confirmation actions and close callbacks under the owner of %s', + async (path) => { + const action = vi.fn(); + const onClose = vi.fn(); + const PromptPage = ({ updateDialog }: PageProps) => ( + + ); + render( + + + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Open confirmation' })); + fireEvent.click(await screen.findByRole('button', { name: 'Confirm action' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(action).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }, + ); + + it('keeps embedded Runs content and theme selection when the host hides the rail', () => { + KFP_FLAGS.DEPLOYMENT = Deployments.KUBEFLOW; + KFP_FLAGS.HIDE_SIDENAV = true; + render( + +
Embedded runs content
}]} + /> +
, + ); + expect(screen.queryByRole('complementary')).not.toBeInTheDocument(); + expect(screen.getByText('Embedded runs content')).toBeVisible(); + expect(screen.getByRole('button', { name: 'Workspace', exact: true })).toBeInTheDocument(); + expect(screen.getByRole('combobox', { name: 'Theme' })).toBeInTheDocument(); + }); +}); + +it('encodes pipeline and experiment identifiers in shared detail routes', () => { + expect(RoutePageFactory.pipelineDetails('pipeline/one%2F space')).toBe( + '/pipelines/details/pipeline%2Fone%252F%20space', + ); + expect(RoutePageFactory.experimentDetails('experiment/one%2F space')).toBe( + '/experiments/details/experiment%2Fone%252F%20space', + ); +}); + +describe('artifact route IDs', () => { + it.each([ + [42, '/artifacts/42'], + ['folder/item', '/artifacts/folder%2Fitem'], + ['100% ready?#', '/artifacts/100%25%20ready%3F%23'], + ['literal%2Ftext', '/artifacts/literal%252Ftext'], + ])('encodes the raw artifact ID %s once', (id, expected) => { + expect(RoutePageFactory.artifactDetails(id)).toBe(expected); + }); +}); + +it('encodes recurring-run and version identifiers without route-template query markers', () => { + expect(RoutePageFactory.recurringRunDetails('schedule/100%')).toBe( + '/recurringrun/details/schedule%2F100%25', + ); + expect(RoutePageFactory.pipelineVersionDetails('pipeline/100%', 'version/100%')).toBe( + '/pipelines/details/pipeline%2F100%25/version/version%2F100%25', + ); }); diff --git a/frontend/src/components/Router.tsx b/frontend/src/components/Router.tsx index 5bbd2fc08b9..61d9cdf0ba1 100644 --- a/frontend/src/components/Router.tsx +++ b/frontend/src/components/Router.tsx @@ -14,49 +14,46 @@ * limitations under the License. */ -import { SnackbarProps } from '@mui/material/Snackbar'; +import type { SnackbarProps, BannerProps, ToolbarProps } from 'src/lib/PageChromeTypes'; import * as React from 'react'; import { Navigate, Route, Routes, useLocation, useNavigate, matchPath } from 'react-router'; import { NavigationProps } from 'src/lib/Navigation'; -import MetadataTransferPage from 'src/pages/MetadataTransfer'; import Page404 from 'src/pages/404'; -import Compare from 'src/pages/Compare'; +import CompareRunsPage from 'src/pages/CompareRunsPage'; import FrontendFeatures from 'src/pages/FrontendFeatures'; -import RunDetailsRouter from 'src/pages/RunDetailsRouter'; -import { classes, stylesheet } from 'typestyle'; -import Banner, { BannerProps } from 'src/components/Banner'; -import { commonCss } from 'src/Css'; +import RunDetailsPage from 'src/pages/RunDetailsPage'; import { Deployments, KFP_FLAGS } from 'src/lib/Flags'; -import AllExperimentsAndArchive, { - AllExperimentsAndArchiveTab, -} from 'src/pages/AllExperimentsAndArchive'; -import AllRecurringRunsPage from 'src/pages/AllRecurringRunsList'; -import AllRunsAndArchive, { AllRunsAndArchiveTab } from 'src/pages/AllRunsAndArchive'; +import ExperimentsPage, { ExperimentsPageTab } from 'src/pages/ExperimentsPage'; +import RecurringRunsPage from 'src/pages/RecurringRunsPage'; +import RunsPage, { RunsPageTab } from 'src/pages/RunsPage'; import ArtifactDetails from 'src/pages/ArtifactDetails'; -import EnhancedArtifactList from 'src/pages/ArtifactList'; +import ArtifactListWithContext from 'src/pages/ArtifactList'; import ExperimentDetailsPage from 'src/pages/ExperimentDetails'; import { GettingStarted } from 'src/pages/GettingStarted'; -import NewExperimentPage from 'src/pages/NewExperiment'; -import NewPipelineVersionPage from 'src/pages/NewPipelineVersion'; -import NewRunSwitcher from 'src/pages/NewRunSwitcher'; -import PipelineDetails from 'src/pages/PipelineDetails'; -import PrivateAndSharedPipelines, { - PrivateAndSharedTab, -} from 'src/pages/PrivateAndSharedPipelines'; -import RecurringRunDetailsRouter from 'src/pages/RecurringRunDetailsRouter'; -import SideNavigation from './SideNav'; -import Toolbar, { ToolbarProps } from './Toolbar'; +import PipelineDetailsPage from 'src/pages/PipelineDetailsPage'; +import PipelinesPage, { PrivateAndSharedTab } from 'src/pages/PipelinesPage'; +import RecurringRunDetailsPage from 'src/pages/RecurringRunDetailsPage'; +import { + BookOpen, + FlaskConical, + Info, + Package, + PlayCircle, + Repeat, + Workflow, + X, +} from 'lucide-react'; +import { ApplicationShell } from './shell/ApplicationShell'; +import type { AppShellNavItem } from './shell/AppShell'; +import { PageChrome } from './shell/PageChrome'; +import { Button } from './ui/button'; import { BuildInfoContext } from 'src/lib/BuildInfo'; -import { - Alert, - Button, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - Snackbar, -} from '@mui/material'; +// Creation and transfer forms are loaded only when their routes are opened. +const MetadataTransferPage = React.lazy(() => import('src/pages/MetadataTransfer')); +const CreateRunPage = React.lazy(() => import('src/pages/CreateRunPage')); +const CreateExperimentPage = React.lazy(() => import('src/pages/CreateExperimentPage')); +const UploadPipelinePage = React.lazy(() => import('src/pages/UploadPipelinePage')); export type RouteConfig = { path: string; @@ -65,12 +62,6 @@ export type RouteConfig = { notExact?: boolean; }; -const css = stylesheet({ - dialog: { - minWidth: 250, - }, -}); - export enum QUERY_PARAMS { cloneFromRun = 'cloneFromRun', cloneFromRecurringRun = 'cloneFromRecurringRun', @@ -135,17 +126,38 @@ export const RoutePage = { export const RoutePageFactory = { artifactDetails: (artifactId: string | number) => { - return RoutePage.ARTIFACT_DETAILS.replace(`:${RouteParams.ID}`, '' + artifactId); + return RoutePage.ARTIFACT_DETAILS.replace(`:${RouteParams.ID}`, encodeURIComponent(artifactId)); }, runDetails: (runId: string) => { - return RoutePage.RUN_DETAILS.replace(`:${RouteParams.runId}`, runId); + return RoutePage.RUN_DETAILS.replace(`:${RouteParams.runId}`, encodeURIComponent(runId)); }, runDetailsTask: (runId: string, taskId: string) => { const search = new URLSearchParams({ [QUERY_PARAMS.taskId]: taskId }); return `${RoutePageFactory.runDetails(runId)}?${search.toString()}`; }, + experimentDetails: (id: string) => { + return RoutePage.EXPERIMENT_DETAILS.replace( + `:${RouteParams.experimentId}`, + encodeURIComponent(id), + ); + }, + recurringRunDetails: (id: string) => { + return RoutePage.RECURRING_RUN_DETAILS.replace( + `:${RouteParams.recurringRunId}`, + encodeURIComponent(id), + ); + }, + pipelineVersionDetails: (id: string, versionId: string) => { + return RoutePage.PIPELINE_DETAILS.replace( + `:${RouteParams.pipelineId}`, + encodeURIComponent(id), + ).replace(`:${RouteParams.pipelineVersionId}?`, encodeURIComponent(versionId)); + }, pipelineDetails: (id: string) => { - return RoutePage.PIPELINE_DETAILS_NO_VERSION.replace(`:${RouteParams.pipelineId}`, id); + return RoutePage.PIPELINE_DETAILS_NO_VERSION.replace( + `:${RouteParams.pipelineId}?`, + encodeURIComponent(id), + ); }, }; @@ -182,9 +194,6 @@ export interface RouterProps { configs?: RouteConfig[]; // only used in tests } -const DEFAULT_ROUTE = - KFP_FLAGS.DEPLOYMENT === Deployments.MARKETPLACE ? RoutePage.START : RoutePage.PIPELINES; - const RemovedExecutionRoute = ({ params }: NavigationProps) => { return ( { { const query = new URLSearchParams(location.search); const reason = query.get(QUERY_PARAMS.executionRedirect); if (reason !== 'list' && reason !== 'detail') return null; - return ( - { - query.delete(QUERY_PARAMS.executionRedirect); - navigate( - { ...location, search: query.size ? `?${query}` : '' }, - { replace: true, state: location.state }, - ); - }} - > + const closeNotice = () => { + query.delete(QUERY_PARAMS.executionRedirect); + navigate( + { ...location, search: query.size ? `?${query}` : '' }, + { replace: true, state: location.state }, + ); + }; + const message = ( + <> Execution pages have moved to Runs and task details. Open a run and select a task to view its inputs, outputs, status, and logs. {reason === 'detail' && ' This legacy execution link cannot select the corresponding task automatically.'} - + + ); + return ( +
+
); }; // This component is made as a wrapper to separate toolbar state for different pages. const Router: React.FC = ({ configs }) => { const buildInfo = React.useContext(BuildInfoContext); + const defaultRoute = + KFP_FLAGS.DEPLOYMENT === Deployments.MARKETPLACE ? RoutePage.START : RoutePage.PIPELINES; let routes: RouteConfig[] = configs || [ { path: RoutePage.START, Component: GettingStarted }, { - Component: AllRunsAndArchive, + Component: RunsPage, path: RoutePage.ARCHIVED_RUNS, - view: AllRunsAndArchiveTab.ARCHIVE, + view: RunsPageTab.ARCHIVE, }, { - Component: AllExperimentsAndArchive, + Component: ExperimentsPage, path: RoutePage.ARCHIVED_EXPERIMENTS, - view: AllExperimentsAndArchiveTab.ARCHIVE, + view: ExperimentsPageTab.ARCHIVE, }, - { path: RoutePage.ARTIFACTS, Component: EnhancedArtifactList }, + { path: RoutePage.ARTIFACTS, Component: ArtifactListWithContext }, { path: RoutePage.ARTIFACT_DETAILS, Component: ArtifactDetails, notExact: true }, { path: RoutePage.EXECUTIONS, Component: RemovedExecutionRoute }, { path: RoutePage.EXECUTION_DETAILS, Component: RemovedExecutionRoute }, { - Component: AllExperimentsAndArchive, + Component: ExperimentsPage, path: RoutePage.EXPERIMENTS, - view: AllExperimentsAndArchiveTab.EXPERIMENTS, + view: ExperimentsPageTab.EXPERIMENTS, }, { path: RoutePage.EXPERIMENT_DETAILS, Component: ExperimentDetailsPage }, - { path: RoutePage.NEW_EXPERIMENT, Component: NewExperimentPage }, - { path: RoutePage.NEW_PIPELINE_VERSION, Component: NewPipelineVersionPage }, - { path: RoutePage.NEW_RUN, Component: NewRunSwitcher }, + { path: RoutePage.NEW_EXPERIMENT, Component: CreateExperimentPage }, + { path: RoutePage.NEW_PIPELINE_VERSION, Component: UploadPipelinePage }, + { path: RoutePage.NEW_RUN, Component: CreateRunPage }, { path: RoutePage.PIPELINES, - Component: PrivateAndSharedPipelines, + Component: PipelinesPage, view: PrivateAndSharedTab.PRIVATE, }, { path: RoutePage.PIPELINES_SHARED, - Component: PrivateAndSharedPipelines, + Component: PipelinesPage, view: PrivateAndSharedTab.SHARED, }, - { path: RoutePage.PIPELINE_DETAILS, Component: PipelineDetails }, - { path: RoutePage.PIPELINE_DETAILS_NO_VERSION, Component: PipelineDetails }, - { path: RoutePage.RUNS, Component: AllRunsAndArchive, view: AllRunsAndArchiveTab.RUNS }, - { path: RoutePage.RECURRING_RUNS, Component: AllRecurringRunsPage }, - { path: RoutePage.RECURRING_RUN_DETAILS, Component: RecurringRunDetailsRouter }, - { path: RoutePage.RUN_DETAILS, Component: RunDetailsRouter }, + { path: RoutePage.PIPELINE_DETAILS, Component: PipelineDetailsPage }, + { path: RoutePage.PIPELINE_DETAILS_NO_VERSION, Component: PipelineDetailsPage }, + { path: RoutePage.RUNS, Component: RunsPage, view: RunsPageTab.RUNS }, + { path: RoutePage.RECURRING_RUNS, Component: RecurringRunsPage }, + { path: RoutePage.RECURRING_RUN_DETAILS, Component: RecurringRunDetailsPage }, + { path: RoutePage.RUN_DETAILS, Component: RunDetailsPage }, { path: RoutePage.RUN_DETAILS_WITH_EXECUTION, Component: LegacyRunExecutionRoute }, - { path: RoutePage.COMPARE, Component: Compare }, + { path: RoutePage.COMPARE, Component: CompareRunsPage }, { path: RoutePage.FRONTEND_FEATURES, Component: FrontendFeatures }, { path: RoutePage.METADATA_TRANSFER, Component: MetadataTransferPage }, ]; @@ -291,10 +309,10 @@ const Router: React.FC = ({ configs }) => { } return ( - // There will be only one instance of SideNav, throughout UI usage. - + // Keep the shell mounted across route changes. + - } /> + } /> {routes.map((route) => ( = ({ configs }) => { ))} } /> - + ); }; @@ -345,7 +363,7 @@ class RoutedPage extends React.Component< > { private childProps = { toolbarProps: { - breadcrumbs: [{ displayName: '', href: '' }], + breadcrumbs: [], actions: {}, pageTitle: '', } as ToolbarProps, @@ -362,7 +380,7 @@ class RoutedPage extends React.Component< bannerProps: {}, dialogProps: { open: false }, snackbarProps: { autoHideDuration: 5000, open: false }, - toolbarProps: { breadcrumbs: [{ displayName: '', href: '' }], actions: [], ...props }, + toolbarProps: { breadcrumbs: [], actions: [], ...props }, }; } @@ -371,61 +389,29 @@ class RoutedPage extends React.Component< const { route, location, navigate, params } = this.props; const Component = route?.Component ?? Page404; const navigation = { location, navigate, params }; + const navigationNotice = + route?.path === RoutePage.RUNS || route?.path === RoutePage.RUN_DETAILS ? ( + + ) : undefined; + const page = ; return ( -
- - {(route?.path === RoutePage.RUNS || route?.path === RoutePage.RUN_DETAILS) && ( - - )} - {this.state.bannerProps.message && ( - - )} - - - - - this._handleDialogClosed()} - > - {this.state.dialogProps.title && ( - {this.state.dialogProps.title} - )} - {this.state.dialogProps.content && ( - - {this.state.dialogProps.content} - - )} - {this.state.dialogProps.buttons && ( - - {this.state.dialogProps.buttons.map((b, i) => ( - - ))} - - )} - -
+ + Loading page…
}>{page} + ); } @@ -452,7 +438,7 @@ class RoutedPage extends React.Component< this.setState({ snackbarProps }); } - private _handleDialogClosed(onClick?: () => void): void { + private _handleDialogClosed = (onClick?: () => void): void => { this.setState({ dialogProps: { open: false } }); if (onClick) { onClick(); @@ -460,25 +446,76 @@ class RoutedPage extends React.Component< if (this.state.dialogProps.onClose) { this.state.dialogProps.onClose(); } - } - private _handleSnackbarClose(): void { + }; + private _handleSnackbarClose = (): void => { this.setState({ snackbarProps: { open: false, message: '' } }); - } + }; } // TODO: loading/error experience until backend is reachable export default Router; -const SideNavLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const location = useLocation(); - // Allow route scroll regions to shrink even when navigation is taller than the viewport. +const ApplicationLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { pathname } = useLocation(); + const items: AppShellNavItem[] = [ + { + id: 'pipelines', + elementId: 'pipelinesBtn', + label: 'Pipelines', + href: RoutePage.PIPELINES, + icon: Workflow, + active: + pathname.startsWith(RoutePage.PIPELINES) || pathname.startsWith(RoutePage.PIPELINES_SHARED), + }, + { + id: 'experiments', + label: 'Experiments', + href: RoutePage.EXPERIMENTS, + icon: FlaskConical, + active: + pathname.startsWith(RoutePage.EXPERIMENTS) || pathname === RoutePage.ARCHIVED_EXPERIMENTS, + }, + { + id: 'runs', + elementId: 'runsBtn', + label: 'Runs', + href: RoutePage.RUNS, + icon: PlayCircle, + active: + pathname.startsWith(RoutePage.RUNS) || + pathname.startsWith(RoutePage.COMPARE) || + pathname === RoutePage.ARCHIVED_RUNS, + }, + { + id: 'recurring-runs', + label: 'Recurring runs', + href: RoutePage.RECURRING_RUNS, + icon: Repeat, + active: + pathname.startsWith(RoutePage.RECURRING_RUNS) || + pathname.startsWith(RoutePrefix.RECURRING_RUN), + }, + { + id: 'artifacts', + label: 'Artifacts', + href: RoutePage.ARTIFACTS, + icon: Package, + active: pathname.startsWith(RoutePrefix.ARTIFACT), + }, + ]; + if (KFP_FLAGS.DEPLOYMENT === Deployments.MARKETPLACE) { + items.unshift({ + id: 'getting-started', + label: 'Getting started', + href: RoutePage.START, + icon: BookOpen, + active: pathname.startsWith(RoutePage.START), + }); + } return ( -
-
- - {children} -
-
+ + {children} + ); }; diff --git a/frontend/src/components/RunFormFields.css b/frontend/src/components/RunFormFields.css new file mode 100644 index 00000000000..feb28ced989 --- /dev/null +++ b/frontend/src/components/RunFormFields.css @@ -0,0 +1,141 @@ +.kfp-theme .kfp-run-form-fields { + display: flex; + flex-direction: column; + gap: 20px; + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} +.kfp-theme .kfp-run-form-fields > legend { + padding: 0; + margin-bottom: 16px; + font-size: 15px; + font-weight: 600; +} +.kfp-theme .kfp-native-field { + display: flex; + flex-direction: column; + gap: 7px; + min-width: 0; + font-size: 13px; + font-weight: 500; +} +.kfp-theme .kfp-native-field select { + min-width: 0; + width: 100%; + height: 34px; + padding: 0 10px; + border: 1px solid var(--border-strong); + border-radius: 8px; + background: var(--background); + color: var(--foreground); + font: inherit; + font-weight: 400; +} +.kfp-theme .kfp-native-field select[aria-invalid='true'] { + border-color: var(--status-failed); +} +.kfp-theme .kfp-native-field select:disabled { + opacity: 0.5; +} +.kfp-theme .kfp-form-check { + display: inline-flex; + align-items: center; + gap: 9px; + font-size: 13px; + cursor: pointer; +} +.kfp-theme .kfp-schedule-boundary { + min-width: 0; + margin: 0; + padding: 14px 0 0; + border: 0; + border-top: 1px solid var(--border); +} +.kfp-theme .kfp-schedule-boundary legend { + padding: 0 8px 0 0; + font-size: 13px; + font-weight: 600; +} +.kfp-theme .kfp-schedule-date-time, +.kfp-theme .kfp-schedule-interval { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + margin-top: 12px; +} +.kfp-theme .kfp-schedule-weekdays { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 12px; +} +.kfp-theme .kfp-form-help { + margin: 8px 0; + color: var(--muted-foreground); + font-size: 12px; + line-height: 1.6; +} +.kfp-theme .kfp-form-help summary { + cursor: pointer; +} +.kfp-theme .kfp-form-error { + color: var(--status-failed); + font-size: 12px; + margin-top: 6px; +} +.kfp-theme .kfp-parameter-fields { + max-width: 760px; +} +.kfp-theme .kfp-parameter-fields h3 { + margin: 0; + font-size: 15px; + font-weight: 600; +} +.kfp-theme .kfp-parameter-fields a, +.kfp-theme .kfp-trigger a { + color: var(--primary); +} +.kfp-theme .kfp-parameter-fields .kfp-native-field, +.kfp-theme .kfp-parameter-fields .kfp-text-field { + margin-bottom: 16px; +} +.kfp-theme .kfp-parameter-editor { + margin: -4px 0 16px; + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + background: white; + color: #222; + color-scheme: light; +} +@media (max-width: 500px) { + .kfp-theme .kfp-schedule-date-time { + grid-template-columns: minmax(0, 1fr); + } +} + +.kfp-theme .kfp-schedule-frequency, +.kfp-theme .kfp-schedule-windows { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + align-items: start; +} +.kfp-theme .kfp-schedule-frequency .kfp-schedule-interval { margin-top: 0; } +.kfp-theme .kfp-schedule-summary { + padding: 14px 16px; + border-left: 3px solid var(--primary); + background: var(--muted); + border-radius: 0 8px 8px 0; + font-size: 14px; +} +.kfp-theme .kfp-schedule-summary p { margin: 6px 0 0; color: var(--muted-foreground); font-size: 12px; line-height: 1.5; } +.kfp-theme .kfp-schedule-advanced { border-top: 1px solid var(--border); padding-top: 16px; } +.kfp-theme .kfp-schedule-advanced summary { cursor: pointer; font-size: 13px; font-weight: 500; } +.kfp-theme .kfp-schedule-advanced > div { margin-top: 16px; } +@media (max-width: 600px) { + .kfp-theme .kfp-schedule-frequency, + .kfp-theme .kfp-schedule-windows { grid-template-columns: minmax(0, 1fr); } +} diff --git a/frontend/src/components/NewRunParametersV2.test.tsx b/frontend/src/components/RunParametersForm.test.tsx similarity index 87% rename from frontend/src/components/NewRunParametersV2.test.tsx rename to frontend/src/components/RunParametersForm.test.tsx index 157a34c126a..1710dda99e1 100644 --- a/frontend/src/components/NewRunParametersV2.test.tsx +++ b/frontend/src/components/RunParametersForm.test.tsx @@ -16,15 +16,47 @@ import { testBestPractices } from 'src/TestUtils'; import { CommonTestWrapper } from 'src/TestWrapper'; -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { ParameterType_ParameterTypeEnum } from 'src/generated/pipeline_spec/pipeline_spec'; -import NewRunParametersV2 from 'src/components/NewRunParametersV2'; +import RunParametersForm from 'src/components/RunParametersForm'; import { getInitialParameterState } from 'src/lib/NewRunParametersUtils'; testBestPractices(); +function chooseLiteralOption(name: string) { + const option = screen.getByRole('option', { name }) as HTMLOptionElement; + fireEvent.change(option.closest('select')!, { target: { value: option.value } }); +} + // Spies on mount-time callbacks may run twice under RTL StrictMode (double mount). -describe('NewRunParametersV2', () => { +describe('RunParametersForm', () => { + it('names the editable JSON control for its parameter', async () => { + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Open Json Editor' })); + await waitFor(() => + expect( + screen.getByRole('textbox', { name: /settings - dict JSON editor/ }), + ).toBeInTheDocument(), + ); + expect(screen.getByLabelText('settings - dict')).toBeDisabled(); + fireEvent.click(screen.getByRole('button', { name: 'Close Json Editor' })); + expect( + screen.queryByRole('textbox', { name: /settings - dict JSON editor/ }), + ).not.toBeInTheDocument(); + expect(screen.getByLabelText('settings - dict')).toBeEnabled(); + }); + it('shows parameters', () => { const props = { titleMessage: 'Specify parameters required by the pipeline', @@ -44,7 +76,7 @@ describe('NewRunParametersV2', () => { }, clonedRuntimeConfig: {}, }; - render(); + render(); screen.getByText('Run parameters'); screen.getByText('Specify parameters required by the pipeline'); @@ -76,7 +108,7 @@ describe('NewRunParametersV2', () => { }, clonedRuntimeConfig: {}, }; - render(); + render(); const strParam = screen.getByDisplayValue('string value'); fireEvent.change(strParam, { target: { value: 'new string' } }); @@ -148,7 +180,7 @@ describe('NewRunParametersV2', () => { handlePipelineRootChange: vi.fn(), handleParameterChange: handleParameterChangeSpy, }; - render(); + render(); const strParam = screen.getByDisplayValue('string value'); handleParameterChangeSpy.mockClear(); @@ -174,7 +206,7 @@ describe('NewRunParametersV2', () => { handlePipelineRootChange: vi.fn(), handleParameterChange: handleParameterChangeSpy, }; - render(); + render(); const strParam = screen.getByLabelText('strParam - string'); handleParameterChangeSpy.mockClear(); @@ -201,7 +233,7 @@ describe('NewRunParametersV2', () => { handlePipelineRootChange: vi.fn(), handleParameterChange: handleParameterChangeSpy, }; - render(); + render(); const boolParam = screen.getByDisplayValue('true'); handleParameterChangeSpy.mockClear(); @@ -227,7 +259,7 @@ describe('NewRunParametersV2', () => { handlePipelineRootChange: vi.fn(), handleParameterChange: handleParameterChangeSpy, }; - render(); + render(); const boolParam = screen.getByLabelText('boolParam - boolean'); handleParameterChangeSpy.mockClear(); @@ -253,7 +285,7 @@ describe('NewRunParametersV2', () => { handlePipelineRootChange: vi.fn(), handleParameterChange: handleParameterChangeSpy, }; - render(); + render(); const boolParam = screen.getByLabelText('boolParam - boolean'); handleParameterChangeSpy.mockClear(); @@ -280,7 +312,7 @@ describe('NewRunParametersV2', () => { handlePipelineRootChange: vi.fn(), handleParameterChange: handleParameterChangeSpy, }; - render(); + render(); handleParameterChangeSpy.mockClear(); const intParam = screen.getByDisplayValue('123'); @@ -306,7 +338,7 @@ describe('NewRunParametersV2', () => { handlePipelineRootChange: vi.fn(), handleParameterChange: handleParameterChangeSpy, }; - render(); + render(); const intParam = screen.getByLabelText('intParam - integer'); handleParameterChangeSpy.mockClear(); @@ -332,7 +364,7 @@ describe('NewRunParametersV2', () => { handlePipelineRootChange: vi.fn(), handleParameterChange: handleParameterChangeSpy, }; - render(); + render(); const intParam = screen.getByLabelText('intParam - integer'); handleParameterChangeSpy.mockClear(); @@ -359,7 +391,7 @@ describe('NewRunParametersV2', () => { handlePipelineRootChange: vi.fn(), handleParameterChange: handleParameterChangeSpy, }; - render(); + render(); const doubleParam = screen.getByDisplayValue('1.23'); handleParameterChangeSpy.mockClear(); @@ -385,7 +417,7 @@ describe('NewRunParametersV2', () => { handlePipelineRootChange: vi.fn(), handleParameterChange: handleParameterChangeSpy, }; - render(); + render(); const doubleParam = screen.getByLabelText('doubleParam - double'); handleParameterChangeSpy.mockClear(); @@ -412,7 +444,7 @@ describe('NewRunParametersV2', () => { handlePipelineRootChange: vi.fn(), handleParameterChange: handleParameterChangeSpy, }; - render(); + render(); const listParam = screen.getByDisplayValue('[1,2,3]'); handleParameterChangeSpy.mockClear(); @@ -438,7 +470,7 @@ describe('NewRunParametersV2', () => { handlePipelineRootChange: vi.fn(), handleParameterChange: handleParameterChangeSpy, }; - render(); + render(); const listParam = screen.getByLabelText('listParam - list'); handleParameterChangeSpy.mockClear(); @@ -464,7 +496,7 @@ describe('NewRunParametersV2', () => { handlePipelineRootChange: vi.fn(), handleParameterChange: handleParameterChangeSpy, }; - render(); + render(); const listParam = screen.getByLabelText('listParam - list'); handleParameterChangeSpy.mockClear(); @@ -491,7 +523,7 @@ describe('NewRunParametersV2', () => { handlePipelineRootChange: vi.fn(), handleParameterChange: handleParameterChangeSpy, }; - render(); + render(); const structParam = screen.getByDisplayValue('{"A":1,"B":2}'); handleParameterChangeSpy.mockClear(); @@ -517,7 +549,7 @@ describe('NewRunParametersV2', () => { handlePipelineRootChange: vi.fn(), handleParameterChange: handleParameterChangeSpy, }; - render(); + render(); const structParam = screen.getByLabelText('structParam - dict'); handleParameterChangeSpy.mockClear(); @@ -543,7 +575,7 @@ describe('NewRunParametersV2', () => { handlePipelineRootChange: vi.fn(), handleParameterChange: handleParameterChangeSpy, }; - render(); + render(); const structParam = screen.getByLabelText('structParam - dict'); handleParameterChangeSpy.mockClear(); @@ -570,7 +602,7 @@ describe('NewRunParametersV2', () => { handleParameterChange: vi.fn(), setIsValidInput: setIsValidInputSpy, }; - render(); + render(); const intParam = screen.getByLabelText('intParam - integer'); setIsValidInputSpy.mockClear(); @@ -597,7 +629,7 @@ describe('NewRunParametersV2', () => { handleParameterChange: vi.fn(), setIsValidInput: setIsValidInputSpy, }; - render(); + render(); const intParam = screen.getByDisplayValue('123'); setIsValidInputSpy.mockClear(); @@ -622,7 +654,7 @@ describe('NewRunParametersV2', () => { handleParameterChange: vi.fn(), setIsValidInput: setIsValidInputSpy, }; - render(); + render(); const boolParam = screen.getByLabelText('boolParam - boolean'); setIsValidInputSpy.mockClear(); @@ -648,7 +680,7 @@ describe('NewRunParametersV2', () => { handleParameterChange: vi.fn(), setIsValidInput: setIsValidInputSpy, }; - render(); + render(); const boolParam = screen.getByLabelText('boolParam - boolean'); setIsValidInputSpy.mockClear(); @@ -673,7 +705,7 @@ describe('NewRunParametersV2', () => { handleParameterChange: vi.fn(), setIsValidInput: setIsValidInputSpy, }; - render(); + render(); const doubleParam = screen.getByLabelText('doubleParam - double'); setIsValidInputSpy.mockClear(); @@ -699,7 +731,7 @@ describe('NewRunParametersV2', () => { handleParameterChange: vi.fn(), setIsValidInput: setIsValidInputSpy, }; - render(); + render(); const listParam = screen.getByLabelText('listParam - list'); setIsValidInputSpy.mockClear(); @@ -725,7 +757,7 @@ describe('NewRunParametersV2', () => { handleParameterChange: vi.fn(), setIsValidInput: setIsValidInputSpy, }; - render(); + render(); const listParam = screen.getByLabelText('listParam - list'); setIsValidInputSpy.mockClear(); @@ -751,7 +783,7 @@ describe('NewRunParametersV2', () => { handleParameterChange: vi.fn(), setIsValidInput: setIsValidInputSpy, }; - render(); + render(); const structParam = screen.getByLabelText('structParam - dict'); setIsValidInputSpy.mockClear(); @@ -777,7 +809,7 @@ describe('NewRunParametersV2', () => { handleParameterChange: vi.fn(), setIsValidInput: setIsValidInputSpy, }; - render(); + render(); const structParam = screen.getByLabelText('structParam - dict'); setIsValidInputSpy.mockClear(); @@ -803,7 +835,7 @@ describe('NewRunParametersV2', () => { handleParameterChange: vi.fn(), setIsValidInput: setIsValidInputSpy, }; - render(); + render(); const structParam = screen.getByLabelText('structParam - dict'); setIsValidInputSpy.mockClear(); @@ -833,7 +865,7 @@ describe('NewRunParametersV2', () => { handleParameterChange: vi.fn(), setIsValidInput: vi.fn(), }; - render(); + render(); screen.getByDisplayValue('gs://dummy_pipeline_root'); }); @@ -848,7 +880,7 @@ describe('NewRunParametersV2', () => { handleParameterChange: vi.fn(), setIsValidInput: vi.fn(), }; - const { rerender } = render(); + const { rerender } = render(); fireEvent.click(screen.getByLabelText('Set custom pipeline root.')); fireEvent.change(screen.getByLabelText('pipeline-root'), { @@ -857,7 +889,7 @@ describe('NewRunParametersV2', () => { expect(screen.getByDisplayValue('gs://custom_pipeline_root')).toBeInTheDocument(); rerender( - { handleParameterChange: vi.fn(), setIsValidInput: vi.fn(), }; - render(); + render(); screen.getByDisplayValue('123'); screen.getByDisplayValue('string_value'); @@ -907,7 +939,7 @@ describe('NewRunParametersV2', () => { handleParameterChange: vi.fn(), setIsValidInput: vi.fn(), }; - render(); + render(); const intParam = screen.getByDisplayValue('123'); fireEvent.change(intParam, { target: { value: '456' } }); @@ -920,11 +952,11 @@ describe('NewRunParametersV2', () => { it('does not display any text fields if there are no parameters', () => { render( - + > , ); @@ -957,7 +989,7 @@ describe('NewRunParametersV2', () => { setIsValidInput: setIsValidInputSpy, }; - render(); + render(); expect(handleParameterChangeSpy).not.toHaveBeenCalled(); expect(setIsValidInputSpy).not.toHaveBeenCalled(); @@ -1027,7 +1059,7 @@ describe('Bug Fix: Default Parameters in Compare Runs (#12536)', () => { clonedRuntimeConfig: {}, handleParameterChange: handleParameterChangeSpy, }; - render(); + render(); // User changes only param_a const paramAInput = screen.getByDisplayValue('default_a'); @@ -1087,7 +1119,7 @@ describe('Literal Parameter Dropdown (#12603)', () => { }, clonedRuntimeConfig: {}, }; - render(); + render(); // Label should be rendered const labels = screen.getAllByText('literalParam - string'); @@ -1098,9 +1130,8 @@ describe('Literal Parameter Dropdown (#12603)', () => { const input = screen.getByDisplayValue('A'); expect(input).toBeInTheDocument(); - // Open the dropdown and verify all options are present - const selectButton = screen.getByText('A'); - fireEvent.mouseDown(selectButton); + // Verify all native options are present + expect(screen.getByRole('combobox')).toHaveDisplayValue('A'); screen.getByText('B'); screen.getByText('C'); }); @@ -1119,14 +1150,13 @@ describe('Literal Parameter Dropdown (#12603)', () => { }, clonedRuntimeConfig: {}, }; - render(); + render(); // Default value is shown screen.getByText('1'); - // Open dropdown and verify all options - const selectButton = screen.getByText('1'); - fireEvent.mouseDown(selectButton); + // Verify all native options + expect(screen.getByRole('combobox')).toHaveDisplayValue('1'); screen.getByText('3'); screen.getByText('5'); }); @@ -1145,12 +1175,11 @@ describe('Literal Parameter Dropdown (#12603)', () => { }, clonedRuntimeConfig: {}, }; - render(); + render(); screen.getByText('0.5'); - const selectButton = screen.getByText('0.5'); - fireEvent.mouseDown(selectButton); + expect(screen.getByRole('combobox')).toHaveDisplayValue('0.5'); screen.getByText('0.1'); screen.getByText('0.9'); }); @@ -1169,7 +1198,7 @@ describe('Literal Parameter Dropdown (#12603)', () => { }, clonedRuntimeConfig: {}, }; - const { container } = render(); + render(); // Should render a regular text field, not a select const textInput = screen.getByDisplayValue('hello'); @@ -1194,13 +1223,12 @@ describe('Literal Parameter Dropdown (#12603)', () => { clonedRuntimeConfig: {}, handleParameterChange: handleParameterChangeSpy, }; - render(); + render(); - // Open dropdown and select a different value - const selectButton = screen.getByText('dev'); - fireEvent.mouseDown(selectButton); + // Select a different native option + expect(screen.getByRole('combobox')).toHaveDisplayValue('dev'); handleParameterChangeSpy.mockClear(); - fireEvent.click(screen.getByText('staging')); + chooseLiteralOption('staging'); expect(handleParameterChangeSpy).toHaveBeenCalledTimes(1); expect(handleParameterChangeSpy).toHaveBeenCalledWith({ env: 'staging' }); @@ -1222,7 +1250,7 @@ describe('Literal Parameter Dropdown (#12603)', () => { handleParameterChange: vi.fn(), setIsValidInput: vi.fn(), }; - render(); + render(); // The cloned value 'prod' should be displayed, not the default 'dev' screen.getByText('prod'); @@ -1246,15 +1274,11 @@ describe('Literal Parameter Dropdown (#12603)', () => { handleParameterChange: handleParameterChangeSpy, setIsValidInput: setIsValidInputSpy, }; - render(); - - // Open dropdown - MUI v5 Select renders with role="combobox" - const selectElement = screen.getByRole('combobox'); - fireEvent.mouseDown(selectElement); + render(); // Select a value setIsValidInputSpy.mockClear(); - fireEvent.click(screen.getByText('dev')); + chooseLiteralOption('dev'); // Should now be valid expect(setIsValidInputSpy).toHaveBeenCalledWith(true); @@ -1277,15 +1301,11 @@ describe('Literal Parameter Dropdown (#12603)', () => { handleParameterChange: handleParameterChangeSpy, setIsValidInput: setIsValidInputSpy, }; - render(); - - // Open dropdown - MUI v5 Select renders with role="combobox" - const selectElement = screen.getByRole('combobox'); - fireEvent.mouseDown(selectElement); + render(); // Select 'true' setIsValidInputSpy.mockClear(); - fireEvent.click(screen.getByText('true')); + chooseLiteralOption('true'); // Should now be valid expect(setIsValidInputSpy).toHaveBeenCalledWith(true); @@ -1308,7 +1328,7 @@ describe('Literal Parameter Dropdown (#12603)', () => { handleParameterChange: handleParameterChangeSpy, setIsValidInput: vi.fn(), }; - render(); + render(); // The placeholder "Select a value" should NOT appear when 0 is selected expect(screen.queryByText('Select a value')).toBeNull(); @@ -1316,4 +1336,59 @@ describe('Literal Parameter Dropdown (#12603)', () => { // The selected value should be rendered as '0' expect(screen.getByText('0')).toBeInTheDocument(); }); + + it('keeps false and zero literal defaults selected and emits typed values after changes', () => { + const onChange = vi.fn(); + render( + , + ); + expect(screen.getByRole('combobox', { name: 'count - integer' })).toHaveDisplayValue('0'); + expect(screen.getByRole('combobox', { name: 'flag - boolean' })).toHaveDisplayValue('false'); + chooseLiteralOption('true'); + expect(onChange).toHaveBeenLastCalledWith({ count: 0, flag: true }); + chooseLiteralOption('false'); + expect(onChange).toHaveBeenLastCalledWith({ count: 0, flag: false }); + }); + + it('distinguishes an empty-string literal from a missing selection', () => { + const onChange = vi.fn(); + const validity = vi.fn(); + render( + , + ); + expect(screen.getByRole('combobox', { name: 'mode - string' })).toHaveDisplayValue( + 'Empty string', + ); + expect(screen.queryByText('Select a value')).not.toBeInTheDocument(); + chooseLiteralOption('train'); + chooseLiteralOption('Empty string'); + expect(onChange).toHaveBeenLastCalledWith({ mode: '' }); + expect(validity).toHaveBeenLastCalledWith(true); + }); }); diff --git a/frontend/src/components/NewRunParametersV2.tsx b/frontend/src/components/RunParametersForm.tsx similarity index 66% rename from frontend/src/components/NewRunParametersV2.tsx rename to frontend/src/components/RunParametersForm.tsx index 050a2137823..5b57d2543e0 100644 --- a/frontend/src/components/NewRunParametersV2.tsx +++ b/frontend/src/components/RunParametersForm.tsx @@ -14,17 +14,10 @@ * limitations under the License. */ -import { - Button, - Checkbox, - FormControl, - FormControlLabel, - InputAdornment, - InputLabel, - MenuItem, - Select, - TextField, -} from '@mui/material'; +import { Button } from './ui/button'; +import { Checkbox } from './ui/checkbox'; +import { TextField } from './ui/text-field'; +import './RunFormFields.css'; import * as React from 'react'; import { useState } from 'react'; import { V2RuntimeConfig } from 'src/apisv2/run'; @@ -40,35 +33,9 @@ import { type RuntimeParameters, type SpecParameters, } from 'src/lib/NewRunParametersUtils'; -import { classes, stylesheet } from 'typestyle'; -import { color, commonCss, spacing, padding } from '../Css'; -import Editor from './Editor'; - -const css = stylesheet({ - button: { - margin: 0, - padding: '3px 5px', - }, - key: { - color: color.strong, - flex: '0 0 50%', - fontWeight: 'bold', - maxWidth: 300, - }, - nonEditableInput: { - color: color.secondaryText, - }, - row: { - borderBottom: `1px solid ${color.divider}`, - display: 'flex', - padding: `${spacing.units(-5)}px ${spacing.units(-6)}px`, - }, - textfield: { - maxWidth: 600, - }, -}); +import Editor from './LazyEditor'; -interface NewRunParametersProps { +interface RunParametersFormProps { titleMessage: string; pipelineRoot?: string; // ComponentInputsSpec_ParameterSpec @@ -80,7 +47,7 @@ interface NewRunParametersProps { setIsValidInput?: (isValid: boolean) => void; } -function NewRunParametersV2(props: NewRunParametersProps) { +function RunParametersForm(props: RunParametersFormProps) { const { specParameters, clonedRuntimeConfig, @@ -108,8 +75,8 @@ function NewRunParametersV2(props: NewRunParametersProps) { ); return ( -
-
Pipeline Root
+
+

Pipeline Root

Pipeline Root represents an artifact repository, refer to{' '} @@ -118,31 +85,23 @@ function NewRunParametersV2(props: NewRunParametersProps) { .
-
- { - setCustomPipelineRootChecked(checked); - if (!checked) { - setCustomPipelineRoot(undefined); - if (handlePipelineRootChange) { - handlePipelineRootChange(undefined); - } - } - }} - inputProps={{ 'aria-label': 'Set custom pipeline root.' }} - /> - } +
+ Custom Pipeline Root + {customPipelineRootChecked && ( { @@ -151,10 +110,9 @@ function NewRunParametersV2(props: NewRunParametersProps) { handlePipelineRootChange(ev.target.value); } }} - className={classes(commonCss.textField, css.textfield)} /> )} -
Run parameters
+

Run parameters

{props.titleMessage}
{!!Object.keys(specParameters).length && ( @@ -209,9 +167,6 @@ function NewRunParametersV2(props: NewRunParametersProps) { }} param={param} /> -
- {param.errorMsg} -
); })} @@ -221,7 +176,7 @@ function NewRunParametersV2(props: NewRunParametersProps) { ); } -export default NewRunParametersV2; +export default RunParametersForm; interface Param { key: string; @@ -282,39 +237,36 @@ class ParamEditor extends React.Component { const { id, onChange, param } = this.props; if (param.literals?.length) { - const getLiteralString = (l: string | number | boolean) => String(l); - const literalStrings = param.literals.map(getLiteralString); - const stringValue = getLiteralString(param.value); - const isValueInLiterals = - param.value != null && param.value !== '' && literalStrings.includes(stringValue); - const selectValue = isValueInLiterals ? stringValue : ''; - + const literalStrings = param.literals.map(String); + // Option indices distinguish the unselected placeholder from a supported empty-string literal. + const selectedIndex = param.value == null ? -1 : literalStrings.indexOf(String(param.value)); return ( - - {param.key} - onChange(String(ev.target.value))} - label={param.key} - aria-label={param.key} - displayEmpty - renderValue={(selected) => - selected === '' ? ( - Select a value - ) : ( - String(selected) - ) - } + value={selectedIndex < 0 ? '' : String(selectedIndex)} + onChange={(event) => onChange(literalStrings[Number(event.target.value)])} + aria-invalid={!!param.errorMsg} + aria-describedby={param.errorMsg ? `${id}-error` : undefined} > - {literalStrings.map((literal) => ( - - {literal} - + {selectedIndex < 0 && ( + + )} + {literalStrings.map((literal, index) => ( + ))} - - + + {param.errorMsg && ( + + )} +
); } @@ -349,40 +301,23 @@ class ParamEditor extends React.Component { return ( <> - {this.state.isJsonField ? ( - onChange(ev.target.value || '')} - className={classes(commonCss.textField, css.textfield)} - InputProps={{ - classes: { disabled: css.nonEditableInput }, - endAdornment: ( - - - - ), - readOnly: false, - }} - /> - ) : ( - onChange(ev.target.value || '')} - className={classes(commonCss.textField, css.textfield)} - /> - )} + onChange(event.target.value)} + error={param.errorMsg} + trailingContent={ + this.state.isJsonField ? ( + + ) : undefined + } + /> {this.state.isJsonField && this.state.isEditorOpen && ( -
+
{ mode='json' theme='github' editorProps={{ $blockScrolling: Infinity }} + setOptions={{ + textInputAriaLabel: `${param.key} JSON editor`, + enableKeyboardAccessibility: true, + }} highlightActiveLine={true} showGutter={true} readOnly={false} diff --git a/frontend/src/components/RuntimeArtifactRows.test.tsx b/frontend/src/components/RuntimeArtifactRows.test.tsx index b0d105fbf8a..0019ef3710c 100644 --- a/frontend/src/components/RuntimeArtifactRows.test.tsx +++ b/frontend/src/components/RuntimeArtifactRows.test.tsx @@ -30,3 +30,15 @@ it('passes a runtime null through to the preview error state without throwing', ); expect(screen.getByText('Can not retrieve storage path from artifact uri: null')).toBeVisible(); }); + +it('encodes raw artifact IDs in runtime value links', () => { + render( + + + , + ); + expect(screen.getByRole('link', { name: 'View artifact' })).toHaveAttribute( + 'href', + '/artifacts/folder%2Fitem%252F%3F%23', + ); +}); diff --git a/frontend/src/components/RuntimeArtifactRows.tsx b/frontend/src/components/RuntimeArtifactRows.tsx index 055db70a857..baa5039304d 100644 --- a/frontend/src/components/RuntimeArtifactRows.tsx +++ b/frontend/src/components/RuntimeArtifactRows.tsx @@ -20,7 +20,7 @@ import ArtifactPreview, { ArtifactPreviewValue, } from 'src/components/ArtifactPreview'; import { RoutePageFactory } from 'src/components/Router'; -import { commonCss } from 'src/Css'; +import './inspection/RunInspection.css'; import { flattenArtifactGroups, getArtifactDisplayName, @@ -33,17 +33,24 @@ type RuntimeArtifactRowValue = ArtifactPreviewValue | { text: string; artifactId export function RuntimeArtifactValue({ value, + namespacePending = false, ...props -}: Omit & { value?: RuntimeArtifactRowValue }) { +}: Omit & { + value?: RuntimeArtifactRowValue; + namespacePending?: boolean; +}) { if (value !== null && typeof value === 'object' && 'text' in value) { return value.artifactId ? ( - + {value.text} ) : ( <>{value.text} ); } + // Keep artifact labels and scalar values visible while withholding storage reads and + // download links until Run Details has resolved the experiment namespace. + if (namespacePending) return Loading experiment namespace…; return ; } @@ -52,7 +59,10 @@ export function buildRuntimeArtifactRows(groups: InputOutputsIOArtifact[] | unde for (const { artifact, artifactKey, group, index } of flattenArtifactGroups(groups)) { const displayName = getArtifactDisplayName(artifact, artifactKey, index, group.artifacts); const label = artifact.artifact_id ? ( - + {displayName} ) : ( diff --git a/frontend/src/components/SideNav.test.tsx b/frontend/src/components/SideNav.test.tsx deleted file mode 100644 index d8bf4635542..00000000000 --- a/frontend/src/components/SideNav.test.tsx +++ /dev/null @@ -1,432 +0,0 @@ -/* - * Copyright 2018 The Kubeflow Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import * as React from 'react'; -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { MemoryRouter } from 'react-router'; -import { vi } from 'vitest'; -import { LocalStorage, LocalStorageKey } from '../lib/LocalStorage'; -import { ExternalLinks, RoutePage } from './Router'; -import { css, SideNav } from './SideNav'; -import { GkeMetadata } from '../lib/GkeMetadata'; - -const wideWidth = 1000; -const narrowWidth = 200; - -const defaultProps = { - gkeMetadata: {}, - buildInfo: { - apiServerCommitHash: '0a7b9e38f2b9bcdef4bbf3234d971e1635b50cd5', - apiServerTagName: '1.0.0', - apiServerReady: true, - buildDate: 'Tue Oct 23 14:23:53 UTC 2018', - frontendCommitHash: '302e93ce99099173f387c7e0635476fe1b69ea98', - frontendTagName: '1.0.0-rc1', - }, -}; - -function isCollapsed(): boolean { - const root = screen.getByTestId('sideNav'); - return root.classList.contains(css.collapsedRoot); -} - -function renderSideNav( - page: RoutePage, - overrides: Partial> = {}, -) { - const ref = React.createRef(); - const props = { - ...defaultProps, - ...overrides, - page, - } as React.ComponentProps; - const renderResult = render( - - - , - ); - if (!ref.current) { - throw new Error('SideNav instance not available'); - } - return { renderResult, instance: ref.current }; -} - -describe('SideNav', () => { - it('keeps namespace transfer out of the sidebar', () => { - renderSideNav(RoutePage.METADATA_TRANSFER); - expect(screen.queryByRole('link', { name: 'Export / Import' })).not.toBeInTheDocument(); - }); - - let localStorageHasKeySpy: ReturnType; - let localStorageIsCollapsedSpy: ReturnType; - - beforeEach(() => { - localStorage.clear(); - localStorageHasKeySpy = vi.spyOn(LocalStorage, 'hasKey').mockImplementation(() => false); - localStorageIsCollapsedSpy = vi - .spyOn(LocalStorage, 'isNavbarCollapsed') - .mockImplementation(() => false); - vi.spyOn(console, 'error').mockImplementation(() => undefined); - (window as any).innerWidth = wideWidth; - }); - - afterEach(() => { - localStorage.clear(); - vi.restoreAllMocks(); - (window as any).innerWidth = wideWidth; - }); - - it('renders expanded state', async () => { - localStorageHasKeySpy.mockImplementationOnce(() => false); - (window as any).innerWidth = wideWidth; - const { renderResult } = renderSideNav(RoutePage.PIPELINES); - await waitFor(() => expect(isCollapsed()).toBe(false)); - expect(renderResult.asFragment()).toMatchSnapshot(); - }); - - it('renders sidebar nav buttons without primary button styling', async () => { - const { renderResult } = renderSideNav(RoutePage.PIPELINES); - await waitFor(() => expect(isCollapsed()).toBe(false)); - - const pipelinesButton = renderResult.getByRole('button', { name: 'Pipelines' }); - const docsButton = renderResult.getByRole('button', { name: 'Documentation' }); - const docsLink = docsButton.closest('a'); - - expect(pipelinesButton).toHaveClass('MuiButton-textInherit'); - expect(pipelinesButton).not.toHaveClass('MuiButton-textPrimary'); - expect(docsButton).toHaveClass('MuiButton-textInherit'); - expect(docsButton).not.toHaveClass('MuiButton-textPrimary'); - expect(docsLink).toHaveAttribute('href', ExternalLinks.DOCUMENTATION); - expect(docsLink).toHaveAttribute('target', '_blank'); - expect(docsLink).toHaveAttribute('rel', 'noopener noreferrer'); - }); - - it('renders collapsed state', async () => { - localStorageHasKeySpy.mockImplementationOnce(() => false); - (window as any).innerWidth = narrowWidth; - const { renderResult } = renderSideNav(RoutePage.PIPELINES); - await waitFor(() => expect(isCollapsed()).toBe(true)); - expect(renderResult.asFragment()).toMatchSnapshot(); - }); - - it('renders Pipelines as active page', async () => { - const { renderResult } = renderSideNav(RoutePage.PIPELINES); - await waitFor(() => expect(isCollapsed()).toBe(false)); - expect(renderResult.asFragment()).toMatchSnapshot(); - }); - - it('renders Pipelines as active when on PipelineDetails page', async () => { - const { renderResult } = renderSideNav(RoutePage.PIPELINE_DETAILS); - await waitFor(() => expect(isCollapsed()).toBe(false)); - expect(renderResult.asFragment()).toMatchSnapshot(); - }); - - it('renders experiments as active page', async () => { - const { renderResult } = renderSideNav(RoutePage.EXPERIMENTS); - await waitFor(() => expect(isCollapsed()).toBe(false)); - expect(renderResult.asFragment()).toMatchSnapshot(); - }); - - it('renders experiments as active when on ExperimentDetails page', async () => { - const { renderResult } = renderSideNav(RoutePage.EXPERIMENT_DETAILS); - await waitFor(() => expect(isCollapsed()).toBe(false)); - expect(renderResult.asFragment()).toMatchSnapshot(); - }); - - it('renders experiments as active page when on NewExperiment page', async () => { - const { renderResult } = renderSideNav(RoutePage.NEW_EXPERIMENT); - await waitFor(() => expect(isCollapsed()).toBe(false)); - expect(renderResult.asFragment()).toMatchSnapshot(); - }); - - it('renders experiments as active page when on Compare page', async () => { - const { renderResult } = renderSideNav(RoutePage.COMPARE); - await waitFor(() => expect(isCollapsed()).toBe(false)); - expect(renderResult.asFragment()).toMatchSnapshot(); - }); - - it('renders experiments as active page when on AllRuns page', async () => { - const { renderResult } = renderSideNav(RoutePage.RUNS); - await waitFor(() => expect(isCollapsed()).toBe(false)); - expect(renderResult.asFragment()).toMatchSnapshot(); - }); - - it('renders experiments as active page when on RunDetails page', async () => { - const { renderResult } = renderSideNav(RoutePage.RUN_DETAILS); - await waitFor(() => expect(isCollapsed()).toBe(false)); - expect(renderResult.asFragment()).toMatchSnapshot(); - }); - - it('renders experiments as active page when on RecurringRunDetails page', async () => { - const { renderResult } = renderSideNav(RoutePage.RECURRING_RUN_DETAILS); - await waitFor(() => expect(isCollapsed()).toBe(false)); - expect(renderResult.asFragment()).toMatchSnapshot(); - }); - - it('renders experiments as active page when on NewRun page', async () => { - const { renderResult } = renderSideNav(RoutePage.NEW_RUN); - await waitFor(() => expect(isCollapsed()).toBe(false)); - expect(renderResult.asFragment()).toMatchSnapshot(); - }); - - it('renders recurring runs as active page', async () => { - const { renderResult } = renderSideNav(RoutePage.RECURRING_RUNS); - await waitFor(() => expect(isCollapsed()).toBe(false)); - expect(renderResult.asFragment()).toMatchSnapshot(); - }); - - it('renders jobs as active page when on JobDetails page', async () => { - const { renderResult } = renderSideNav(RoutePage.RECURRING_RUN_DETAILS); - await waitFor(() => expect(isCollapsed()).toBe(false)); - expect(renderResult.asFragment()).toMatchSnapshot(); - }); - - it('show jupyterhub link if accessible', () => { - const { renderResult, instance } = renderSideNav(RoutePage.COMPARE); - act(() => { - instance.setState({ jupyterHubAvailable: true }); - }); - expect(renderResult.asFragment()).toMatchSnapshot(); - }); - - it('collapses if collapse state is true localStorage', async () => { - localStorageIsCollapsedSpy.mockImplementation(() => true); - localStorageHasKeySpy.mockImplementation(() => true); - (window as any).innerWidth = wideWidth; - const { renderResult } = renderSideNav(RoutePage.COMPARE); - await waitFor(() => expect(isCollapsed()).toBe(true)); - }); - - it('expands if collapse state is false in localStorage', async () => { - localStorageIsCollapsedSpy.mockImplementationOnce(() => false); - localStorageHasKeySpy.mockImplementationOnce(() => true); - const { renderResult } = renderSideNav(RoutePage.COMPARE); - await waitFor(() => expect(isCollapsed()).toBe(false)); - }); - - it('collapses if no collapse state in localStorage, and window is too narrow', async () => { - localStorageIsCollapsedSpy.mockImplementationOnce(() => false); - localStorageHasKeySpy.mockImplementationOnce(() => false); - (window as any).innerWidth = narrowWidth; - const { renderResult } = renderSideNav(RoutePage.COMPARE); - await waitFor(() => expect(isCollapsed()).toBe(true)); - }); - - it('expands if no collapse state in localStorage, and window is wide', async () => { - localStorageIsCollapsedSpy.mockImplementationOnce(() => false); - localStorageHasKeySpy.mockImplementationOnce(() => false); - (window as any).innerWidth = wideWidth; - const { renderResult } = renderSideNav(RoutePage.COMPARE); - await waitFor(() => expect(isCollapsed()).toBe(false)); - }); - - it('collapses if no collapse state in localStorage, and window goes from wide to narrow', async () => { - localStorageIsCollapsedSpy.mockImplementationOnce(() => false); - localStorageHasKeySpy.mockImplementationOnce(() => false); - (window as any).innerWidth = wideWidth; - const { renderResult } = renderSideNav(RoutePage.COMPARE); - await waitFor(() => expect(isCollapsed()).toBe(false)); - - act(() => { - (window as any).innerWidth = narrowWidth; - window.dispatchEvent(new Event('resize')); - }); - await waitFor(() => expect(isCollapsed()).toBe(true)); - }); - - it('expands if no collapse state in localStorage, and window goes from narrow to wide', async () => { - localStorageIsCollapsedSpy.mockImplementationOnce(() => false); - localStorageHasKeySpy.mockImplementationOnce(() => false); - (window as any).innerWidth = narrowWidth; - const { renderResult } = renderSideNav(RoutePage.COMPARE); - await waitFor(() => expect(isCollapsed()).toBe(true)); - - act(() => { - (window as any).innerWidth = wideWidth; - window.dispatchEvent(new Event('resize')); - }); - await waitFor(() => expect(isCollapsed()).toBe(false)); - }); - - it('saves state in localStorage if chevron is clicked', async () => { - localStorageIsCollapsedSpy.mockImplementationOnce(() => false); - localStorageHasKeySpy.mockImplementationOnce(() => false); - const spy = vi.spyOn(LocalStorage, 'saveNavbarCollapsed'); - - (window as any).innerWidth = narrowWidth; - const { renderResult } = renderSideNav(RoutePage.COMPARE); - await waitFor(() => expect(isCollapsed()).toBe(true)); - - fireEvent.click(screen.getByTestId('chevron-toggle')); - expect(spy).toHaveBeenCalledWith(false); - }); - - it('labels the chevron toggle with the action it performs', async () => { - renderSideNav(RoutePage.PIPELINES); - await waitFor(() => expect(isCollapsed()).toBe(false)); - - fireEvent.click(screen.getByRole('button', { name: 'Collapse navigation' })); - await waitFor(() => expect(isCollapsed()).toBe(true)); - expect(screen.getByRole('button', { name: 'Expand navigation' })).toBeInTheDocument(); - }); - - it('does not collapse if collapse state is saved in localStorage, and window resizes', async () => { - localStorageIsCollapsedSpy.mockImplementation(() => false); - localStorageHasKeySpy.mockImplementation(() => true); - - (window as any).innerWidth = wideWidth; - const { renderResult } = renderSideNav(RoutePage.COMPARE); - await waitFor(() => expect(isCollapsed()).toBe(false)); - - act(() => { - (window as any).innerWidth = narrowWidth; - window.dispatchEvent(new Event('resize')); - }); - await waitFor(() => expect(isCollapsed()).toBe(false)); - }); - - it('populates the display build information using the default props', () => { - const { instance, renderResult } = renderSideNav(RoutePage.PIPELINES); - expect(renderResult.asFragment()).toMatchSnapshot(); - - const buildInfo = defaultProps.buildInfo; - expect((instance as any)._getBuildInfo()).toEqual({ - tagName: buildInfo.apiServerTagName, - commitHash: buildInfo.apiServerCommitHash.substring(0, 7), - commitUrl: - 'https://www.github.com/kubeflow/pipelines/commit/' + buildInfo.apiServerCommitHash, - date: new Date(buildInfo.buildDate).toLocaleDateString(), - }); - }); - - it('display the correct GKE metadata', () => { - const clusterName = 'some-cluster-name'; - const projectId = 'some-project-id'; - const gkeMetadata: GkeMetadata = { clusterName, projectId }; - - const newProps = { - ...defaultProps, - gkeMetadata, - buildInfo: {}, - }; - - const { renderResult } = renderSideNav(RoutePage.PIPELINES, newProps); - expect(renderResult.asFragment()).toMatchSnapshot(); - }); - - it('displays the frontend tag name if the api server hash is not returned', () => { - const newProps = { - ...defaultProps, - buildInfo: { - apiServerReady: true, - buildDate: 'Tue Oct 23 14:23:53 UTC 2018', - frontendCommitHash: '302e93ce99099173f387c7e0635476fe1b69ea98', - frontendTagName: '1.0.0', - }, - }; - - const { instance } = renderSideNav(RoutePage.PIPELINES, newProps); - expect((instance as any)._getBuildInfo()).toEqual( - expect.objectContaining({ - commitHash: newProps.buildInfo.frontendCommitHash.substring(0, 7), - tagName: newProps.buildInfo.frontendTagName, - }), - ); - }); - - it('uses the frontend commit hash for the link URL if the api server hash is not returned', () => { - const newProps = { - ...defaultProps, - buildInfo: { - apiServerReady: true, - buildDate: 'Tue Oct 23 14:23:53 UTC 2018', - frontendCommitHash: '302e93ce99099173f387c7e0635476fe1b69ea98', - }, - }; - - const { instance } = renderSideNav(RoutePage.PIPELINES, newProps); - expect((instance as any)._getBuildInfo()).toEqual( - expect.objectContaining({ - commitUrl: - 'https://www.github.com/kubeflow/pipelines/commit/' + - newProps.buildInfo.frontendCommitHash, - }), - ); - }); - - it("displays 'unknown' if the frontend and api server tag names/commit hashes are not returned", () => { - const newProps = { - ...defaultProps, - buildInfo: { - apiServerReady: true, - buildDate: 'Tue Oct 23 14:23:53 UTC 2018', - }, - }; - - const { instance } = renderSideNav(RoutePage.PIPELINES, newProps); - expect((instance as any)._getBuildInfo()).toEqual( - expect.objectContaining({ - commitHash: 'unknown', - tagName: 'unknown', - }), - ); - }); - - it('links to the github repo root if the frontend and api server commit hashes are not returned', () => { - const newProps = { - ...defaultProps, - buildInfo: { - apiServerReady: true, - buildDate: 'Tue Oct 23 14:23:53 UTC 2018', - }, - }; - - const { instance } = renderSideNav(RoutePage.PIPELINES, newProps); - expect((instance as any)._getBuildInfo()).toEqual( - expect.objectContaining({ - commitUrl: 'https://www.github.com/kubeflow/pipelines', - }), - ); - }); - - it("displays 'unknown' if the date is not returned", () => { - const newProps = { - ...defaultProps, - buildInfo: { - apiServerCommitHash: '0a7b9e38f2b9bcdef4bbf3234d971e1635b50cd5', - apiServerReady: true, - frontendCommitHash: '302e93ce99099173f387c7e0635476fe1b69ea98', - }, - }; - - const { instance } = renderSideNav(RoutePage.PIPELINES, newProps); - expect((instance as any)._getBuildInfo()).toEqual( - expect.objectContaining({ - date: 'unknown', - }), - ); - }); - - it('auto-collapses on first load when localStorage key is missing and window is narrow', async () => { - localStorageHasKeySpy.mockRestore(); - localStorageIsCollapsedSpy.mockRestore(); - localStorage.removeItem(LocalStorageKey.navbarCollapsed); - (window as any).innerWidth = narrowWidth; - - const { renderResult } = renderSideNav(RoutePage.COMPARE); - await waitFor(() => expect(isCollapsed()).toBe(true)); - }); -}); diff --git a/frontend/src/components/SideNav.tsx b/frontend/src/components/SideNav.tsx deleted file mode 100644 index 3cc07428f81..00000000000 --- a/frontend/src/components/SideNav.tsx +++ /dev/null @@ -1,693 +0,0 @@ -/* - * Copyright 2018 The Kubeflow Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import ArtifactsIcon from '@mui/icons-material/BubbleChart'; -import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; -import JupyterhubIcon from '@mui/icons-material/Code'; -import DescriptionIcon from '@mui/icons-material/Description'; -import OpenInNewIcon from '@mui/icons-material/OpenInNew'; -import DirectionsRun from '@mui/icons-material/DirectionsRun'; -import * as React from 'react'; -import { Link } from 'react-router'; -import { classes, stylesheet } from 'typestyle'; -import { ExternalLinks, RoutePage, RoutePrefix } from '../components/Router'; -import { commonCss, fontsize } from '../Css'; -import ExperimentsIcon from '../icons/experiments'; -import GitHubIcon from '../icons/GitHub-Mark-120px-plus.png'; -import PipelinesIcon from '../icons/pipelines'; -import { BuildInfo } from '../lib/Apis'; -import { Deployments, KFP_FLAGS } from '../lib/Flags'; -import { LocalStorage, LocalStorageKey } from '../lib/LocalStorage'; -import { GkeMetadataContext, GkeMetadata } from 'src/lib/GkeMetadata'; -import { Alarm } from '@mui/icons-material'; -import { BuildInfoContext } from 'src/lib/BuildInfo'; - -import { Button, IconButton, Tooltip } from '@mui/material'; - -export const tailwindcss = { - sideNavItem: 'flex flex-row flex-shrink-0', -}; - -export const sideNavColors = { - bg: '#f8fafb', - fgActive: '#0d6de7', - fgActiveInvisible: 'rgb(227, 233, 237, 0)', - fgDefault: '#9aa0a6', - hover: '#f1f3f4', - separator: '#bdc1c6', - sideNavBorder: '#e8eaed', -}; - -const COLLAPSED_SIDE_NAV_SIZE = 72; -const EXPANDED_SIDE_NAV_SIZE = 220; - -export const css = stylesheet({ - active: { - color: sideNavColors.fgActive + ' !important', - }, - button: { - $nest: { - '&::hover': { - backgroundColor: sideNavColors.hover, - }, - }, - borderRadius: 0, - color: sideNavColors.fgDefault, - display: 'block', - fontSize: fontsize.medium, - fontWeight: 'bold', - height: 44, - marginBottom: 16, - maxWidth: EXPANDED_SIDE_NAV_SIZE, - overflow: 'hidden', - padding: '12px 10px 10px 26px', - textAlign: 'left', - textTransform: 'none', - transition: 'max-width 0.3s', - whiteSpace: 'nowrap', - width: EXPANDED_SIDE_NAV_SIZE, - }, - chevron: { - color: sideNavColors.fgDefault, - marginLeft: 16, - padding: 6, - transition: 'transform 0.3s', - }, - collapsedButton: { - maxWidth: COLLAPSED_SIDE_NAV_SIZE, - minWidth: COLLAPSED_SIDE_NAV_SIZE, - padding: '12px 10px 10px 26px', - }, - collapsedChevron: { - transform: 'rotate(180deg)', - }, - collapsedExternalLabel: { - // Hide text when collapsing, but do it with a transition of both height and - // opacity - height: 0, - opacity: 0, - }, - collapsedLabel: { - // Hide text when collapsing, but do it with a transition - opacity: 0, - }, - collapsedRoot: { - width: `${COLLAPSED_SIDE_NAV_SIZE}px !important`, - }, - collapsedSeparator: { - margin: '20px !important', - }, - envMetadata: { - color: sideNavColors.fgDefault, - marginBottom: 16, - marginLeft: 30, - }, - icon: { - height: 20, - width: 20, - }, - iconImage: { - opacity: 0.6, // Images are too colorful there by default, reduce their color. - }, - indicator: { - borderBottom: '3px solid transparent', - borderLeft: `3px solid ${sideNavColors.fgActive}`, - borderTop: '3px solid transparent', - height: 42, - left: 0, - position: 'absolute', - zIndex: 1, - }, - indicatorHidden: { - opacity: 0, - }, - infoHidden: { - opacity: 0, - transition: 'opacity 0s', - transitionDelay: '0s', - // guarantees info doesn't affect layout when hidden - overflow: 'hidden', - height: 0, - }, - infoVisible: { - flexShrink: 0, - opacity: 'initial', - transition: 'opacity 0.2s', - transitionDelay: '0.3s', - overflow: 'hidden', - }, - label: { - fontSize: fontsize.base, - letterSpacing: 0.25, - marginLeft: 20, - transition: 'opacity 0.3s', - verticalAlign: 'super', - }, - link: { - color: '#77abda', - }, - openInNewTabIcon: { - height: 12, - marginBottom: 8, - marginLeft: 5, - width: 12, - }, - root: { - background: sideNavColors.bg, - borderRight: `1px ${sideNavColors.sideNavBorder} solid`, - paddingTop: 15, - transition: 'width 0.3s', - width: EXPANDED_SIDE_NAV_SIZE, - }, - separator: { - border: '0px none transparent', - borderTop: `1px solid ${sideNavColors.separator}`, - margin: 20, - }, -}); - -interface DisplayBuildInfo { - commitHash: string; - commitUrl: string; - date: string; - tagName: string; -} - -interface SideNavProps { - page: string; -} - -interface SideNavInternalProps extends SideNavProps { - gkeMetadata: GkeMetadata; - buildInfo: BuildInfo | undefined; -} - -interface SideNavState { - collapsed: boolean; - jupyterHubAvailable: boolean; - manualCollapseState: boolean; -} - -type SideNavButtonProps = Omit< - React.ComponentPropsWithoutRef, - 'children' | 'color' -> & { - children: React.ReactNode; - collapsed: boolean; -}; - -const SideNavButton: React.FC = ({ - children, - collapsed, - className, - ...buttonProps -}) => ( - -); - -export class SideNav extends React.Component { - private _isMounted = true; - private readonly _AUTO_COLLAPSE_WIDTH = 800; - private readonly _HUB_ADDRESS = '/hub/'; - private readonly _boundMaybeResize = this._maybeResize.bind(this); - - constructor(props: any) { - super(props); - - const collapsed = LocalStorage.isNavbarCollapsed(); - - this.state = { - collapsed, - // Set jupyterHubAvailable to false so UI don't show Jupyter Hub link - jupyterHubAvailable: false, - manualCollapseState: LocalStorage.hasKey(LocalStorageKey.navbarCollapsed), - }; - } - - public async componentDidMount(): Promise { - this._isMounted = true; - window.addEventListener('resize', this._boundMaybeResize); - this._maybeResize(); - } - - public componentWillUnmount(): void { - this._isMounted = false; - window.removeEventListener('resize', this._boundMaybeResize); - } - - public render(): React.JSX.Element | null { - const page = this.props.page; - const displayBuildInfo: DisplayBuildInfo = this._getBuildInfo(); - const { collapsed } = this.state; - const { gkeMetadata } = this.props; - const iconColor = { - active: sideNavColors.fgActive, - inactive: sideNavColors.fgDefault, - }; - - if (KFP_FLAGS.HIDE_SIDENAV) { - return null; - } - - return ( -
-
- {KFP_FLAGS.DEPLOYMENT === Deployments.MARKETPLACE && ( - <> -
- - - -
- - - Getting Started - -
-
- -
- - )} -
- - - -
-
- -
- - Pipelines - -
-
- -
-
- - - -
-
- -
- - Experiments - -
-
- -
-
- - - -
- - Runs -
-
- -
-
- - - -
- - - Recurring Runs - -
-
- -
-
- - - -
- - - Artifacts - -
-
- -
- {this.state.jupyterHubAvailable && ( - - - -
- - - Notebooks - - -
-
-
-
- )} -
- } - /> - ( - Github - )} - /> -
- - - -
-
- {gkeMetadata.clusterName && gkeMetadata.projectId && ( - - - - )} - {displayBuildInfo && ( - - - - )} - - - -
-
- ); - } - - private _getBuildInfo(buildInfo = this.props.buildInfo): DisplayBuildInfo { - const commitHash = buildInfo?.apiServerCommitHash || buildInfo?.frontendCommitHash || ''; - const tagName = buildInfo?.apiServerTagName || buildInfo?.frontendTagName || ''; - - return { - tagName: tagName || 'unknown', - commitHash: commitHash ? commitHash.substring(0, 7) : 'unknown', - commitUrl: - 'https://www.github.com/kubeflow/pipelines' + - (commitHash && commitHash !== 'unknown' ? `/commit/${commitHash}` : ''), - date: buildInfo?.buildDate - ? new Date(buildInfo?.buildDate).toLocaleDateString('en-US') - : 'unknown', - }; - } - - private _highlightPipelinesButton(page: string): boolean { - return page.startsWith(RoutePage.PIPELINES) || page.startsWith(RoutePage.PIPELINES_SHARED); - } - - private _highlightExperimentsButton(page: string): boolean { - return page.startsWith(RoutePage.EXPERIMENTS) || page === RoutePage.ARCHIVED_EXPERIMENTS; - } - - private _highlightRunsButton(page: string): boolean { - return ( - page.startsWith(RoutePage.RUNS) || - page.startsWith(RoutePage.COMPARE) || - page === RoutePage.ARCHIVED_RUNS - ); - } - - private _highlightRecurringRunsButton(page: string): boolean { - return page.startsWith(RoutePage.RECURRING_RUNS) || page.startsWith(RoutePrefix.RECURRING_RUN); - } - - private _highlightArtifactsButton(page: string): boolean { - return page.startsWith(RoutePrefix.ARTIFACT); - } - - private _toggleNavClicked(): void { - this.setStateSafe( - { - collapsed: !this.state.collapsed, - manualCollapseState: true, - }, - () => LocalStorage.saveNavbarCollapsed(this.state.collapsed), - ); - this._toggleNavCollapsed(); - } - - private _toggleNavCollapsed(shouldCollapse?: boolean): void { - this.setStateSafe({ - collapsed: shouldCollapse !== undefined ? shouldCollapse : !this.state.collapsed, - }); - } - - private _maybeResize(): void { - if (!this.state.manualCollapseState) { - this._toggleNavCollapsed(window.innerWidth < this._AUTO_COLLAPSE_WIDTH); - } - } - - private setStateSafe(newState: Partial, cb?: () => void): void { - if (this._isMounted) { - this.setState(newState as any, cb); - } - } -} - -interface ExternalUriProps { - title: string; - to: string; - collapsed: boolean; - icon: (className: string) => React.ReactNode; -} - -// tslint:disable-next-line:variable-name -const ExternalUri: React.FC = ({ title, to, collapsed, icon }) => ( - - - -
- {icon(css.icon)} - {title} - -
-
-
-
-); - -const EnhancedSideNav: React.FC = (props) => { - const gkeMetadata = React.useContext(GkeMetadataContext); - const buildInfo = React.useContext(BuildInfoContext); - return ; -}; -export default EnhancedSideNav; diff --git a/frontend/src/components/SidePanel.interaction.test.tsx b/frontend/src/components/SidePanel.interaction.test.tsx deleted file mode 100644 index d0e14c388a3..00000000000 --- a/frontend/src/components/SidePanel.interaction.test.tsx +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright 2026 The Kubeflow Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import * as React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { vi } from 'vitest'; -import SidePanel from './SidePanel'; - -// Mock @mui/material Slide to avoid react-transition-group issues in jsdom -vi.mock('@mui/material', async () => { - const actual = await vi.importActual('@mui/material'); - return { - ...actual, - Slide: ({ children, in: isIn }: { children: React.ReactElement; in: boolean }) => - isIn ? children : null, - }; -}); - -// Mock re-resizable to avoid rendering issues in jsdom -vi.mock('re-resizable', () => ({ - __esModule: true, - Resizable: ({ children, className }: { children: React.ReactNode; className?: string }) => ( -
{children}
- ), -})); - -describe('SidePanel (interaction)', () => { - let user: ReturnType; - const defaultProps = { - isOpen: true, - onClose: vi.fn(), - title: 'Test Panel', - }; - - beforeEach(() => { - user = userEvent.setup(); - vi.clearAllMocks(); - }); - - it('renders the title when open', () => { - render(); - expect(screen.getByText('Test Panel')).toBeInTheDocument(); - }); - - it('renders children when open', () => { - render( - -
Child Content
-
, - ); - expect(screen.getByText('Child Content')).toBeInTheDocument(); - }); - - it('renders close button when open', () => { - render(); - expect(screen.getByRole('button', { name: 'close' })).toBeInTheDocument(); - }); - - it('calls onClose when close button is clicked', async () => { - const onClose = vi.fn(); - render(); - await user.click(screen.getByRole('button', { name: 'close' })); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it('calls onClose when Escape key is pressed', async () => { - const onClose = vi.fn(); - render(); - await user.keyboard('{Escape}'); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it('does not call onClose on Escape when panel is closed', async () => { - const onClose = vi.fn(); - render(); - await user.keyboard('{Escape}'); - expect(onClose).not.toHaveBeenCalled(); - }); - - it('shows loading spinner when isBusy is true', () => { - render(); - expect(screen.getByRole('progressbar')).toBeInTheDocument(); - }); - - it('does not show loading spinner when isBusy is false', () => { - render(); - expect(screen.queryByRole('progressbar')).toBeNull(); - }); - - it('removes keydown listener on unmount', async () => { - const onClose = vi.fn(); - const { unmount } = render(); - unmount(); - // fireEvent needed here: userEvent.keyboard targets focused element, but after unmount - // the event listener should be removed from document - fireEvent.keyDown(document, { key: 'Escape' }); - expect(onClose).not.toHaveBeenCalled(); - }); -}); diff --git a/frontend/src/components/SidePanel.test.tsx b/frontend/src/components/SidePanel.test.tsx deleted file mode 100644 index 8db2804b7ee..00000000000 --- a/frontend/src/components/SidePanel.test.tsx +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2018 The Kubeflow Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { render } from '@testing-library/react'; -import { vi } from 'vitest'; -import SidePanel from './SidePanel'; - -describe('SidePanel (unmocked)', () => { - it('does not emit legacy componentWillReceiveProps warnings from Resizable', () => { - const consoleErrorSpy = vi.spyOn(console, 'error'); - - try { - render( - - side panel content - , - ); - - const legacyLifecycleWarnings = consoleErrorSpy.mock.calls.filter(([message]) => - typeof message === 'string' - ? message.includes('componentWillReceiveProps') && message.includes('Resizable') - : false, - ); - - expect(legacyLifecycleWarnings).toHaveLength(0); - } finally { - consoleErrorSpy.mockRestore(); - } - }); -}); diff --git a/frontend/src/components/SidePanel.tsx b/frontend/src/components/SidePanel.tsx deleted file mode 100644 index 60a5048a26c..00000000000 --- a/frontend/src/components/SidePanel.tsx +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Copyright 2018 The Kubeflow Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import * as React from 'react'; -import CloseIcon from '@mui/icons-material/Close'; -import { Resizable } from 're-resizable'; -import { color, commonCss, zIndex } from '../Css'; -import { stylesheet } from 'typestyle'; - -import { Button, CircularProgress, Slide } from '@mui/material'; - -const css = stylesheet({ - closeButton: { - color: color.inactive, - margin: 15, - minHeight: 0, - minWidth: 0, - padding: 0, - }, - nodeName: { - flexGrow: 1, - textAlign: 'center', - }, - sidepane: { - backgroundColor: color.background, - borderLeft: 'solid 1px #ddd', - bottom: 0, - display: 'flex', - flexFlow: 'column', - position: 'absolute !important' as any, - right: 0, - top: 0, - zIndex: zIndex.SIDE_PANEL, - }, -}); - -interface SidePanelProps { - isBusy?: boolean; - isOpen: boolean; - onClose: () => void; - title: string; - defaultWidth?: string | number; - children?: React.ReactNode; -} - -class SidePanel extends React.Component { - public componentDidMount(): void { - document.addEventListener('keydown', this.handleKeyDown); - } - - public componentWillUnmount(): void { - document.removeEventListener('keydown', this.handleKeyDown); - } - - private handleKeyDown = (event: KeyboardEvent): void => { - if (event.key === 'Escape' && this.props.isOpen) { - this.props.onClose(); - } - }; - - public render(): React.JSX.Element { - const { isBusy, isOpen, onClose, title, defaultWidth } = this.props; - return ( - - {/* Wrapper div is required for MUI v5 Slide: Slide expects a single DOM element child that can receive a ref. */} -
- - {isOpen && ( -
-
- -
{title}
-
-
- {isBusy === true && ( - - )} - -
{this.props.children}
-
-
- )} -
-
-
- ); - } -} - -export default SidePanel; diff --git a/frontend/src/components/Toolbar.test.tsx b/frontend/src/components/Toolbar.test.tsx deleted file mode 100644 index 54995294d52..00000000000 --- a/frontend/src/components/Toolbar.test.tsx +++ /dev/null @@ -1,263 +0,0 @@ -/* - * Copyright 2018 The Kubeflow Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import * as React from 'react'; -import { fireEvent, render, screen } from '@testing-library/react'; -import { MemoryRouter } from 'react-router'; -import { vi } from 'vitest'; -import Toolbar, { ToolbarActionMap } from './Toolbar'; -import HelpIcon from '@mui/icons-material/Help'; -import InfoIcon from '@mui/icons-material/Info'; - -const action1 = vi.fn(); -const action2 = vi.fn(); -const actions: ToolbarActionMap = { - action1: { - action: action1, - disabledTitle: 'test disabled title', - icon: HelpIcon, - id: 'test id', - title: 'test title', - tooltip: 'test tooltip', - }, - action2: { - action: action2, - disabled: true, - disabledTitle: 'test disabled title2', - icon: InfoIcon, - id: 'test id2', - title: 'test title2', - tooltip: 'test tooltip2', - }, -}; - -const breadcrumbs = [ - { - displayName: 'test display name', - href: '/some/test/path', - }, - { - displayName: 'test display name2', - href: '/some/test/path2', - }, -]; - -const navigate = vi.fn(); - -function renderWithRouter(ui: React.ReactElement) { - return render({ui}); -} - -describe('Toolbar', () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.spyOn(window.history, 'length', 'get').mockReturnValue(2); - }); - - afterEach(() => vi.restoreAllMocks()); - - it('navigates Back through the native navigation function', () => { - renderWithRouter( - , - ); - fireEvent.click(screen.getByRole('button', { name: 'Back' })); - expect(navigate).toHaveBeenCalledWith(-1); - }); - - it('disables Back when the browser has no previous entry', () => { - vi.spyOn(window.history, 'length', 'get').mockReturnValue(1); - renderWithRouter( - , - ); - expect(screen.getByRole('button', { name: 'Back' })).toBeDisabled(); - }); - - it('renders nothing when there are no breadcrumbs or actions', () => { - const { container } = render( - , - ); - expect(container.firstChild).toBeNull(); - }); - - it('renders without breadcrumbs and a string page title', () => { - const { asFragment } = renderWithRouter( - , - ); - expect(asFragment()).toMatchSnapshot(); - }); - - it('renders without breadcrumbs and a component page title', () => { - const { asFragment } = renderWithRouter( - test page title
} - />, - ); - expect(asFragment()).toMatchSnapshot(); - }); - - it('renders without breadcrumbs and one action', () => { - const singleAction = { - action1: { - action: action1, - disabledTitle: 'test disabled title', - icon: HelpIcon, - id: 'test id', - title: 'test title', - tooltip: 'test tooltip', - }, - }; - const { asFragment } = renderWithRouter( - , - ); - expect(asFragment()).toMatchSnapshot(); - }); - - it('renders without actions and one breadcrumb', () => { - const { asFragment } = renderWithRouter( - , - ); - expect(asFragment()).toMatchSnapshot(); - }); - - it('renders without actions, one breadcrumb, and a page name', () => { - const { asFragment } = renderWithRouter( - , - ); - expect(asFragment()).toMatchSnapshot(); - }); - - it('renders without breadcrumbs and two actions', () => { - const { asFragment } = renderWithRouter( - , - ); - expect(asFragment()).toMatchSnapshot(); - }); - - it('fires the right action function when button is clicked', () => { - renderWithRouter( - , - ); - fireEvent.click(screen.getByRole('button', { name: 'test title' })); - expect(action1).toHaveBeenCalled(); - }); - - it('renders outlined action buttons', () => { - const outlinedActions = { - action1: { - action: vi.fn(), - id: 'test outlined id', - outlined: true, - title: 'test outlined title', - tooltip: 'test outlined tooltip', - }, - }; - - const { asFragment } = renderWithRouter( - , - ); - expect(asFragment()).toMatchSnapshot(); - }); - - it('renders primary action buttons', () => { - const primaryActions = { - action1: { - action: vi.fn(), - id: 'test primary id', - primary: true, - title: 'test primary title', - tooltip: 'test primary tooltip', - }, - }; - - const { asFragment } = renderWithRouter( - , - ); - expect(asFragment()).toMatchSnapshot(); - }); - - it('renders primary action buttons without outline, even if outline is true', () => { - const outlinedPrimaryActions = { - action1: { - action: vi.fn(), - id: 'test id', - outlined: true, - primary: true, - title: 'test title', - tooltip: 'test tooltip', - }, - }; - - const { asFragment } = renderWithRouter( - , - ); - expect(asFragment()).toMatchSnapshot(); - }); - - it('renders with two breadcrumbs and two actions', () => { - const { asFragment } = renderWithRouter( - , - ); - expect(asFragment()).toMatchSnapshot(); - }); -}); diff --git a/frontend/src/components/Toolbar.tsx b/frontend/src/components/Toolbar.tsx deleted file mode 100644 index b369cceec71..00000000000 --- a/frontend/src/components/Toolbar.tsx +++ /dev/null @@ -1,220 +0,0 @@ -/* - * Copyright 2018 The Kubeflow Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import ChevronRightIcon from '@mui/icons-material/ChevronRight'; -import * as React from 'react'; -import { CSSProperties } from 'react'; -import { Link, NavigateFunction } from 'react-router'; -import { classes, stylesheet } from 'typestyle'; -import BusyButton from '../atoms/BusyButton'; -import { color, commonCss, dimension, fonts, fontsize, spacing } from '../Css'; - -import { IconButton, Tooltip } from '@mui/material'; - -export interface ToolbarActionMap { - [key: string]: ToolbarActionConfig; -} - -export interface ToolbarActionConfig { - action: () => void; - busy?: boolean; - disabled?: boolean; - disabledTitle?: string; - icon?: any; - id?: string; - outlined?: boolean; - primary?: boolean; - style?: CSSProperties; - title: string; - tooltip: string; -} - -export interface Breadcrumb { - displayName: string; - href: string; -} - -const backIconHeight = 24; - -const css = stylesheet({ - actions: { - display: 'flex', - marginRight: spacing.units(-2), - paddingTop: 25, - alignItems: 'center', - }, - backIcon: { - fontSize: backIconHeight, - verticalAlign: 'bottom', - }, - backLink: { - cursor: 'pointer', - marginRight: 10, - padding: 3, - }, - breadcrumbs: { - color: color.inactive, - fontFamily: fonts.secondary, - fontSize: fontsize.small, - letterSpacing: 0.25, - margin: '10px 37px', - }, - chevron: { - height: 12, - }, - disabled: { - color: '#aaa', - }, - enabled: { - color: color.foreground, - }, - link: { - $nest: { - '&:hover': { - background: color.lightGrey, - }, - }, - borderRadius: 3, - padding: 3, - }, - pageName: { - color: color.strong, - fontSize: fontsize.pageTitle, - lineHeight: '28px', - }, - root: { - alignItems: 'center', - display: 'flex', - flexShrink: 0, - height: dimension.jumbo, - justifyContent: 'space-between', - }, - topLevelToolbar: { - borderBottom: `1px solid ${color.lightGrey}`, - paddingBottom: 15, - paddingLeft: 20, - }, -}); - -export interface ToolbarProps { - actions: ToolbarActionMap; - breadcrumbs: Breadcrumb[]; - navigate?: NavigateFunction; - pageTitle: string | React.JSX.Element; - pageTitleTooltip?: string; - topLevelToolbar?: boolean; -} - -class Toolbar extends React.Component { - public render(): React.JSX.Element | null { - const { actions, breadcrumbs, pageTitle, pageTitleTooltip } = { ...this.props }; - - if (!actions.length && !breadcrumbs.length && !pageTitle) { - return null; - } - - return ( -
-
- {/* Breadcrumb */} -
- {breadcrumbs.map((crumb, i) => ( - - {i !== 0 && } - - {crumb.displayName} - - - ))} -
-
- {/* Back Arrow */} - {breadcrumbs.length > 0 && ( - -
- {' '} - {/* Div needed because we sometimes disable a button within a tooltip */} - this.props.navigate?.(-1)} - size='large' - > - - -
-
- )} - {/* Resource Name */} - - {pageTitle} - -
-
- {/* Actions / Buttons */} -
- {Object.keys(actions).map((buttonKey, i) => { - const button = actions[buttonKey]; - return ( - -
- {/* Extra level needed by tooltip when child is disabled */} - -
-
- ); - })} -
-
- ); - } -} - -export default Toolbar; diff --git a/frontend/src/components/Trigger.test.tsx b/frontend/src/components/Trigger.test.tsx index 8136030dac6..d4131c932b5 100644 --- a/frontend/src/components/Trigger.test.tsx +++ b/frontend/src/components/Trigger.test.tsx @@ -19,517 +19,248 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { vi } from 'vitest'; import Trigger from './Trigger'; -const PARAMS_DEFAULT = { - catchup: true, - maxConcurrentRuns: '10', -}; -const PERIODIC_DEFAULT = { - end_time: undefined, - interval_second: (60 * 60).toString(), - start_time: undefined, -}; -const CRON_DEFAULT = { cron: '0 0 * * * ?', end_time: undefined, start_time: undefined }; +const defaults = { catchup: true, maxConcurrentRuns: '10' }; +const periodic = { interval_second: '3600', start_time: undefined, end_time: undefined }; -async function selectOption(selectElement: HTMLElement, optionText: string): Promise { - fireEvent.mouseDown(selectElement); - const option = await screen.findByRole('option', { name: optionText }); - fireEvent.click(option); +function change(label: string, value: string) { + fireEvent.change(screen.getByLabelText(label, { exact: true }), { target: { value } }); } -function getTriggerTypeSelect(): HTMLElement { - return screen.getByRole('combobox', { name: /trigger type/i }); +function openAdvanced() { + fireEvent.click(screen.getByText('Advanced execution settings', { exact: true })); } -function getIntervalCategorySelect(): HTMLElement { - const comboboxes = screen.getAllByRole('combobox'); - return comboboxes[1]; -} - -describe('Trigger', () => { - const RealDate = Date; - - function mockDate(isoDate: Date): void { - (global as any).Date = class extends RealDate { - constructor(...args: any[]) { - super(); - if (args.length === 0) { - return new RealDate(isoDate); - } - return new (RealDate as any)(...args); - } - }; - } - - beforeAll(() => { - process.env.TZ = 'UTC'; - const now = new Date(2018, 11, 21, 7, 53); - mockDate(now); +describe('Trigger schedule editor', () => { + it('defaults to an hourly interval and hides optional boundaries', () => { + const onChange = vi.fn(); + render(); + expect(screen.getByLabelText('Repeat')).toHaveValue('interval'); + expect(screen.queryByLabelText('Start date')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('End date')).not.toBeInTheDocument(); + expect(onChange).toHaveBeenLastCalledWith({ + ...defaults, + trigger: { periodic_schedule: periodic }, + }); }); - afterAll(() => { - (global as any).Date = RealDate; + it('keeps fixed intervals separate from calendar schedules', () => { + const onChange = vi.fn(); + render(); + change('Interval', '3'); + change('Interval unit', 'Hour'); + expect(onChange).toHaveBeenLastCalledWith({ + ...defaults, + trigger: { + periodic_schedule: { ...periodic, interval_second: '10800' }, + }, + }); + expect(screen.queryByRole('option', { name: 'Months' })).not.toBeInTheDocument(); }); - it('renders periodic schedule controls for initial render', () => { - const { asFragment, unmount } = render(); - expect(asFragment()).toMatchSnapshot(); - unmount(); + it.each([ + ['daily', '0 0 9 * * *'], + ['weekly', '0 0 9 * * 1,2,3,4,5'], + ['monthly', '0 0 9 1 * *'], + ])('builds the %s preset as a calendar schedule', (repeat, cron) => { + const onChange = vi.fn(); + render(); + change('Repeat', repeat); + expect(onChange).toHaveBeenLastCalledWith({ + ...defaults, + trigger: { + cron_schedule: { cron, start_time: undefined, end_time: undefined }, + }, + }); }); - it('renders periodic schedule controls if the trigger type is CRON', async () => { - const { asFragment, unmount } = render(); - await selectOption(getTriggerTypeSelect(), 'Cron'); - expect(asFragment()).toMatchSnapshot(); - unmount(); + it('updates calendar time and day independently of boundaries', () => { + const onChange = vi.fn(); + render(); + change('Repeat', 'monthly'); + change('Day of month', '15'); + change('Run at', '14:30'); + expect(onChange).toHaveBeenLastCalledWith({ + ...defaults, + trigger: { + cron_schedule: { cron: '0 30 14 15 * *', start_time: undefined, end_time: undefined }, + }, + }); }); - it('renders week days if the trigger type is CRON and interval is weekly', async () => { - const { asFragment, unmount } = render(); - await selectOption(getTriggerTypeSelect(), 'Cron'); - await selectOption(getIntervalCategorySelect(), 'Week'); - expect(asFragment()).toMatchSnapshot(); - unmount(); + it('supports weekday selection and rejects an empty selection', () => { + const onChange = vi.fn(); + render(); + change('Repeat', 'weekly'); + for (const day of ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday']) { + fireEvent.click(screen.getByRole('button', { name: day, exact: true })); + } + expect(onChange).toHaveBeenLastCalledWith({ ...defaults, trigger: undefined }); + fireEvent.click(screen.getByRole('button', { name: 'Saturday', exact: true })); + expect(onChange).toHaveBeenLastCalledWith({ + ...defaults, + trigger: { + cron_schedule: { cron: '0 0 9 * * 6', start_time: undefined, end_time: undefined }, + }, + }); }); - it('renders all week days enabled', async () => { - const { asFragment, unmount } = render(); - await selectOption(getTriggerTypeSelect(), 'Cron'); - await selectOption(getIntervalCategorySelect(), 'Week'); - fireEvent.click(screen.getByRole('checkbox', { name: 'All' })); - expect(asFragment()).toMatchSnapshot(); - unmount(); + it('validates custom cron and recovers without silently changing it', () => { + const onChange = vi.fn(); + render(); + change('Repeat', 'custom'); + change('Cron expression', 'invalid cron'); + expect(onChange).toHaveBeenLastCalledWith({ ...defaults, trigger: undefined }); + change('Cron expression', '0 15 8 ? * 1-5'); + expect(onChange).toHaveBeenLastCalledWith({ + ...defaults, + trigger: { + cron_schedule: { cron: '0 15 8 ? * 1-5', start_time: undefined, end_time: undefined }, + }, + }); }); - it('enables a single day on click', async () => { - const { asFragment, unmount } = render(); - await selectOption(getTriggerTypeSelect(), 'Cron'); - await selectOption(getIntervalCategorySelect(), 'Week'); - fireEvent.click(screen.getByRole('button', { name: 'M' })); - fireEvent.click(screen.getByRole('button', { name: 'W' })); - expect(asFragment()).toMatchSnapshot(); - unmount(); + it.each(['0', '-1', '1.5'])('rejects invalid interval %s', (value) => { + const onChange = vi.fn(); + render(); + change('Interval unit', 'Second'); + change('Interval', value); + expect(onChange).toHaveBeenLastCalledWith({ ...defaults, trigger: undefined }); }); - describe('max concurrent run', () => { - it('shows error message if the input is invalid (non-integer)', () => { - render(); - const maxConcurrencyParam = screen.getByDisplayValue('10'); - fireEvent.change(maxConcurrencyParam, { target: { value: '10a' } }); - screen.getByText('Invalid input. The maximum concurrent runs should be a positive integer.'); - }); - - it('shows error message if the input is invalid (negative integer)', () => { - render(); - const maxConcurrencyParam = screen.getByDisplayValue('10'); - fireEvent.change(maxConcurrencyParam, { target: { value: '-10' } }); - screen.getByText('Invalid input. The maximum concurrent runs should be a positive integer.'); + it('accepts fractional hours that produce whole seconds', () => { + const onChange = vi.fn(); + render(); + change('Interval', '1.5'); + expect(onChange).toHaveBeenLastCalledWith({ + ...defaults, + trigger: { periodic_schedule: { ...periodic, interval_second: '5400' } }, }); }); - describe('interval trigger', () => { - it('builds an every-hour trigger by default', () => { - const spy = vi.fn(); - render(); - - expect(spy).toHaveBeenLastCalledWith({ - ...PARAMS_DEFAULT, - trigger: { - periodic_schedule: PERIODIC_DEFAULT, - }, - }); - }); - - it('builds trigger with a start time if the checkbox is checked', () => { - const spy = vi.fn(); - render(); - fireEvent.click(screen.getByRole('checkbox', { name: 'Has start date' })); - const now = new Date(2018, 11, 21, 7, 53); - expect(spy).toHaveBeenLastCalledWith({ - ...PARAMS_DEFAULT, - trigger: { - periodic_schedule: { ...PERIODIC_DEFAULT, start_time: now }, - }, - }); - }); - - it('builds trigger with the entered start date/time', () => { - const spy = vi.fn(); - render(); - fireEvent.click(screen.getByRole('checkbox', { name: 'Has start date' })); - fireEvent.change(screen.getByLabelText('Start date'), { - target: { value: '2018-11-23' }, - }); - fireEvent.change(screen.getByLabelText('Start time'), { - target: { value: '08:35' }, - }); - expect(spy).toHaveBeenLastCalledWith({ - ...PARAMS_DEFAULT, - trigger: { - periodic_schedule: { - ...PERIODIC_DEFAULT, - start_time: new Date(2018, 10, 23, 8, 35), - }, - }, - }); - }); - - it('builds trigger without the entered start date if no time is entered', () => { - const spy = vi.fn(); - render(); - fireEvent.click(screen.getByRole('checkbox', { name: 'Has start date' })); - fireEvent.change(screen.getByLabelText('Start date'), { - target: { value: '2018-11-23' }, - }); - fireEvent.change(screen.getByLabelText('Start time'), { - target: { value: '' }, - }); - expect(spy).toHaveBeenLastCalledWith({ - ...PARAMS_DEFAULT, - trigger: { - periodic_schedule: PERIODIC_DEFAULT, - }, - }); - }); - - it('builds trigger without the entered start time if no date is entered', () => { - const spy = vi.fn(); - render(); - fireEvent.click(screen.getByRole('checkbox', { name: 'Has start date' })); - fireEvent.change(screen.getByLabelText('Start date'), { - target: { value: '' }, - }); - fireEvent.change(screen.getByLabelText('Start time'), { - target: { value: '11:33' }, - }); - expect(spy).toHaveBeenLastCalledWith({ - ...PARAMS_DEFAULT, - trigger: { - periodic_schedule: PERIODIC_DEFAULT, - }, - }); - }); - - it('builds trigger with a date if both start and end checkboxes are checked', () => { - const spy = vi.fn(); - render(); - fireEvent.click(screen.getByRole('checkbox', { name: 'Has start date' })); - fireEvent.click(screen.getByRole('checkbox', { name: 'Has end date' })); - const now = new Date(2018, 11, 21, 7, 53); - const oneWeekLater = new Date(2018, 11, 28, 7, 53); - expect(spy).toHaveBeenLastCalledWith({ - ...PARAMS_DEFAULT, - trigger: { - periodic_schedule: { ...PERIODIC_DEFAULT, end_time: oneWeekLater, start_time: now }, - }, - }); - }); - - it('resets trigger to no start date if it is added then removed', () => { - const spy = vi.fn(); - render(); - fireEvent.click(screen.getByRole('checkbox', { name: 'Has start date' })); - fireEvent.click(screen.getByRole('checkbox', { name: 'Has start date' })); - expect(spy).toHaveBeenLastCalledWith({ - ...PARAMS_DEFAULT, - trigger: { - periodic_schedule: PERIODIC_DEFAULT, - }, - }); - }); - - it('Show invalid start date/time format message if date has wrong format.', async () => { - render(); - fireEvent.click(screen.getByRole('checkbox', { name: 'Has start date' })); - - // jsdom sanitizes type="date" inputs, rejecting non-YYYY-MM-DD values. - // Temporarily switch to type="text" so the invalid value reaches handleChange. - const startDateInput = screen.getByLabelText('Start date') as HTMLInputElement; - startDateInput.type = 'text'; - fireEvent.change(startDateInput, { - target: { value: 'this_is_not_valid_date_format' }, - }); - expect( - await screen.findByText("Invalid start date or time, start time won't be set"), - ).toBeInTheDocument(); - - fireEvent.change(screen.getByLabelText('Start date'), { - target: { value: '2021-01-01' }, - }); - expect( - screen.queryByText("Invalid start date or time, start time won't be set"), - ).not.toBeInTheDocument(); - }); - - it('Hide invalid start date/time format message if start time checkbox is not selected.', async () => { - render(); - fireEvent.click(screen.getByRole('checkbox', { name: 'Has start date' })); - - const startDateInput = screen.getByLabelText('Start date') as HTMLInputElement; - startDateInput.type = 'text'; - fireEvent.change(startDateInput, { - target: { value: 'this_is_not_valid_date_format' }, - }); - expect( - await screen.findByText("Invalid start date or time, start time won't be set"), - ).toBeInTheDocument(); - - fireEvent.click(screen.getByRole('checkbox', { name: 'Has start date' })); - expect( - screen.queryByText("Invalid start date or time, start time won't be set"), - ).not.toBeInTheDocument(); - }); - - it('Show invalid end date/time format message if date has wrong format.', async () => { - render(); - fireEvent.click(screen.getByRole('checkbox', { name: 'Has end date' })); - - const endTimeInput = screen.getByLabelText('End time') as HTMLInputElement; - endTimeInput.type = 'text'; - fireEvent.change(endTimeInput, { - target: { value: 'this_is_not_valid_time_format' }, - }); - expect( - await screen.findByText("Invalid end date or time, end time won't be set"), - ).toBeInTheDocument(); - - fireEvent.change(screen.getByLabelText('End time'), { - target: { value: '11:22' }, - }); - expect( - screen.queryByText("Invalid end date or time, end time won't be set"), - ).not.toBeInTheDocument(); - }); - - it('Hide invalid end date/time format message if start time checkbox is not selected.', async () => { - render(); - fireEvent.click(screen.getByRole('checkbox', { name: 'Has end date' })); - - const endTimeInput = screen.getByLabelText('End time') as HTMLInputElement; - endTimeInput.type = 'text'; - fireEvent.change(endTimeInput, { - target: { value: 'this_is_not_valid_date_format' }, - }); - expect( - await screen.findByText("Invalid end date or time, end time won't be set"), - ).toBeInTheDocument(); - - fireEvent.click(screen.getByRole('checkbox', { name: 'Has end date' })); - expect( - screen.queryByText("Invalid end date or time, end time won't be set"), - ).not.toBeInTheDocument(); - }); - - it('builds trigger with a weekly interval', async () => { - const spy = vi.fn(); - render(); - await selectOption(getIntervalCategorySelect(), 'Weeks'); - expect(spy).toHaveBeenLastCalledWith({ - ...PARAMS_DEFAULT, - trigger: { - periodic_schedule: { - ...PERIODIC_DEFAULT, - interval_second: (7 * 24 * 60 * 60).toString(), - }, - }, - }); - }); - - it('builds trigger with an every-three-months interval', async () => { - const spy = vi.fn(); - render(); - await selectOption(getIntervalCategorySelect(), 'Months'); - fireEvent.change(screen.getByRole('spinbutton'), { target: { value: 3 } }); - expect(spy).toHaveBeenLastCalledWith({ - ...PARAMS_DEFAULT, - trigger: { - periodic_schedule: { - ...PERIODIC_DEFAULT, - interval_second: (3 * 30 * 24 * 60 * 60).toString(), - }, - }, - }); + it('keeps concurrency and catchup settings in advanced options', () => { + const onChange = vi.fn(); + render(); + openAdvanced(); + change('Maximum concurrent runs', '3'); + fireEvent.click( + screen.getByRole('checkbox', { name: 'Run missed schedules when resumed', exact: true }), + ); + expect(onChange).toHaveBeenLastCalledWith({ + catchup: false, + maxConcurrentRuns: '3', + trigger: { periodic_schedule: periodic }, }); + }); - it('builds trigger with the specified max concurrency setting', () => { - const spy = vi.fn(); - render(); - fireEvent.change(screen.getByDisplayValue('10'), { target: { value: '3' } }); - expect(spy).toHaveBeenLastCalledWith({ - ...PARAMS_DEFAULT, - maxConcurrentRuns: '3', - trigger: { - periodic_schedule: PERIODIC_DEFAULT, - }, - }); - }); + it.each(['0', '-1', '1.5', '11', 'abc'])('rejects invalid concurrency %s', (value) => { + const onChange = vi.fn(); + render(); + openAdvanced(); + change('Maximum concurrent runs', value); + expect(onChange.mock.lastCall?.[0].maxConcurrentRuns).toBeUndefined(); + }); - it('builds trigger with the specified catchup setting', () => { - const spy = vi.fn(); - render(); - fireEvent.click(screen.getByRole('checkbox', { name: 'Catchup' })); - expect(spy).toHaveBeenLastCalledWith({ - ...PARAMS_DEFAULT, - catchup: false, - trigger: { - periodic_schedule: PERIODIC_DEFAULT, + it.each(['Start', 'End'])( + 'validates %s boundaries and removes them when deselected', + (boundary) => { + const onChange = vi.fn(); + render(); + change(boundary === 'Start' ? 'Starts' : 'Ends', 'date'); + change(`${boundary} date`, '2026-10-20'); + change(`${boundary} time`, ''); + expect(onChange).toHaveBeenLastCalledWith({ ...defaults, trigger: undefined }); + expect(screen.getByLabelText(`${boundary} time`)).toBeInvalid(); + change(`${boundary} time`, '08:35'); + expect(screen.getByLabelText(`${boundary} time`)).toBeValid(); + change(boundary === 'Start' ? 'Starts' : 'Ends', boundary === 'Start' ? 'now' : 'never'); + expect(onChange).toHaveBeenLastCalledWith({ + ...defaults, + trigger: { periodic_schedule: periodic }, + }); + }, + ); + + it('converts chosen local boundaries to instants and rejects reversed windows', () => { + const onChange = vi.fn(); + render(); + change('Starts', 'date'); + change('Start date', '2026-10-20'); + change('Start time', '08:35'); + change('Ends', 'date'); + change('End date', '2026-10-21'); + change('End time', '18:45'); + expect(onChange).toHaveBeenLastCalledWith({ + ...defaults, + trigger: { + periodic_schedule: { + ...periodic, + start_time: new Date(2026, 9, 20, 8, 35), + end_time: new Date(2026, 9, 21, 18, 45), }, - }); + }, }); + change('End date', '2026-10-19'); + expect(onChange).toHaveBeenLastCalledWith({ ...defaults, trigger: undefined }); + }); - it('inits with cloned initial props', () => { - const spy = vi.fn(); - const startTime = new Date('2020-01-01T23:53:00.000Z'); + it.each(['17 15 8 ? * 1-5', '@every 90s', '@every .5h', '@every +1h', '@every 0'])( + 'preserves existing cron %s and timestamp precision on unrelated changes', + (cron) => { + const onChange = vi.fn(); + const start_time = new Date('2026-10-20T08:03:27.123Z'); + const end_time = new Date('2026-11-20T18:49:53.456Z'); render( , ); - expect(spy).toHaveBeenLastCalledWith({ + expect(screen.getByLabelText('Repeat')).toHaveValue('custom'); + expect(screen.getByLabelText('Cron expression')).toHaveValue(cron); + expect(onChange).toHaveBeenLastCalledWith({ catchup: false, - maxConcurrentRuns: '3', - trigger: { - periodic_schedule: { - end_time: undefined, - interval_second: '10800', - start_time: startTime, - }, - }, - }); - }); - }); - - describe('cron', () => { - it('builds a 1-hour cron trigger by default', async () => { - const spy = vi.fn(); - render(); - await selectOption(getTriggerTypeSelect(), 'Cron'); - expect(spy).toHaveBeenLastCalledWith({ - ...PARAMS_DEFAULT, - trigger: { - cron_schedule: CRON_DEFAULT, - }, - }); - }); - - it('builds a 1-hour cron trigger with specified start date', async () => { - const spy = vi.fn(); - render(); - await selectOption(getTriggerTypeSelect(), 'Cron'); - fireEvent.click(screen.getByRole('checkbox', { name: 'Has start date' })); - fireEvent.change(screen.getByLabelText('Start date'), { - target: { value: '2018-03-23' }, - }); - expect(spy).toHaveBeenLastCalledWith({ - ...PARAMS_DEFAULT, - trigger: { - cron_schedule: { - ...CRON_DEFAULT, - start_time: new Date(2018, 2, 23, 7, 53), - cron: '0 53 * * * ?', - }, - }, - }); - }); - - it('builds a daily cron trigger with specified end date/time', async () => { - const spy = vi.fn(); - render(); - await selectOption(getTriggerTypeSelect(), 'Cron'); - fireEvent.click(screen.getByRole('checkbox', { name: 'Has end date' })); - await selectOption(getIntervalCategorySelect(), 'Day'); - const oneWeekLater = new Date(2018, 11, 28, 7, 53); - expect(spy).toHaveBeenLastCalledWith({ - ...PARAMS_DEFAULT, - trigger: { - cron_schedule: { ...CRON_DEFAULT, end_time: oneWeekLater, cron: '0 0 0 * * ?' }, - }, - }); - }); - - it('builds a weekly cron trigger that runs every Monday, Friday, and Saturday', async () => { - const spy = vi.fn(); - render(); - await selectOption(getTriggerTypeSelect(), 'Cron'); - await selectOption(getIntervalCategorySelect(), 'Week'); - - fireEvent.click(screen.getByRole('checkbox', { name: 'All' })); - fireEvent.click(screen.getByRole('button', { name: 'M' })); - fireEvent.click(screen.getByRole('button', { name: 'F' })); - // Two "S" buttons exist (Sun, Sat); pick the second = Saturday - fireEvent.click(screen.getAllByRole('button', { name: 'S' })[1]); - expect(spy).toHaveBeenLastCalledWith({ - ...PARAMS_DEFAULT, - trigger: { - cron_schedule: { ...CRON_DEFAULT, cron: '0 0 0 ? * 1,5,6' }, - }, - }); - }); - - it('builds a cron with the manually specified cron string, even if days are toggled', async () => { - const spy = vi.fn(); - render(); - await selectOption(getTriggerTypeSelect(), 'Cron'); - await selectOption(getIntervalCategorySelect(), 'Week'); - - fireEvent.click(screen.getByRole('checkbox', { name: 'All' })); - fireEvent.click(screen.getByRole('button', { name: 'M' })); - fireEvent.click(screen.getByRole('button', { name: 'F' })); - // Two "S" buttons exist (Sun, Sat); pick the second = Saturday - fireEvent.click(screen.getAllByRole('button', { name: 'S' })[1]); - - fireEvent.click(screen.getByRole('checkbox', { name: /allow editing cron expression/i })); - fireEvent.change(screen.getByLabelText('cron expression'), { - target: { value: 'oops this will break!' }, + maxConcurrentRuns: '4', + trigger: { cron_schedule: { cron, start_time, end_time } }, }); - expect(spy).toHaveBeenLastCalledWith({ - ...PARAMS_DEFAULT, - trigger: { - cron_schedule: { ...CRON_DEFAULT, cron: 'oops this will break!' }, - }, + openAdvanced(); + change('Maximum concurrent runs', '5'); + expect(onChange).toHaveBeenLastCalledWith({ + catchup: false, + maxConcurrentRuns: '5', + trigger: { cron_schedule: { cron, start_time, end_time } }, }); - }); + }, + ); - it('inits with cloned initial props', () => { - const spy = vi.fn(); - const startTime = new Date('2020-01-01T00:00:00.000Z'); - const endTime = new Date('2020-01-02T01:02:00.000Z'); + it.each(['90', '3661', '7776000'])( + 'preserves existing exact interval %s seconds', + (interval_second) => { + const onChange = vi.fn(); render( , ); - expect(spy).toHaveBeenLastCalledWith({ - catchup: true, - maxConcurrentRuns: '4', + expect(onChange).toHaveBeenLastCalledWith({ + ...defaults, trigger: { - cron_schedule: { - cron: '0 0 0 ? * 1,5,6', - start_time: startTime, - end_time: endTime, - }, + periodic_schedule: { ...periodic, interval_second }, }, }); - }); - }); + openAdvanced(); + change('Maximum concurrent runs', '2'); + expect(onChange).toHaveBeenLastCalledWith({ + ...defaults, + maxConcurrentRuns: '2', + trigger: { + periodic_schedule: { ...periodic, interval_second }, + }, + }); + }, + ); }); diff --git a/frontend/src/components/Trigger.tsx b/frontend/src/components/Trigger.tsx index f63f8ddc3b3..7023f37735d 100644 --- a/frontend/src/components/Trigger.tsx +++ b/frontend/src/components/Trigger.tsx @@ -15,516 +15,336 @@ */ import * as React from 'react'; -import { classes, stylesheet } from 'typestyle'; -import { HelpButton } from '../atoms/HelpButton'; -import { ExternalLink } from '../atoms/ExternalLink'; -import Input from '../atoms/Input'; -import Separator from '../atoms/Separator'; -import { color, commonCss, padding } from '../Css'; -import { - buildCron, - buildTrigger, - dateToPickerFormat, - PeriodicInterval, - TriggerSchedule, - pickersToDate, - triggers, - TriggerType, - parseTrigger, - ParsedTrigger, -} from '../lib/TriggerUtils'; -import { logger } from 'src/lib/Utils'; +import { TextField } from './ui/text-field'; +import { Button } from './ui/button'; +import { Checkbox } from './ui/checkbox'; +import { dateToPickerFormat, pickersToDate, TriggerSchedule } from '../lib/TriggerUtils'; +import { scheduleDescription, describeCron } from '../lib/ScheduleDisplay'; +import './RunFormFields.css'; -import { Fab, Checkbox, FormControlLabel, MenuItem } from '@mui/material'; - -type TriggerInitialProps = { - maxConcurrentRuns?: string; - catchup?: boolean; +type Preset = 'interval' | 'daily' | 'weekly' | 'monthly' | 'custom'; +const units = { Second: 1, Minute: 60, Hour: 3600, Day: 86400, Week: 604800 }; +type Unit = keyof typeof units; +interface Config { trigger?: TriggerSchedule; -}; - -interface TriggerProps { - initialProps?: TriggerInitialProps; - onChange?: (config: { - trigger?: TriggerSchedule; - maxConcurrentRuns?: string; - catchup: boolean; - }) => void; + maxConcurrentRuns?: string; + catchup: boolean; } - -interface TriggerState { +interface Props { + initialProps?: Partial; + onChange?: (config: Config) => void; +} +interface Boundary { + enabled: boolean; + date: string; + time: string; + original?: Date; +} +interface State { + preset: Preset; + interval: string; + unit: Unit; cron: string; - editCron: boolean; - endDate: string; - endTime: string; - hasEndDate: boolean; - hasStartDate: boolean; - intervalCategory: PeriodicInterval; - intervalValue: number; - maxConcurrentRuns: string; - selectedDays: boolean[]; - startDate: string; - startTime: string; - type: TriggerType; + originalCron?: string; + runAt: string; + days: number[]; + monthDay: string; + start: Boundary; + end: Boundary; + concurrency: string; catchup: boolean; - startTimeMessage: string; - endTimeMessage: string; } - -const css = stylesheet({ - noMargin: { - margin: 0, - }, - alert: { - color: color.alert, - }, -}); - -export default class Trigger extends React.Component { - public state: TriggerState = (() => { - const { maxConcurrentRuns, catchup, trigger } = - this.props.initialProps || ({} as TriggerInitialProps); - let parsedTrigger: Partial = {}; - try { - if (trigger) { - parsedTrigger = parseTrigger(trigger); - } - } catch (err) { - logger.warn('Failed to parse original trigger: ', trigger); - logger.warn(err); +function boundary(value: Date | undefined, fallback: Date): Boundary { + const original = value ? new Date(value) : undefined; + const [date, time] = dateToPickerFormat(original || fallback); + return { enabled: !!value, date, time, original }; +} +function boundaryValue(value: Boundary): Date | undefined { + if (!value.enabled) return undefined; + if (!value.date || !value.time) throw new Error('Choose both a date and time.'); + // Untouched clones retain seconds/milliseconds and their original instant across DST folds. + if (value.original) { + const [date, time] = dateToPickerFormat(value.original); + if (date === value.date && time === value.time) return value.original; + } + return pickersToDate(true, value.date, value.time); +} +function schedule(state: State): { trigger?: TriggerSchedule; error?: string } { + try { + const start_time = boundaryValue(state.start); + const end_time = boundaryValue(state.end); + if (start_time && end_time && end_time <= start_time) + throw new Error('End must be after start.'); + if (state.preset === 'interval') { + const count = Number(state.interval); + const seconds = count * units[state.unit]; + if (!Number.isSafeInteger(seconds) || seconds < 1 || count <= 0) + throw new Error('Enter a positive interval in whole seconds.'); + return { + trigger: { periodic_schedule: { interval_second: String(seconds), start_time, end_time } }, + }; } - const startDateTime = parsedTrigger.startDateTime ?? new Date(); - const endDateTime = - parsedTrigger.endDateTime ?? - new Date( - startDateTime.getFullYear(), - startDateTime.getMonth(), - startDateTime.getDate() + 7, - startDateTime.getHours(), - startDateTime.getMinutes(), - ); - const [startDate, startTime] = dateToPickerFormat(startDateTime); - const [endDate, endTime] = dateToPickerFormat(endDateTime); + let cron = state.cron; + if (state.preset !== 'custom') { + if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(state.runAt)) + throw new Error('Choose a valid run time.'); + const [hour, minute] = state.runAt.split(':').map(Number); + if (state.preset === 'weekly' && !state.days.length) + throw new Error('Choose at least one day.'); + if ( + state.preset === 'monthly' && + (!/^\d+$/.test(state.monthDay) || +state.monthDay < 1 || +state.monthDay > 31) + ) + throw new Error('Choose a day from 1 to 31.'); + cron = `0 ${minute} ${hour} ${state.preset === 'monthly' ? state.monthDay : '*'} * ${state.preset === 'weekly' ? state.days.join(',') : '*'}`; + } + if (cron !== state.originalCron) describeCron(cron); + return { trigger: { cron_schedule: { cron, start_time, end_time } } }; + } catch (error) { + return { error: error instanceof Error ? error.message : 'Enter a valid cron expression.' }; + } +} +export default class Trigger extends React.Component { + public state: State = (() => { + const initial = this.props.initialProps; + const trigger = initial?.trigger; + const source = trigger?.cron_schedule || trigger?.periodic_schedule; + const seconds = Number(trigger?.periodic_schedule?.interval_second || 3600); + const unit = + (['Week', 'Day', 'Hour', 'Minute', 'Second'] as Unit[]).find( + (unit) => seconds > 0 && seconds % units[unit] === 0, + ) || 'Second'; + const today = new Date(); return { - catchup: catchup ?? true, - maxConcurrentRuns: maxConcurrentRuns || '10', - hasEndDate: !!parsedTrigger?.endDateTime, - endDate, - endTime, - hasStartDate: !!parsedTrigger?.startDateTime, - startDate, - startTime, - selectedDays: new Array(7).fill(true), - type: parsedTrigger.type ?? TriggerType.INTERVALED, - // cron state - editCron: parsedTrigger.type === TriggerType.CRON, - cron: parsedTrigger.cron || '', - // interval state - intervalCategory: parsedTrigger.intervalCategory ?? PeriodicInterval.HOUR, - intervalValue: parsedTrigger.intervalValue ?? 1, - startTimeMessage: '', - endTimeMessage: '', + preset: trigger?.cron_schedule ? 'custom' : 'interval', + interval: String(seconds / units[unit]), + unit, + cron: trigger?.cron_schedule?.cron || '', + originalCron: trigger?.cron_schedule?.cron, + runAt: '09:00', + days: [1, 2, 3, 4, 5], + monthDay: '1', + start: boundary(source?.start_time, today), + end: boundary(source?.end_time, new Date(today.getTime() + 7 * 86400000)), + concurrency: initial?.maxConcurrentRuns ?? '10', + catchup: initial?.catchup ?? true, }; })(); - public componentDidMount(): void { - // TODO: This is called here because NewRun only updates its Trigger in state when onChange is - // called on the Trigger, which without this may never happen if a user doesn't interact with - // the Trigger. NewRun should probably keep the Trigger state and pass it down as a prop to this - this._updateTrigger(); + this.publish(); } - - public render(): React.JSX.Element { - const { - cron, - editCron, - endDate, - endTime, - hasEndDate, - hasStartDate, - intervalCategory, - intervalValue, - maxConcurrentRuns, - selectedDays, - startDate, - startTime, - type, - catchup, - startTimeMessage, - endTimeMessage, - } = this.state; - + private publish = () => { + this.props.onChange?.({ + trigger: schedule(this.state).trigger, + maxConcurrentRuns: + Number.isInteger(+this.state.concurrency) && + +this.state.concurrency >= 1 && + +this.state.concurrency <= 10 + ? this.state.concurrency + : undefined, + catchup: this.state.catchup, + }); + }; + private change = (patch: Partial) => + this.setState(patch as Pick, this.publish); + private renderBoundary = (key: 'start' | 'end') => { + const value = this.state[key]; + const label = key === 'start' ? 'Start' : 'End'; + const update = (patch: Partial) => this.change({ [key]: { ...value, ...patch } }); return ( -
- - {Array.from(triggers.entries()).map((trigger, i) => ( - - {trigger[1].displayName} - - ))} - - -
- - {!(Number.isInteger(Number(maxConcurrentRuns)) && Number(maxConcurrentRuns) > 0) && ( -
- {'Invalid input. The maximum concurrent runs should be a positive integer.'} -
- )} - -
- - } - label='Has start date' - /> - - - -
-
+ + {value.enabled && ( +
+ update({ date: e.target.value, original: undefined })} /> - - update({ time: e.target.value, original: undefined })} />
-
- {endTimeMessage} -
- - - } - label='Catchup' + )} +
+ ); + }; + public render(): React.JSX.Element { + const s = this.state; + const result = schedule(s); + const concurrencyValid = + Number.isInteger(Number(s.concurrency)) && +s.concurrency >= 1 && +s.concurrency <= 10; + const localZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + return ( +
+ Schedule +
+ + {s.preset === 'interval' && ( +
+ this.change({ interval: e.target.value })} + /> + +
+ )} + {['daily', 'weekly', 'monthly'].includes(s.preset) && ( + this.change({ runAt: e.target.value })} /> - -

- Whether the recurring run should catch up if behind schedule. Defaults to true. -

-

- For example, if the recurring run is paused for a while and re-enabled - afterwards. If catchup=true, the scheduler will catch up on (backfill) each - missed interval. Otherwise, it only schedules the latest interval if more than - one interval is ready to be scheduled. -

-

- Usually, if your pipeline handles backfill internally, you should turn catchup - off to avoid duplicate backfill. -

-
- } + )} + {s.preset === 'monthly' && ( + this.change({ monthDay: e.target.value })} /> - - - - Run every - {type === TriggerType.INTERVALED && ( -
- - -
- )} - - - {(Object.keys(PeriodicInterval) as Array).map( - (interval, i) => ( - - {PeriodicInterval[interval] + (type === TriggerType.INTERVALED ? 's' : '')} - - ), - )} - -
+ )}
- - {type === TriggerType.CRON && ( -
- {intervalCategory === PeriodicInterval.WEEK && ( -
- On: - + {s.preset === 'weekly' && ( +
+ {['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'].map( + (day, index) => ( +
+ > + {day.slice(0, 3)} + + ), )} - -
- - } - label={ - - Allow editing cron expression. (format is specified{' '} - - here - - ) - - } - /> -
- - + )} + {s.preset === 'custom' && ( +
+ this.change({ cron: e.target.value })} + error={result.error} /> - -
Note: Start and end dates/times are handled outside of cron.
+

+ Seconds · minutes · hours · day of month · month · day of week. Example: 0 30 9 * * + MON-FRI. +

)} -
- ); - } - - public handleChange = (name: string) => (event: any) => { - const target = event.target; - const value = target.type === 'checkbox' ? target.checked : target.value; - // Make sure the desired field is set on the state object first, then - // use the state values to compute the new trigger - this.setState( - { - [name]: value, - } as any, - this._updateTrigger, - ); - }; - - private _updateTrigger = () => { - const { - hasStartDate, - hasEndDate, - startDate, - startTime, - endDate, - endTime, - editCron, - intervalCategory, - intervalValue, - type, - cron, - selectedDays, - catchup, - } = this.state; - - var startDateTime: Date | undefined = undefined; - var endDateTime: Date | undefined = undefined; - var startTimeMessage = ''; - var endTimeMessage = ''; - - try { - if (hasStartDate) { - startDateTime = pickersToDate(hasStartDate, startDate, startTime); - } - } catch (e) { - if (e instanceof Error && e.message === 'Invalid picker format') { - startTimeMessage = "Invalid start date or time, start time won't be set"; - } else { - throw e; - } - } - - try { - if (hasEndDate) { - endDateTime = pickersToDate(hasEndDate, endDate, endTime); - } - } catch (e) { - if (e instanceof Error && e.message === 'Invalid picker format') { - endTimeMessage = "Invalid end date or time, end time won't be set"; - } else { - throw e; - } - } - - this.setState({ - startTimeMessage: startTimeMessage, - endTimeMessage: endTimeMessage, - }); - - // TODO: Why build the cron string unless the TriggerType is not CRON? - // Unless cron editing is enabled, calculate the new cron string, set it in state, - // then use it to build new trigger object and notify the parent - this.setState( - { - cron: editCron ? cron : buildCron(startDateTime, intervalCategory, selectedDays), - }, - () => { - const trigger = buildTrigger( - intervalCategory, - intervalValue, - startDateTime, - endDateTime, - type, - this.state.cron, - ); - - if (this.props.onChange) { - this.props.onChange({ - catchup, - maxConcurrentRuns: trigger ? this.state.maxConcurrentRuns : undefined, - trigger, - }); - } - }, - ); - }; - - private _isAllDaysChecked(): boolean { - return this.state.selectedDays.every((d) => !!d); - } - - private _toggleCheckAllDays(): void { - const isAllChecked = this._isAllDaysChecked(); - this.state.selectedDays.forEach((d, i) => { - if (d !== !isAllChecked) { - this._toggleDay(i); - } - }); - } - - private _toggleDay(index: number): void { - const newDays = this.state.selectedDays; - newDays[index] = !newDays[index]; - const startDate = pickersToDate( - this.state.hasStartDate, - this.state.startDate, - this.state.startTime, - ); - const cron = buildCron(startDate, this.state.intervalCategory, this.state.selectedDays); - - this.setState( - { - cron, - selectedDays: newDays, - }, - this._updateTrigger, +
+ {result.error ? ( +

+ {result.error} +

+ ) : ( + {scheduleDescription(result.trigger)} + )} +

+ {s.preset === 'interval' + ? 'Fixed time between scheduled starts, not between completed runs.' + : 'Calendar schedules use the cluster scheduler’s timezone (UTC in the default installation). The cluster does not expose its configured timezone here.'} +

+ {s.preset === 'monthly' && +s.monthDay > 28 && ( +

Months without day {s.monthDay} are skipped.

+ )} +
+
+ {this.renderBoundary('start')} + {this.renderBoundary('end')} +
+ {(s.start.enabled || s.end.enabled) && ( +

+ Start and end dates use your local timezone: {localZone}. They bound the schedule; they + do not change its repeat time. +

+ )} +
+ Advanced execution settings +
+ this.change({ concurrency: e.target.value })} + error={concurrencyValid ? undefined : 'Enter a whole number from 1 to 10.'} + /> + +

+ When enabled, missed schedule times are backfilled after a pause or delay. This can + create many runs. Turn it off if the pipeline handles backfill itself. +

+
+
+ ); } } diff --git a/frontend/src/components/UploadPipelineDialog.test.tsx b/frontend/src/components/UploadPipelineDialog.test.tsx index 440e78a1237..170e3641e94 100644 --- a/frontend/src/components/UploadPipelineDialog.test.tsx +++ b/frontend/src/components/UploadPipelineDialog.test.tsx @@ -15,9 +15,10 @@ */ import * as React from 'react'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render as renderWithoutTheme, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { vi } from 'vitest'; +import { ThemeProvider } from 'src/components/shell/ThemeProvider'; import UploadPipelineDialog, { ImportMethod, PIPELINE_PACKAGE_ACCEPT, @@ -25,6 +26,23 @@ import UploadPipelineDialog, { pipelinePackageValidator, } from './UploadPipelineDialog'; +function render(element: React.ReactElement) { + return renderWithoutTheme({element}); +} +beforeEach(() => { + localStorage.clear(); + vi.stubGlobal('matchMedia', (media: string) => ({ + matches: false, + media, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })); +}); +afterEach(() => { + vi.unstubAllGlobals(); + localStorage.clear(); +}); + describe('PIPELINE_PACKAGE_ACCEPT', () => { it('accepts only backend-supported formats', () => { const allExtensions = Object.values(PIPELINE_PACKAGE_ACCEPT).flat().sort(); diff --git a/frontend/src/components/UploadPipelineDialog.tsx b/frontend/src/components/UploadPipelineDialog.tsx index 9ab0e27989c..4ade9f008c4 100644 --- a/frontend/src/components/UploadPipelineDialog.tsx +++ b/frontend/src/components/UploadPipelineDialog.tsx @@ -15,48 +15,15 @@ */ import * as React from 'react'; -import BusyButton from '../atoms/BusyButton'; import DropzoneArea, { DropzoneAreaHandle } from '../atoms/DropzoneArea'; -import Input from '../atoms/Input'; -import { TextFieldProps } from '@mui/material/TextField'; -import { padding, commonCss, zIndex, color } from '../Css'; -import { stylesheet, classes } from 'typestyle'; import { ExternalLink } from '../atoms/ExternalLink'; import PrivateSharedSelector from './PrivateSharedSelector'; import { BuildInfoContext } from 'src/lib/BuildInfo'; -import { - Button, - Dialog, - DialogActions, - DialogTitle, - FormControlLabel, - InputAdornment, - Radio, -} from '@mui/material'; - -const css = stylesheet({ - dropOverlay: { - backgroundColor: color.lightGrey, - border: '2px dashed #aaa', - bottom: 0, - left: 0, - padding: '2.5em 0', - position: 'absolute', - right: 0, - textAlign: 'center', - top: 0, - zIndex: zIndex.DROP_ZONE_OVERLAY, - }, - fileError: { - color: 'red', - fontSize: 12, - marginTop: 4, - }, - root: { - width: 500, - }, -}); +import { Button } from './ui/button'; +import { TextField } from './ui/text-field'; +import { ModalDialog } from './ui/dialog'; +import './pipelines/PipelineForms.css'; export enum ImportMethod { LOCAL = 'local', @@ -142,7 +109,7 @@ class UploadPipelineDialog extends React.Component< }; } - public render(): React.JSX.Element { + public render(): React.JSX.Element | null { const { dropzoneActive, file, @@ -153,47 +120,72 @@ class UploadPipelineDialog extends React.Component< uploadPipelineName, busy, } = this.state; - + if (!this.props.open) return null; return ( - this._uploadDialogClosed(false)} - open={this.props.open} - classes={{ paper: css.root }} + actions={ + <> + + + + } > - Upload and name your pipeline -
+
{this.context?.apiServerMultiUser && ( { - this.setState({ - isPrivatePipeline: val, - }); - }} - > - )} - -
Upload a pipeline package file from your computer or import one using a URL.
- -
- } - onChange={() => this.setState({ importMethod: ImportMethod.LOCAL, fileError: '' })} - /> - } - onChange={() => this.setState({ importMethod: ImportMethod.URL, fileError: '' })} + value={this.state.isPrivatePipeline} + onChange={(isPrivatePipeline) => this.setState({ isPrivatePipeline })} /> -
- - {importMethod === ImportMethod.LOCAL && ( - + )} +

+ Upload a pipeline package file from your computer or import one using a URL. +

+
+ Pipeline package source + + +
+ + {importMethod === ImportMethod.LOCAL ? ( +
- {dropzoneActive &&
Drop files..
} - -
You can also drag and drop the file here.
- - Drop files…
} +

You can also drag and drop the file here.

+ - - - ), - readOnly: true, - }} + required + readOnly + error={fileError} + trailingContent={ + + } /> - {fileError &&
{fileError}
} -
- )} - - {importMethod === ImportMethod.URL && ( - -
URL must be publicly accessible.
- - -
+
+ ) : ( + )} - -
- - {/* */} - - - - this._uploadDialogClosed.bind(this)(true)} - title='Upload' - busy={busy} - disabled={ - !uploadPipelineName || (importMethod === ImportMethod.LOCAL ? !file : !fileUrl) - } - /> - -
+ ); } - public handleChange = (name: string) => (event: any) => { - this.setState({ - [name]: (event.target as TextFieldProps).value, - } as any); - }; + public handleChange = + (name: string) => (event: React.ChangeEvent) => { + this.setState({ + [name]: event.target.value, + } as any); + }; private _onDropzoneDragEnter(): void { this.setState({ dropzoneActive: true }); @@ -352,7 +305,7 @@ class UploadPipelineDialog extends React.Component< export default UploadPipelineDialog; export const DocumentationCompilePipeline: React.FC = () => ( -
+
For expected file format, refer to{' '} Compile Pipeline Documentation diff --git a/frontend/src/components/__snapshots__/Banner.test.tsx.snap b/frontend/src/components/__snapshots__/Banner.test.tsx.snap deleted file mode 100644 index f3cf923c57c..00000000000 --- a/frontend/src/components/__snapshots__/Banner.test.tsx.snap +++ /dev/null @@ -1,191 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`Banner > defaults to error mode 1`] = ` - -