From 8c39af866fec9d9a8a999fea6f845859b7b8b094 Mon Sep 17 00:00:00 2001 From: visortelle Date: Sun, 19 Jul 2026 22:29:35 +0300 Subject: [PATCH 1/8] e2e test suite + bunch of bugfixes --- .github/workflows/ci.yml | 67 ++ e2e/.env.example | 12 + e2e/README.md | 234 +++- e2e/build.sbt | 59 +- e2e/project/build.properties | 2 +- e2e/scripts/run-dekaf.sh | 42 + e2e/scripts/stack-down.sh | 9 + e2e/scripts/stack-up-cluster.sh | 82 ++ e2e/scripts/stack-up.sh | 55 + e2e/scripts/values-test.yaml | 58 + .../consumersession/ConsumerSessionPage.scala | 212 ++++ .../consumersession/ExportModal.scala | 17 + .../consumersession/FilterPanel.scala | 38 + .../consumersession/TargetSelector.scala | 23 + .../features/consumersession/ToolsPanel.scala | 18 + .../features/library/LibraryBrowser.scala | 39 + .../features/library/LibrarySaveDialog.scala | 20 + .../features/library/LibrarySidebar.scala | 71 ++ e2e/src/main/scala/harness/Cli.scala | 232 ++++ e2e/src/main/scala/harness/Config.scala | 30 + e2e/src/main/scala/harness/Eventually.scala | 15 + .../main/scala/harness/PulsarFixtures.scala | 178 +++ e2e/src/main/scala/harness/PwRuntime.scala | 21 + e2e/src/main/scala/test_env/TestEnv.scala | 39 - .../main/scala/ui/ConfirmationDialog.scala | 17 + .../scala/ui/SubscriptionOverviewPage.scala | 14 + .../ConsumerSessionConfigSpec.scala | 29 + .../consumersession/ConsumerSessionSpec.scala | 46 + .../consumersession/CsConsoleSpec.scala | 63 + .../consumersession/CsDetailsSpec.scala | 61 + .../consumersession/CsExportSpec.scala | 89 ++ .../consumersession/CsFiltersSpec.scala | 129 ++ .../consumersession/CsLifecycleSpec.scala | 62 + .../CsProjectionColoringSpec.scala | 122 ++ .../consumersession/CsStartFromSpec.scala | 69 ++ .../consumersession/CsTableSpec.scala | 113 ++ .../consumersession/CsTargetsSpec.scala | 103 ++ .../features/library/LibraryBrowseSpec.scala | 116 ++ .../features/library/LibraryCrudSpec.scala | 112 ++ .../features/library/LibraryNotesSpec.scala | 63 + .../features/library/LibraryScopeSpec.scala | 19 + .../features/library/LibrarySelectSpec.scala | 59 + .../scala/features/library/LibrarySpec.scala | 49 + .../features/producer/ProducerBytesSpec.scala | 84 ++ .../features/producer/ProducerSpec.scala | 48 + .../producer/ProducerValidationSpec.scala | 47 + e2e/src/test/scala/harness/DekafSuite.scala | 111 ++ .../test/scala/knownbugs/KnownBugSpec.scala | 52 + .../scala/knownbugs/MoreKnownBugsSpec.scala | 197 +++ .../scala/primitives/PrimitivesSpec.scala | 72 ++ .../test/scala/routes/BreadCrumbsSpec.scala | 29 + .../test/scala/routes/CredentialsSpec.scala | 110 ++ .../test/scala/routes/HealthCheckSpec.scala | 25 + .../scala/routes/InstanceConfigSpec.scala | 57 + e2e/src/test/scala/routes/ModalsSpec.scala | 29 + .../scala/routes/NamespaceBundlesSpec.scala | 70 ++ .../routes/NamespaceGeoReplicationSpec.scala | 39 + .../scala/routes/NamespaceOverviewSpec.scala | 43 + .../routes/NamespacePermissionSpec.scala | 103 ++ .../routes/NamespacePolicyBreadthSpec.scala | 182 +++ .../scala/routes/NamespacePolicySpec.scala | 74 ++ e2e/src/test/scala/routes/NamespaceSpec.scala | 54 + .../test/scala/routes/NavigationSpec.scala | 36 + .../scala/routes/NavigationTreeSpec.scala | 61 + e2e/src/test/scala/routes/RenderSpec.scala | 65 + .../test/scala/routes/ResilienceSpec.scala | 50 + e2e/src/test/scala/routes/SchemaSpec.scala | 113 ++ .../test/scala/routes/StatsRenderSpec.scala | 65 + .../scala/routes/SubscriptionActionSpec.scala | 171 +++ .../test/scala/routes/SubscriptionSpec.scala | 22 + e2e/src/test/scala/routes/TableSpec.scala | 120 ++ .../scala/routes/TenantOverviewSpec.scala | 50 + e2e/src/test/scala/routes/TenantSpec.scala | 29 + .../test/scala/routes/TopicActionsSpec.scala | 122 ++ .../scala/routes/TopicPolicyBreadthSpec.scala | 57 + .../test/scala/routes/TopicPolicySpec.scala | 60 + e2e/src/test/scala/routes/TopicSpec.scala | 57 + .../scala/routes/instance/InstanceSpec.scala | 77 ++ .../create_tenant/CreateTenantPageSpec.scala | 45 - .../ResourceGroupsPageSpec.scala | 93 -- .../create/CreateResourceGroupPageSpec.scala | 52 - .../EditResourceGroupPageSpec.scala | 79 -- .../namespaces/NamespacesPageSpec.scala | 64 - .../_namespace_id/policies/Properties.scala | 130 -- .../_namespace_id/topics/TopicsPageSpec.scala | 66 - .../topics/_topic_id/TopicPageSpec.scala | 136 --- .../_topic_id/schema/SchemaPageSpec.scala | 129 -- .../permissions/PermissionsPageSpec.scala | 52 - .../test/scala/smoke/OverviewSmokeSpec.scala | 19 + .../ConsumerSessionRunner.scala | 15 +- server/src/main/scala/library/Library.scala | 53 +- .../scala/library/LibraryServiceImpl.scala | 19 + .../src/main/scala/pulsar_auth/Client.scala | 83 +- .../pulsar_auth/PulsarAuthInterceptor.scala | 46 +- .../library/LibraryBugRegressionsTest.scala | 90 ++ .../pulsar_auth/ClientConstructionTest.scala | 56 + ui/__mocks__/fileMock.js | 1 + ui/__mocks__/styleMock.js | 1 + .../Configuration/Configuration.tsx | 12 +- .../CreateTenantPage/CreateTenantPage.tsx | 10 +- .../DeleteDialog/DeleteDialog.tsx | 10 +- .../ResourceGroupForm/ResourceGroupForm.tsx | 20 +- .../NamespacePage/CreateTopic/CreateTopic.tsx | 3 +- .../NamespaceBundlesEditor.tsx | 1 + .../Permissions/Permissions.tsx | 9 +- .../fields/compaction-threshold.tsx | 2 + .../NamespaceDetails/fields/deduplication.tsx | 1 + .../fields/delayed-delivery.tsx | 2 + .../NamespaceDetails/fields/dispatch-rate.tsx | 3 +- .../fields/encryption-required.tsx | 1 + .../fields/max-consumers-per-topic.tsx | 2 + .../max-unacked-messages-per-consumer.tsx | 2 + .../FilesystemInput/FilesystemInput.tsx | 1 + .../offload-policies/offload-policies.tsx | 2 + .../NamespaceDetails/fields/persistence.tsx | 4 + .../fields/replication-clusters.tsx | 3 + .../fields/resource-group.tsx | 2 + .../NamespaceDetails/fields/retention.tsx | 3 +- .../fields/schema-compatibility-strategy.tsx | 1 + .../NamespacePropertiesEditor.tsx | 2 +- .../ExpireMessages/ExpireMessages.tsx | 12 +- .../Overview/ResetCursor/ResetCursor.tsx | 3 + .../Overview/SkipMessages/SkipMessages.tsx | 2 + .../SubscriptionPage/SubscriptionPage.tsx | 2 + .../CreateNamespace/CreateNamespace.tsx | 9 +- .../TenantPage/Overview/Overview.tsx | 2 + .../UpdatePartitionedTopicButton.tsx | 1 + .../ViewTopicPartitionsButton.tsx | 1 + .../Schema/AvroEditor/AvroEditor.tsx | 1 + ui/components/TopicPage/Schema/Schema.tsx | 2 +- .../Schema/SchemaEntry/SchemaEntry.tsx | 2 +- .../SchemaTypeInput/SchemaTypeInput.tsx | 1 + .../CreateSubscription/CreateSubscription.tsx | 17 +- .../TopicPage/TopicDetails/TopicDetails.tsx | 2 +- .../TopicCompactionStatus.tsx | 1 + .../TopicDetails/fields/delayed-delivery.tsx | 1 + .../TopicDetails/fields/max-consumers.tsx | 2 + ui/components/app/contexts/AppContext.tsx | 11 +- .../HealthCheckContext/HealthCheckContext.tsx | 4 +- ui/components/app/contexts/Modals/Modals.tsx | 1 + .../app/pulsar-auth/Button/Button.tsx | 1 + .../CredentialsEditor/CredentialsEditor.tsx | 4 +- .../app/pulsar-auth/Editor/Editor.tsx | 10 +- .../AuthParamsString/AuthParamsString.tsx | 2 + .../pulsar-auth/Editor/methods/Jwt/Jwt.tsx | 1 + .../Editor/methods/OAuth2/OAuth2.tsx | 2 + ui/components/app/pulsar-auth/conversions.ts | 3 + .../ui/ActionButton/ActionButton.tsx | 2 +- ui/components/ui/BreadCrumbs/BreadCrumbs.tsx | 5 +- .../DurationInput/DurationInput.tsx | 2 + .../ui/ConfigurationTable/Input/Input.tsx | 2 + .../ListInput/ListInput.tsx | 5 + .../MemorySizeInput/MemorySizeInput.tsx | 2 + .../UpdateConfirmation/UpdateConfirmation.tsx | 4 +- .../ui/ConsumerSession/Console/Console.tsx | 3 + .../Console/ContextLogs/ContextLogs.tsx | 2 +- .../Console/ContextRepl/ContextRepl.tsx | 6 +- .../Console/Producer/Producer.tsx | 19 +- .../ui/ConsumerSession/ConsumerSession.tsx | 29 +- .../ConsumerSession/Message/Field/Field.tsx | 4 +- .../ui/ConsumerSession/Message/Message.tsx | 2 + .../Message/MessageDetails/MessageDetails.tsx | 3 + .../ui/ConsumerSession/Message/Td.tsx | 7 +- .../ui/ConsumerSession/Message/fields.tsx | 6 +- .../ColoringRuleChainInput.tsx | 3 +- .../ColorPickerButton/ColorPickerButton.tsx | 4 +- .../ColoringRuleInput/ColoringRuleInput.tsx | 2 + .../FilterChainEditor/FilterChainEditor.tsx | 4 +- .../BasicFilterEditor/BasicFilterEditor.tsx | 1 + .../TestOpStringIncludesInput.tsx | 1 + .../AnyTestOpTypeSelect.tsx | 4 +- .../BasicMessageFilterOpInput.tsx | 4 +- .../FilterEditor/FilterEditor.tsx | 3 + .../JsFilterEditor/JsFilterEditor.tsx | 2 +- .../SessionConfiguration.tsx | 9 + .../DeserializerInput/DeserializerInput.tsx | 1 + .../SessionTargetInput/SessionTargetInput.tsx | 2 + .../StartFromInput/StartFromInput.tsx | 6 +- ui/components/ui/ConsumerSession/Th.tsx | 5 +- .../ExportMessagesButton.tsx | 3 +- .../MessageFieldsConfig.tsx | 2 +- .../MessagesExporter/MessagesExporter.tsx | 4 +- .../ui/ConsumerSession/Toolbar/Toolbar.tsx | 16 +- ui/components/ui/ConsumerSession/sort.ts | 25 + .../TopicSelectorInfo/TopicSelectorInfo.tsx | 2 +- .../TopicSelectorInput/TopicSelectorInput.tsx | 7 +- .../ValueProjectionInput.tsx | 1 + .../ValueProjectionListInput.tsx | 3 +- .../value-projections-utils.tsx | 1 + ui/components/ui/IconToggle/IconToggle.tsx | 4 +- .../InvertedToggle/InvertedToggle.tsx | 4 +- .../ui/IconToggle/OnOffToggle/OnOffToggle.tsx | 4 +- ui/components/ui/Icons/Icons.tsx | 1 + .../StringFilterInput/StringFilterInput.tsx | 4 +- .../ui/KeyValueEditor/KeyValueEditor.test.tsx | 52 + .../ui/KeyValueEditor/KeyValueEditor.tsx | 4 +- .../Layout/NavigationTree/NavigationTree.tsx | 21 +- .../PickLibraryItemButton.tsx | 5 + .../SaveLibraryItemButton.tsx | 4 +- .../LibraryBrowserPanel.tsx | 1 + .../LibraryItemName/LibraryItemName.tsx | 1 + .../AvailableInContextsButton.test.tsx | 44 + .../AvailableInContextsButton.tsx | 5 +- .../ExactNamespaceMatcherInput.tsx | 1 + .../ResourceMatcherInput.tsx | 1 + .../ResourceMatchersInput.tsx | 2 + .../DeleteLibraryItemButton.tsx | 3 +- .../SearchResults/SearchResults.tsx | 11 +- .../dialogs/BrowseDialog/BrowseDialog.tsx | 3 + .../OverwriteExistingItemDialog.tsx | 4 +- .../dialogs/SaveItemDialog/SaveItemDialog.tsx | 6 +- .../Library/AllObjectTypes/AllObjectTypes.tsx | 10 +- .../ConsumerSessions/ConsumerSessions.tsx | 1 + .../ui/LibrarySidebar/Library/Library.tsx | 2 + .../ui/LibrarySidebar/LibrarySidebar.tsx | 2 + .../ui/LibrarySidebar/Notes/Notes.module.css | 18 - .../ui/LibrarySidebar/Notes/Notes.tsx | 28 +- .../ui/LibrarySidebar/Notes/blog-note.tsx | 5 +- .../EditNameDialog/EditNameDialog.tsx | 4 +- ui/components/ui/Select/Select.tsx | 2 + ui/components/ui/SortInput/SortInput.tsx | 2 + .../Table/FiltersToolbar/FiltersToolbar.tsx | 9 +- ui/components/ui/Table/Table.tsx | 21 +- .../StringFilterInput/StringFilterInput.tsx | 2 +- ui/components/ui/Tabs/Tabs.tsx | 8 +- ui/components/ui/Toggle/Toggle.tsx | 5 +- ui/components/ui/Toolbar/Toolbar.tsx | 7 +- .../ui/resizable/ColumnResizeHandle.tsx | 1 + ui/jest.config.js | 10 +- ui/jest.setup.js | 11 +- ui/package-lock.json | 1064 ++++++++++++++++- ui/package.json | 2 + 232 files changed, 7383 insertions(+), 1148 deletions(-) create mode 100644 e2e/.env.example create mode 100755 e2e/scripts/run-dekaf.sh create mode 100755 e2e/scripts/stack-down.sh create mode 100755 e2e/scripts/stack-up-cluster.sh create mode 100755 e2e/scripts/stack-up.sh create mode 100644 e2e/scripts/values-test.yaml create mode 100644 e2e/src/main/scala/features/consumersession/ConsumerSessionPage.scala create mode 100644 e2e/src/main/scala/features/consumersession/ExportModal.scala create mode 100644 e2e/src/main/scala/features/consumersession/FilterPanel.scala create mode 100644 e2e/src/main/scala/features/consumersession/TargetSelector.scala create mode 100644 e2e/src/main/scala/features/consumersession/ToolsPanel.scala create mode 100644 e2e/src/main/scala/features/library/LibraryBrowser.scala create mode 100644 e2e/src/main/scala/features/library/LibrarySaveDialog.scala create mode 100644 e2e/src/main/scala/features/library/LibrarySidebar.scala create mode 100644 e2e/src/main/scala/harness/Cli.scala create mode 100644 e2e/src/main/scala/harness/Config.scala create mode 100644 e2e/src/main/scala/harness/Eventually.scala create mode 100644 e2e/src/main/scala/harness/PulsarFixtures.scala create mode 100644 e2e/src/main/scala/harness/PwRuntime.scala delete mode 100644 e2e/src/main/scala/test_env/TestEnv.scala create mode 100644 e2e/src/main/scala/ui/ConfirmationDialog.scala create mode 100644 e2e/src/main/scala/ui/SubscriptionOverviewPage.scala create mode 100644 e2e/src/test/scala/features/consumersession/ConsumerSessionConfigSpec.scala create mode 100644 e2e/src/test/scala/features/consumersession/ConsumerSessionSpec.scala create mode 100644 e2e/src/test/scala/features/consumersession/CsConsoleSpec.scala create mode 100644 e2e/src/test/scala/features/consumersession/CsDetailsSpec.scala create mode 100644 e2e/src/test/scala/features/consumersession/CsExportSpec.scala create mode 100644 e2e/src/test/scala/features/consumersession/CsFiltersSpec.scala create mode 100644 e2e/src/test/scala/features/consumersession/CsLifecycleSpec.scala create mode 100644 e2e/src/test/scala/features/consumersession/CsProjectionColoringSpec.scala create mode 100644 e2e/src/test/scala/features/consumersession/CsStartFromSpec.scala create mode 100644 e2e/src/test/scala/features/consumersession/CsTableSpec.scala create mode 100644 e2e/src/test/scala/features/consumersession/CsTargetsSpec.scala create mode 100644 e2e/src/test/scala/features/library/LibraryBrowseSpec.scala create mode 100644 e2e/src/test/scala/features/library/LibraryCrudSpec.scala create mode 100644 e2e/src/test/scala/features/library/LibraryNotesSpec.scala create mode 100644 e2e/src/test/scala/features/library/LibraryScopeSpec.scala create mode 100644 e2e/src/test/scala/features/library/LibrarySelectSpec.scala create mode 100644 e2e/src/test/scala/features/library/LibrarySpec.scala create mode 100644 e2e/src/test/scala/features/producer/ProducerBytesSpec.scala create mode 100644 e2e/src/test/scala/features/producer/ProducerSpec.scala create mode 100644 e2e/src/test/scala/features/producer/ProducerValidationSpec.scala create mode 100644 e2e/src/test/scala/harness/DekafSuite.scala create mode 100644 e2e/src/test/scala/knownbugs/KnownBugSpec.scala create mode 100644 e2e/src/test/scala/knownbugs/MoreKnownBugsSpec.scala create mode 100644 e2e/src/test/scala/primitives/PrimitivesSpec.scala create mode 100644 e2e/src/test/scala/routes/BreadCrumbsSpec.scala create mode 100644 e2e/src/test/scala/routes/CredentialsSpec.scala create mode 100644 e2e/src/test/scala/routes/HealthCheckSpec.scala create mode 100644 e2e/src/test/scala/routes/InstanceConfigSpec.scala create mode 100644 e2e/src/test/scala/routes/ModalsSpec.scala create mode 100644 e2e/src/test/scala/routes/NamespaceBundlesSpec.scala create mode 100644 e2e/src/test/scala/routes/NamespaceGeoReplicationSpec.scala create mode 100644 e2e/src/test/scala/routes/NamespaceOverviewSpec.scala create mode 100644 e2e/src/test/scala/routes/NamespacePermissionSpec.scala create mode 100644 e2e/src/test/scala/routes/NamespacePolicyBreadthSpec.scala create mode 100644 e2e/src/test/scala/routes/NamespacePolicySpec.scala create mode 100644 e2e/src/test/scala/routes/NamespaceSpec.scala create mode 100644 e2e/src/test/scala/routes/NavigationSpec.scala create mode 100644 e2e/src/test/scala/routes/NavigationTreeSpec.scala create mode 100644 e2e/src/test/scala/routes/RenderSpec.scala create mode 100644 e2e/src/test/scala/routes/ResilienceSpec.scala create mode 100644 e2e/src/test/scala/routes/SchemaSpec.scala create mode 100644 e2e/src/test/scala/routes/StatsRenderSpec.scala create mode 100644 e2e/src/test/scala/routes/SubscriptionActionSpec.scala create mode 100644 e2e/src/test/scala/routes/SubscriptionSpec.scala create mode 100644 e2e/src/test/scala/routes/TableSpec.scala create mode 100644 e2e/src/test/scala/routes/TenantOverviewSpec.scala create mode 100644 e2e/src/test/scala/routes/TenantSpec.scala create mode 100644 e2e/src/test/scala/routes/TopicActionsSpec.scala create mode 100644 e2e/src/test/scala/routes/TopicPolicyBreadthSpec.scala create mode 100644 e2e/src/test/scala/routes/TopicPolicySpec.scala create mode 100644 e2e/src/test/scala/routes/TopicSpec.scala create mode 100644 e2e/src/test/scala/routes/instance/InstanceSpec.scala delete mode 100644 e2e/src/test/scala/routes/instance/create_tenant/CreateTenantPageSpec.scala delete mode 100644 e2e/src/test/scala/routes/instance/resource_groups/ResourceGroupsPageSpec.scala delete mode 100644 e2e/src/test/scala/routes/instance/resource_groups/create/CreateResourceGroupPageSpec.scala delete mode 100644 e2e/src/test/scala/routes/instance/resource_groups/edit/_resource_group_id/EditResourceGroupPageSpec.scala delete mode 100644 e2e/src/test/scala/routes/tenants/_tenant_id/namespaces/NamespacesPageSpec.scala delete mode 100644 e2e/src/test/scala/routes/tenants/_tenant_id/namespaces/_namespace_id/policies/Properties.scala delete mode 100644 e2e/src/test/scala/routes/tenants/_tenant_id/namespaces/_namespace_id/topics/TopicsPageSpec.scala delete mode 100644 e2e/src/test/scala/routes/tenants/_tenant_id/namespaces/_namespace_id/topics/_topic_id/TopicPageSpec.scala delete mode 100644 e2e/src/test/scala/routes/tenants/_tenant_id/namespaces/_namespace_id/topics/_topic_id/schema/SchemaPageSpec.scala delete mode 100644 e2e/src/test/scala/routes/tenants/_tenant_id/namespaces/_namespace_id/topics/permissions/PermissionsPageSpec.scala create mode 100644 e2e/src/test/scala/smoke/OverviewSmokeSpec.scala create mode 100644 server/src/test/scala/library/LibraryBugRegressionsTest.scala create mode 100644 server/src/test/scala/pulsar_auth/ClientConstructionTest.scala create mode 100644 ui/__mocks__/fileMock.js create mode 100644 ui/__mocks__/styleMock.js create mode 100644 ui/components/ui/KeyValueEditor/KeyValueEditor.test.tsx create mode 100644 ui/components/ui/LibraryBrowser/LibraryItemEditor/AvailableInContextsButton/AvailableInContextsButton.test.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d8fa3a5e..af82de6a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,3 +29,70 @@ jobs: - name: UI unit tests shell: bash run: nix-shell --run 'cd ui && make test' + + e2e: + runs-on: [self-hosted, x64] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: ./.github/actions/git-lfs-pull + + # Playwright's browser download is not pinned/cached elsewhere; do it up front so a clean + # runner has Chromium before the suite starts. + - name: Install Playwright browsers + shell: bash + run: nix-shell --run 'cd e2e && sbt "runMain com.microsoft.playwright.CLI install --with-deps chromium"' + + - name: Bring up Pulsar (standalone) + Dekaf server + shell: bash + run: | + # The runner is ephemeral (fresh checkout), so Library writes under server/data don't + # persist across runs - no disposable data root needed (a fresh DEKAF_DATA_DIR would also + # skip the consumer-session libs.js/proto that run-dekaf builds under the default root). + nix-shell --run 'e2e/scripts/stack-up.sh' + nix-shell --run 'e2e/scripts/run-dekaf.sh' & + for _ in $(seq 1 60); do + curl -sf http://localhost:8090/ >/dev/null 2>&1 && break + sleep 5 + done + + - name: E2E - green lane + shell: bash + env: + DEKAF_BASE_URL: http://localhost:8090 + PULSAR_ADMIN_URL: http://localhost:18080 + PULSAR_SERVICE_URL: pulsar://localhost:6650 + # Record a trace for EVERY test (not just failures) so all results are navigable as artifacts. + TRACE: on + run: nix-shell --run 'cd e2e && sbt test' + + # Upload results whether the suite passed or failed, so they're always navigable. + # e2e-traces - one Playwright trace per test (____.zip). Navigate them + # with `sbt "runMain harness.TraceDashboard"` (clickable, colorized index) or by + # dropping a zip on https://trace.playwright.dev. + # e2e-test-reports - JUnit XML (target/test-reports) + the static HTML report (target/test-html, + # open index.html) - a pass/fail tree with durations and failure detail. + - name: Upload e2e traces (all tests) + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-traces + path: e2e/target/traces/*.zip + if-no-files-found: ignore + + - name: Upload e2e test reports (JUnit XML + HTML) + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-test-reports + path: | + e2e/target/test-reports/ + e2e/target/test-html/ + if-no-files-found: ignore + + - name: Tear down the stack + if: always() + shell: bash + run: nix-shell --run 'e2e/scripts/stack-down.sh' || true diff --git a/e2e/.env.example b/e2e/.env.example new file mode 100644 index 000000000..6f4141cf0 --- /dev/null +++ b/e2e/.env.example @@ -0,0 +1,12 @@ +# Load into your shell before `sbt test`: set -a; source e2e/.env.example; set +a +# (these `export`s reach the JVM's sys.env; a bare `X=Y` source would not.) +# Local standalone stack (scripts/stack-up.sh) publishes admin on :18080 (host :8080 is often taken). +export DEKAF_BASE_URL=http://localhost:8090 +export PULSAR_ADMIN_URL=http://localhost:18080 +export PULSAR_SERVICE_URL=pulsar://localhost:6650 + +# Debug/observe knobs (uncomment): +# export HEADED=true # 1/true/yes/on all accepted +# export SLOWMO=300 +# export TRACE=on # on | off | retain-on-failure (default) +# export PWDEBUG=1 # opens the Playwright Inspector (forces headed) diff --git a/e2e/README.md b/e2e/README.md index 102c5cad5..342126e31 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -1,8 +1,232 @@ -## sbt project compiled with Scala 3 +# Dekaf end-to-end tests -### Usage +An all-JVM e2e suite for the Dekaf UI: **Scala 3 + Playwright for Java + ScalaTest**, driving the +real UI against a real Apache Pulsar. Every mutation is cross-checked through the **PulsarAdmin** +REST client (the oracle); test data is produced with the **PulsarClient**. No mocks. -This is a normal sbt project. You can compile code with `sbt compile`, run it with `sbt run`, and `sbt console` will start a Scala 3 REPL. +- **Catalog:** the specs *are* the catalog - grouped by page/feature under `test/scala/` (see §5 + Layout); each `test("…")` name describes the interaction it covers. +- **Priority features** (deepest coverage): the **Library** (`features/library/`) and the + **Consumer Session** (`features/consumersession/`). +- **Known gaps** the suite doesn't yet cover are in §6. -For more information on the sbt-dotty plugin, see the -[scala3-example-project](https://github.com/scala/scala3-example-project/blob/main/README.md). +--- + +## 1. Prerequisites + +You need `sbt`, a JDK (21), `node`+`npm` (to build the UI bundle the server serves), Docker (for the +Pulsar standalone), and the per-arch `envoy.bin` shipped in `bin/`. If the repo uses Nix, everything +except Docker is provided by the dev shell - prefix any command with `nix develop --command …`, e.g. +`nix develop --command bash -c 'cd e2e && sbt test'`. + +--- + +## 2. Bring up the local stack (once) + +```bash +# 1) Pulsar standalone in Docker (admin :18080, broker :6650) +e2e/scripts/stack-up.sh + +# 2) Build the UI + run the Dekaf server on :8090, pointed at that Pulsar +e2e/scripts/run-dekaf.sh # leave running in its own terminal +``` + +Tear the Pulsar container down with `e2e/scripts/stack-down.sh`. For a full multi-broker / multi-bookie +shape instead of standalone, use `e2e/scripts/stack-up-cluster.sh` (Helm on k3d). + +The suite talks to these via three env vars (defaults shown, and they already match `stack-up.sh`). +Load them with `set -a; source e2e/.env.example; set +a` (the `set -a` is what exports them to the JVM): + +| Var | Default | What | +|---|---|---| +| `DEKAF_BASE_URL` | `http://localhost:8090` | the Dekaf UI under test | +| `PULSAR_ADMIN_URL` | `http://localhost:18080` | PulsarAdmin oracle (matches `stack-up.sh`; host `:8080` is often taken) | +| `PULSAR_SERVICE_URL` | `pulsar://localhost:6650` | PulsarClient producer | + +> **After editing anything under `ui/`, rebuild the bundle** so the running server serves it: +> `nix develop --command bash -c 'cd ui && node ./build.js'` (fast, ~1s; skips the full `tsc`). + +--- + +## 3. Run the tests + +```bash +cd e2e +export PULSAR_ADMIN_URL=http://localhost:18080 \ + PULSAR_SERVICE_URL=pulsar://localhost:6650 \ + DEKAF_BASE_URL=http://localhost:8090 + +sbt test # the green lane (KnownBug excluded by default → passes) + +sbt "testOnly features.consumersession.*" # one feature area +sbt "testOnly features.library.*" # the Library feature +sbt "testOnly routes.NavigationTreeSpec" # one spec +sbt "testOnly *CsFiltersSpec -- -z CS-10" # a single test by name substring (-z) +``` + +### The `KnownBug` mechanism (lane currently EMPTY - all bugs fixed 2026-07-19) + +While an app bug is open, its regression test asserts the **correct** behavior, is tagged `KnownBug`, +and stays **red on purpose** - excluded from `sbt test` (`Test / test / testOptions` in `build.sbt`) +so the normal run is green. When the bug is fixed, the test is **untagged** and joins the green lane +as an ordinary regression. All 19 catalogued bugs were fixed on 2026-07-19, so the +`knownbugs/*Spec` tests now run green in the normal lane (6 remain `ignore`d - fixed app-side but not +driveable from this harness; rationale inline, and see §6). The tag + exclusion stay wired for the next bug: + +```bash +sbt "testOnly * -- -n KnownBug" # the bug lane - currently runs nothing (no open bugs) +sbt test # green lane, includes the fixed-bug regressions +``` + +Tests run **serially** (`Test / parallelExecution := false`) against one shared stack; isolation is by +a fresh-UUID `context_id`/tenant per test (auto-torn-down), so many suites are safe to interleave. + +--- + +## 4. Debug & observe each test in a UI ← "playwright dashboard mode" + +Playwright for Java has **no Node-style `--ui` mode**, but the harness wires up the tools that +together give the same "play and observe each test separately" experience. All are driven by env vars +read in `harness/Config.scala`. + +A **Trace** is a full recording of one test: an action-by-action timeline with a DOM snapshot at every +step, plus the network, console, and source. **Traces are only recorded for FAILING tests by default** +(`TRACE=retain-on-failure`) - to review *every* test, record with `TRACE=on`. Note `sbt test` excludes the +KnownBug lane, so use `testOnly *` to record **all** tests (green lane + KnownBug) in one pass: + +```bash +TRACE=on sbt "testOnly *" # every test → target/traces/____.zip +``` + +Each trace is stamped with the test's outcome - `PASS` / `FAIL` / `BUG` (a `KnownBug` test that failed as +designed) / `SKIP` (canceled) - right in the filename (see `DekafSuite.stopTracing`), and a re-run replaces a +test's prior trace rather than leaving a stale duplicate. + +### 4a. Trace dashboard - browse & pick any test (the closest thing to `--ui`) + +```bash +sbt "runMain harness.TraceDashboard" # clickable index of every recorded test → opens the browser +``` + +Serves everything from **one** local http origin (**:9500**, override with `TRACE_PORT`): a grouped-by-spec +index at `/dashboard`, and the bundled Playwright trace viewer at `/`. Rows are **colorized by outcome** +(green passed · red failed · amber known-bug · grey skipped) with **filter chips** (`All / Failed / Known bug / +Passed / Skipped`) to isolate, say, just the failures. Click any test to open its **own** full trace (timeline · +DOM snapshots · network · console) via `/?trace=…`. Single-origin is what makes it work - the viewer's service +worker (which renders the DOM snapshots) needs scope `/`, and plain-http loopback avoids the HTTPS→HTTP +mixed-content block you hit pointing the hosted `trace.playwright.dev` at a local trace. **Fully local, no +internet.** Ctrl-C stops it. + +### 4b. Trace Viewer - one test, or all merged + +```bash +sbt "runMain harness.ShowTrace target/traces/*CS-10*.zip" # one test (glob expanded by ShowTrace) +sbt "runMain harness.ShowTrace" # all recorded traces, merged into one timeline (no picker) +``` + +Trace filenames are `____.zip` (STATUS ∈ PASS/FAIL/BUG/SKIP - stamped from the +test outcome), so a bare-suite glob needs the leading `*`. The quoted sbt command shields `*` from +your shell; ShowTrace expands it itself. Also fully offline (bundled viewer on :9323). + +### 4c. Headed mode - watch the browser live + +```bash +HEADED=true sbt "testOnly routes.NavigationTreeSpec" # see the real browser drive the UI +HEADED=true SLOWMO=800 sbt "testOnly ..." # + slow each action to 800ms so you can follow +``` + +### 4d. Playwright Inspector - step through & pick locators + +```bash +PWDEBUG=1 sbt "testOnly *CsFiltersSpec -- -z CS-10" +``` + +`PWDEBUG=1` opens the **Playwright Inspector**: the run pauses, and you step action-by-action, watch +each locator highlight in the page, and try selectors live. (It also forces headed mode.) + +### 4e. Codegen - record interactions into selectors/code + +```bash +sbt "runMain harness.Codegen http://localhost:8090" +``` + +Click around the app; Playwright writes the matching locators/actions you can paste into a spec. + +### 4f. Individual tests from the IDE + +Import the `e2e` sbt project into IntelliJ (or VS Code + Metals); each `test("…")` gets a gutter +run/debug icon, so you can run and observe one test at a time with breakpoints. + +--- + +## 5. Layout + +``` +e2e/src +├── main/scala +│ ├── harness/ Config, PwRuntime (shared Playwright+Browser), PulsarFixtures (admin oracle + +│ │ producer + per-test teardown), Eventually (poll the oracle, no fixed sleeps), +│ │ Cli (ShowTrace / Codegen mains) +│ ├── ui/ reusable component objects (ConfirmationDialog, SubscriptionOverviewPage, …) +│ └── features/ component objects for the priority features +│ ├── library/ LibrarySidebar, LibrarySaveDialog, LibraryBrowser +│ └── consumersession/ ConsumerSessionPage, FilterPanel, TargetSelector, ExportModal, ToolsPanel +└── test/scala + ├── harness/ DekafSuite (base trait: fresh BrowserContext + trace + fixtures per test) + ├── smoke/ routes/ instance/ navigation, chrome, and per-page specs + ├── primitives/ cross-cutting form primitives (X-1/2/3) + ├── features/ library/ + consumersession/ + producer/ specs + └── knownbugs/ the KnownBug-tagged regressions (§6) +``` + +**Instrumentation** is additive only: UI components take an optional `testId` prop rendered to +`data-testid` (never any behavior change), selected in tests with `page.getByTestId(...)`. + +--- + +## 6. Known gaps & follow-ups + +Honest backlog - none block the green lane; each is a place the suite proves less than it might. + +**Infrastructure** +- **Playwright browsers aren't pinned/cached** - a clean CI runner downloads Chromium on first use + (the CI `e2e` job installs it each run). +- **Library state accumulates.** `run-dekaf.sh` doesn't set `DEKAF_DATA_DIR`, so items land in + `server/data/library` and survive runs. Tests are context-scoped so they still pass, but + instance-scoped items (e.g. LIB-16's note) leak and counts drift. Isolating the data dir also needs + `js/dist/libs.js` + `proto/` seeded into it (see `run-dekaf.sh`). +- **No independent Library oracle.** LIB CRUD arranges *and* verifies through the same UI path, so a + shared serialization/render defect could pass both. A generated `LibraryService` gRPC stub would fix + this (and let the `ignore`d BUG-8/9/17 regressions drive the server directly). + +**Assertions thinner than the feature they name** (acknowledged, not defects) +- **CS-16/17** don't assert the full counter/state machine incl. broker-side consumer presence; + **CS-23** asserts the details panel opens, not its tab contents; **CS-28** asserts formats + `.zip` + entry indices, not exact exported values. +- **TOP-3/4, SUB-3** assert success toasts rather than a polled state change; **RES-2** supplies a + missing id, not a malformed persisted config; **LIB-18** proves the `?id=` URL loads, not that the + exact saved config restored. +- **Read-only stat views** (INS-9/10 · TOP-11/12 · SUB-7) are render-smoke - the anchor renders with no + crash; they don't assert specific stat values. +- **Catalog breadth** is narrower than some pages: e.g. TEN-1 omits cluster add/remove; NS-8 runs + split + clear-backlog but not unload/unload-all; TOP-7 doesn't verify Earliest/Latest cursor + semantics; Producer properties/event-time have testIds but no dedicated test yet. + +**Open product/config questions** (not test gaps) +- **SUB-6**: Delete-Subscription's guard is the **topic FQN**, not the subscription name - the test + encodes today's behavior; confirm it's intended. +- The optional cluster profile (`stack-up-cluster.sh` / `values-test.yaml`) pins a chart declaring + Pulsar 2.10.2 while overriding the image to 3.2.1 - template-validated only, never deployed. Align + versions before relying on it. + +**Regression coverage - the `KnownBug` lane** (catalogued bugs fixed; see §3). Twelve +run green as ordinary regressions in `knownbugs/*Spec` (BUG-1,3,4,5,6,10,12,13,14,15,18 + +BUG-19→NAV-14). Six are fixed app-side but not driveable from Playwright and stay `ignore`d with inline +rationale - covered instead by **jest** component tests (BUG-2/7: `KeyValueEditor` / +`AvailableInContextsButton`) and **server** unit tests (BUG-8/9/17: `LibraryBugRegressionsTest`). +**Two were reclassified as intended design** after owner review: **BUG-11** - auto-refresh is +deliberately ONE global toggle ("we either want to refresh any table, or not"); a `MoreKnownBugsSpec` +test now pins the global-shared semantics. **BUG-16** - per-connection library-storage scoping was +reverted: a Dekaf instance always serves one Pulsar, so isolation lives at the deployment layer +(the desktop app gives each saved connection its own `DEKAF_DATA_DIR`; docker runs one dekaf per +pulsar). The library dir stays flat; the path-traversal guard (BUG-17) is unaffected. diff --git a/e2e/build.sbt b/e2e/build.sbt index a52c7cbae..6b4cbcc39 100644 --- a/e2e/build.sbt +++ b/e2e/build.sbt @@ -1,30 +1,57 @@ -val scala3Version = "3.2.1" -val zioVersion = "2.0.5" -val pulsarVersion = "2.11.0" +val scala3Version = "3.3.3" +val pulsarVersion = "3.2.1" // aligned with server/ (PulsarAdmin + PulsarClient) +val playwrightVersion = "1.47.0" +val scalatestVersion = "3.2.19" -Test / fork := true +ThisBuild / scalaVersion := scala3Version lazy val root = project .in(file(".")) .settings( - name := "spec", + name := "dekaf-e2e", version := "0.1.0-SNAPSHOT", - scalaVersion := scala3Version, + // One forked JVM; tests run sequentially (single shared browser). Parallelism comes later + // via multiple forked runners once the suite is large. + Test / fork := true, + Test / parallelExecution := false, - libraryDependencies ++= Seq( - "dev.zio" %% "zio-test" % zioVersion % Test, - "dev.zio" %% "zio-test-sbt" % zioVersion % Test, - "dev.zio" %% "zio-test-magnolia" % zioVersion % Test, - "com.dimafeng" %% "testcontainers-scala-scalatest" % "0.40.12" % Test, + // The green lane. `sbt test` excludes the deliberately-red `KnownBug` regressions, so a normal + // run is expected to pass. Scoped to the `test` task ONLY - scoping it to `Test` would also apply + // to `testOnly`, where it would cancel out an explicit `-n KnownBug` and silently run 0 tests. + // sbt test -> green lane (KnownBug excluded) + // sbt "testOnly * -- -n KnownBug" -> bug lane (only the regressions; all should fail) + Test / test / testOptions += Tests.Argument(TestFrameworks.ScalaTest, "-l", "KnownBug"), + + // A navigable static HTML report (test tree, durations, failure detail) written to + // target/test-html, for CI to upload as a downloadable artifact. ScalaTest's `-h` reporter + // renders via flexmark (dependency below). Scoped to the `test` task, like `-l` above, so it + // runs on `sbt test` (the CI green lane) without slowing targeted `testOnly` runs. + Test / test / testOptions += Tests.Argument(TestFrameworks.ScalaTest, "-h", "target/test-html"), - "dev.zio" % "zio-direct_3" % "1.0.0-RC1", - "com.microsoft.playwright" % "playwright" % "1.28.1", - "net.datafaker" % "datafaker" % "1.7.0", + // Silence JDK-21 reflective-access warnings from the Pulsar client's Netty transport. + Test / javaOptions ++= Seq( + "--add-opens=java.base/java.lang=ALL-UNNAMED", + "--add-opens=java.base/java.nio=ALL-UNNAMED", + "--add-opens=java.base/jdk.internal.misc=ALL-UNNAMED", + "--add-opens=java.base/sun.nio.ch=ALL-UNNAMED", + "--add-opens=java.base/sun.net=ALL-UNNAMED", // Pulsar DnsResolverUtil + ), - // Pulsar + libraryDependencies ++= Seq( + // Browser automation (page objects + harness live in src/main → compile scope) + "com.microsoft.playwright" % "playwright" % playwrightVersion, + + // Admin + test-data generation, all-JVM "org.apache.pulsar" % "pulsar-client-original" % pulsarVersion, "org.apache.pulsar" % "pulsar-client-admin-original" % pulsarVersion, + + "net.datafaker" % "datafaker" % "2.0.2", + "org.slf4j" % "slf4j-simple" % "2.0.13", // quiet the Pulsar client's SLF4J + + // Test framework + "org.scalatest" %% "scalatest" % scalatestVersion % Test, + // Renders ScalaTest's `-h` HTML report (see testOptions above); required on the classpath. + "com.vladsch.flexmark" % "flexmark-all" % "0.64.8" % Test, ), - testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") ) diff --git a/e2e/project/build.properties b/e2e/project/build.properties index 8b9a0b0ab..04267b14a 100644 --- a/e2e/project/build.properties +++ b/e2e/project/build.properties @@ -1 +1 @@ -sbt.version=1.8.0 +sbt.version=1.9.9 diff --git a/e2e/scripts/run-dekaf.sh b/e2e/scripts/run-dekaf.sh new file mode 100755 index 000000000..63c99b65b --- /dev/null +++ b/e2e/scripts/run-dekaf.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# +# Run the Dekaf server locally on :8090, pointed at the e2e Pulsar standalone. +# Prereqs: node + npm (UI build), sbt, and the per-arch envoy.bin shipped in bin/. +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo="$(cd "$here/../.." && pwd)" + +ADMIN_PORT="${PULSAR_ADMIN_PORT:-18080}" +BROKER_PORT="${PULSAR_BROKER_PORT:-6650}" +DEKAF_PORT="${DEKAF_PORT:-8090}" + +# The embedded Envoy proxy (what actually listens on DEKAF_PORT) must be on PATH. +bin_dir="$(node "$repo/bin/get-bin-dir.js")" +export PATH="$PATH:$bin_dir" + +export DEKAF_PORT +export DEKAF_PUBLIC_BASE_URL="http://localhost:${DEKAF_PORT}" +export DEKAF_PULSAR_WEB_URL="http://localhost:${ADMIN_PORT}" +export DEKAF_PULSAR_BROKER_URL="pulsar://localhost:${BROKER_PORT}" +export DEKAF_PULSAR_NAME="dekaf-e2e-local" + +# --- Clean-checkout prerequisites (both are gitignored build artifacts) --------------------------- +# 1) ui/node_modules - the UI build needs its deps installed. +if [ ! -d "$repo/ui/node_modules" ]; then + echo "Installing UI dependencies (ui/)..." + (cd "$repo/ui" && { npm ci || npm install; }) +fi + +# 2) server/data/js/dist/libs.js - ConsumerSessionContext.scala reads this at startup, so the server +# fails to boot without it. Built by `server/Makefile: build-any` (cd data/js && npm i && npm run build). +if [ ! -f "$repo/server/data/js/dist/libs.js" ]; then + echo "Building the consumer-session JS bundle (server/data/js)..." + (cd "$repo/server/data/js" && npm install && npm run build) +fi + +echo "Building UI bundle (ui/)..." +(cd "$repo/ui" && npm run build) + +echo "Starting Dekaf on :${DEKAF_PORT} → admin :${ADMIN_PORT}, broker :${BROKER_PORT} ..." +cd "$repo/server" && exec sbt run diff --git a/e2e/scripts/stack-down.sh b/e2e/scripts/stack-down.sh new file mode 100755 index 000000000..5626b834f --- /dev/null +++ b/e2e/scripts/stack-down.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Tear down the local Pulsar standalone started by stack-up.sh. +set -euo pipefail +NAME="${PULSAR_CONTAINER_NAME:-dekaf-e2e-pulsar}" +if docker rm -f "$NAME" >/dev/null 2>&1; then + echo "Stopped and removed '$NAME'." +else + echo "No '$NAME' container running." +fi diff --git a/e2e/scripts/stack-up-cluster.sh b/e2e/scripts/stack-up-cluster.sh new file mode 100755 index 000000000..5086089ae --- /dev/null +++ b/e2e/scripts/stack-up-cluster.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# +# OPTIONAL "full-shape" LOCAL stack: a multi-broker / multi-bookie Pulsar cluster via the +# official apache/pulsar Helm chart on a local k3d cluster. Heavier than stack-up.sh - use +# when you need multi-broker behavior (or, later, geo-replication with a second release). +# +# Prereqs: docker, k3d, kubectl, helm. +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +K3D_CLUSTER="${K3D_CLUSTER:-dekaf-e2e}" +CHART_VERSION="${PULSAR_CHART_VERSION:-3.0.0}" +NS="${PULSAR_NS:-pulsar}" +RELEASE="${PULSAR_RELEASE:-pulsar}" +ADMIN_PORT="${PULSAR_ADMIN_PORT:-18080}" +BROKER_PORT="${PULSAR_BROKER_PORT:-6650}" + +if ! k3d cluster list | awk '{print $1}' | grep -qx "$K3D_CLUSTER"; then + echo "Creating k3d cluster '$K3D_CLUSTER'..." + # 1 server + 2 agents = 3 nodes. The chart gives bookies a mandatory hostname anti-affinity, so + # values-test.yaml's 3 bookie replicas need 3 distinct nodes or one stays Pending and --wait times out. + k3d cluster create "$K3D_CLUSTER" --agents 2 +fi +kubectl config use-context "k3d-$K3D_CLUSTER" >/dev/null + +helm repo add apache https://pulsar.apache.org/charts >/dev/null 2>&1 || true +helm repo update apache >/dev/null + +echo "Installing Pulsar (chart $CHART_VERSION, release '$RELEASE' in ns '$NS')..." +kubectl create namespace "$NS" >/dev/null 2>&1 || true +helm upgrade "$RELEASE" apache/pulsar \ + --install --version "$CHART_VERSION" \ + --namespace "$NS" \ + --set clusterName="$RELEASE" \ + -f "$here/values-test.yaml" \ + --timeout 15m --wait + +echo "Port-forwarding admin :$ADMIN_PORT and broker :$BROKER_PORT (leave this running)..." +# Forward to the proxy service's NAMED ports: its HTTP service port is 80 (not 8080 - that is the +# container port), so a numeric "$ADMIN_PORT:8080" fails. Names are stable across chart versions. +kubectl -n "$NS" port-forward "svc/${RELEASE}-proxy" "$ADMIN_PORT:http" "$BROKER_PORT:pulsar" & +PF_PID=$! +# Always tear the port-forward down on exit (success or failure) so a failed run leaves nothing behind. +trap 'kill "$PF_PID" 2>/dev/null || true' EXIT + +printf "Waiting for the admin endpoint to answer" +ready=false +for _ in $(seq 1 60); do + # A dead port-forward can never become ready - fail fast instead of probing a corpse 60 times. + if ! kill -0 "$PF_PID" 2>/dev/null; then + echo + echo "ERROR: the port-forward (pid $PF_PID) died before the admin endpoint became ready." >&2 + exit 1 + fi + if curl -sf "http://localhost:$ADMIN_PORT/admin/v2/brokers/ready" >/dev/null 2>&1; then + echo " ready." + ready=true + break + fi + printf "." + sleep 2 +done + +if [ "$ready" != true ]; then + echo + echo "ERROR: the admin endpoint at http://localhost:$ADMIN_PORT did not become ready in time." >&2 + exit 1 +fi + +# Made it: hand the port-forward off to the foreground `wait` below; drop the kill-on-exit trap so +# the long-lived tunnel isn't torn down when the script's setup phase ends. +trap - EXIT + +cat </dev/null +fi + +echo "Starting Pulsar $PULSAR_VERSION standalone (admin :$ADMIN_PORT, broker :$BROKER_PORT)..." +# force-delete flags let the per-test teardown wipe tenants/namespaces cleanly. +docker run -d --name "$NAME" \ + -p "$ADMIN_PORT:8080" \ + -p "$BROKER_PORT:6650" \ + -e PULSAR_PREFIX_forceDeleteNamespaceAllowed=true \ + -e PULSAR_PREFIX_forceDeleteTenantAllowed=true \ + apachepulsar/pulsar:"$PULSAR_VERSION" \ + sh -c "bin/apply-config-from-env.py conf/standalone.conf && bin/pulsar standalone" >/dev/null + +printf "Waiting for broker to become ready" +for _ in $(seq 1 90); do + if curl -sf "http://localhost:$ADMIN_PORT/admin/v2/brokers/ready" >/dev/null 2>&1; then + echo " ready." + echo "Clusters: $(curl -s "http://localhost:$ADMIN_PORT/admin/v2/clusters")" + cat < + if v.length >= 2 && v.startsWith("\"") && v.endsWith("\"") then v.substring(1, v.length - 1) else v + + /** The rendered (DOM) width of a column header in px - proves a persisted width is actually applied. */ + def columnDomWidth(key: String): Double = + val b = th(key).boundingBox() + if b == null then -1.0 else b.width + + /** Wait for the message table header to be rendered (e.g. after a reload). */ + def waitHeader(timeoutMs: Double = 20000): Unit = + assertThat(th("publishTime")).isVisible(new LocatorAssertions.IsVisibleOptions().setTimeout(timeoutMs)) + + /** Scroll the message rows by `dy` px (wheel over the table; negative = up), then let it settle. */ + def scrollTableBy(dy: Int): Unit = + val b = table.boundingBox() + page.mouse().move(b.x + b.width / 2, b.y + b.height / 2) + page.mouse().wheel(0, dy) + page.waitForTimeout(400) + + def scrollTableDown(dy: Int): Unit = scrollTableBy(dy) + /** Scroll the message rows all the way to the top (a running session auto-scrolls to the bottom). */ + def scrollTableToTop(): Unit = scrollTableBy(-100000) + + /** The message index (`displayIndex`, in the first `cs-message` cell) of the first currently- + * rendered row - it rises when the (virtualized) rows actually scroll. */ + def firstRenderedIndex: Int = + messages.allInnerTexts().asScala.headOption.map(_.trim).flatMap(_.toIntOption).getOrElse(-1) + + // --- Scoped sub-objects --- + def sessionFilterPanel: FilterPanel = FilterPanel(page, sessionFilters) + /** The per-target filter chain (visible without "advanced"); this is the path that actually filters + * the streamed rows. Session-level filters only edit config in these tests. */ + def targetFilterPanel: FilterPanel = FilterPanel(page, targetFilters) + def target(i: Int = 0): TargetSelector = TargetSelector(page, targets.nth(i)) + + // --- Navigation --- + def openForTopic(tenant: String, namespace: String, topic: String): Unit = + page.navigate(s"/tenants/$tenant/namespaces/$namespace/topics/persistent/$topic/consumer-session") + + /** Namespace-level mount (no current topic) - drives CS-7. */ + def openForNamespace(tenant: String, namespace: String): Unit = + page.navigate(s"/tenants/$tenant/namespaces/$namespace/consumer-session") + + def setStartFrom(label: String): Unit = + startFromSelect.selectOption(new SelectOption().setLabel(label)) + + def revealAdvanced(): Unit = advancedToggle.click() + def setDeserializer(label: String): Unit = deserializerSelect.selectOption(new SelectOption().setLabel(label)) + def addTarget(): Unit = addTargetButton.click() + + def addProjection(): Unit = + sessionProjections.getByRole(AriaRole.BUTTON, new Locator.GetByRoleOptions().setName("Add Projection")).click() + + def addColoringRule(): Unit = + sessionColoring.getByRole(AriaRole.BUTTON, new Locator.GetByRoleOptions().setName("Add Coloring Rule")).click() + + /** Click a palette swatch by its color name (e.g. "red-500"); auto-applies + closes the modal. */ + def pickSwatch(colorName: String): Unit = + page.locator(s"[data-tooltip-content='$colorName']").click() + + /** Start (or resume) the session. The first play in a fresh context pops the "Pulsar Credentials" + * modal; give it a beat to render, then dismiss it if present and re-click play. */ + def play(): Unit = + playButton.click() + page.waitForTimeout(500) + val credentialsModal = page.getByText("Pulsar Credentials") + if credentialsModal.isVisible then + page.getByRole(AriaRole.BUTTON, new GetByRoleOptions().setName("Done").setExact(true)).click() + playButton.click() + + def stop(): Unit = stopButton.click() + def clickFirstMessage(): Unit = messages.first().click() + def searchInResults(t: String): Unit = searchInput.fill(t) + // Force-click: the button's own "Toggle additional tools" tooltip can overlay it and intercept a normal click. + def openTools(): Unit = toolsButton.click(new Locator.ClickOptions().setForce(true)) + + // --- state (CS-16..19) --- + def state: String = session.getAttribute("data-cs-state") + def assertState(s: String, timeoutMs: Double = 20000): Unit = + assertThat(session).hasAttribute("data-cs-state", s, new LocatorAssertions.HasAttributeOptions().setTimeout(timeoutMs)) + + /** While `running`, clicking Play requests a pause (Toolbar: running -> 'pausing'). */ + def pauseFromToolbar(): Unit = playButton.click() + + def waitMessages(n: Int, timeoutMs: Double = 20000): Unit = + assertThat(messages).hasCount(n, new LocatorAssertions.HasCountOptions().setTimeout(timeoutMs)) + + /** Wait until the toolbar reports `n` messages loaded. Use this instead of `waitMessages` when n is + * larger than a viewport - the message table is virtualized, so DOM rows != loaded messages. */ + def awaitLoaded(n: Int, timeoutMs: Double = 30000): Unit = + assertThat(loaded).hasText(n.toString, new LocatorAssertions.HasTextOptions().setTimeout(timeoutMs)) + + // --- lifecycle triggers (CS-18) --- + def wheelUpOverTable(): Unit = + val b = table.boundingBox() + page.mouse().move(b.x + b.width / 2, b.y + b.height / 2) + page.mouse().wheel(0, -150) + + /** Simulate a window/tab blur (the app pauses on visibilitychange -> hidden). */ + def blurWindow(): Unit = + page.evaluate( + """() => { + | Object.defineProperty(document, 'hidden', { configurable: true, get: () => true }); + | Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' }); + | document.dispatchEvent(new Event('visibilitychange', { bubbles: true })); + |}""".stripMargin) + + // --- table (CS-20/21/22/26) --- + // The CS message table renders outside a , so ARIA columnheader role isn't implied; + // count the instrumented header cells instead. + def headerCount: Int = table.locator("[data-testid^='cs-th-']").count() + /** 'sticky' if the header cell or any ancestor is position:sticky, else 'not-sticky'. */ + def firstHeaderPosition: String = + table.locator("[data-testid^='cs-th-']").first().evaluate( + "el => { let e = el; while (e && e !== document.body) { if (getComputedStyle(e).position === 'sticky') return 'sticky'; e = e.parentElement; } return 'not-sticky'; }" + ).asInstanceOf[String] + + def sortBy(key: String): Unit = th(key).click() + def sortAttr: String = table.getAttribute("data-cs-sort") + def assertSort(v: String, timeoutMs: Double = 5000): Unit = + assertThat(table).hasAttribute("data-cs-sort", v, new LocatorAssertions.HasAttributeOptions().setTimeout(timeoutMs)) + + /** Drag a column's resize handle by `dx` px (document-level mousemove/up). */ + def resizeColumn(key: String, dx: Int): Unit = + val handle = th(key).getByTestId("table-resize-handle") + val b: BoundingBox = handle.boundingBox() + val sx = b.x + b.width / 2 + val sy = b.y + b.height / 2 + page.mouse().move(sx, sy) + page.mouse().down() + page.mouse().move(sx + dx, sy, new Mouse.MoveOptions().setSteps(8)) + page.mouse().up() + + /** The persisted width for a column, or -1 if unset. */ + def storedColumnWidth(key: String): Double = + val v = page.evaluate( + s"""() => { const w = JSON.parse(localStorage.getItem('table:consumer-session-messages:column-widths') || '{}'); return (w['$key'] ?? -1); }""") + v match { case n: Number => n.doubleValue(); case _ => -1.0 } + + /** Focus the message table so real keyboard input reaches it (it is `tabIndex=0`). */ + def focusTable(): Unit = table.focus() + + /** Press a key as a REAL keyboard user would - the table must be focused first. Exercises the true + * accessibility path (previously this dispatched a synthetic keydown, bypassing focus entirely). + * Respects the 64ms in-app debounce between calls. */ + def pressKeyOnTable(key: String): Unit = + page.keyboard().press(key) + page.waitForTimeout(120) diff --git a/e2e/src/main/scala/features/consumersession/ExportModal.scala b/e2e/src/main/scala/features/consumersession/ExportModal.scala new file mode 100644 index 000000000..6c8c5d9db --- /dev/null +++ b/e2e/src/main/scala/features/consumersession/ExportModal.scala @@ -0,0 +1,17 @@ +package features.consumersession + +import com.microsoft.playwright.{Locator, Page} +import com.microsoft.playwright.options.SelectOption + +/** The "Export N messages" modal (opened from the toolbar's `cs-export-open`). */ +final case class ExportModal(page: Page): + val root: Locator = page.getByTestId("modal") + val format: Locator = page.getByTestId("cs-export-format") + val runButton: Locator = page.getByTestId("cs-export-run") + val resetButton: Locator = page.getByTestId("cs-export-reset") + val fieldRows: Locator = page.locator("[data-testid^='cs-export-field-']") + + def formatOptionCount: Int = format.locator("option").count() + def selectFormat(value: String): Unit = + format.selectOption(new SelectOption().setValue(value)) + def close(): Unit = root.press("Escape") diff --git a/e2e/src/main/scala/features/consumersession/FilterPanel.scala b/e2e/src/main/scala/features/consumersession/FilterPanel.scala new file mode 100644 index 000000000..0ea982f83 --- /dev/null +++ b/e2e/src/main/scala/features/consumersession/FilterPanel.scala @@ -0,0 +1,38 @@ +package features.consumersession + +import com.microsoft.playwright.{Locator, Page} +import com.microsoft.playwright.options.{AriaRole, SelectOption} + +/** Ops on ONE message-filter-chain editor, scoped to its container locator + * (e.g. `cs-session-filters`). Add buttons are located by role within the scope. */ +final case class FilterPanel(page: Page, root: Locator): + def addButton: Locator = + root.getByRole(AriaRole.BUTTON, new Locator.GetByRoleOptions().setName("Add Filter")) + + // Per-filter controls (use .first() when >1 filter present). + def opSelect: Locator = root.getByTestId("cs-filter-op") + def valueInput: Locator = root.getByTestId("cs-filter-value") + def modeToggle: Locator = root.getByTestId("cs-filter-mode") // Basic <-> JavaScript + def enableToggle: Locator = root.getByTestId("cs-filter-enable") + def negateToggle: Locator = root.getByTestId("cs-filter-negate") + def logicToggle: Locator = root.getByTestId("cs-filter-logic") // AND <-> OR (needs >=2 filters) + def jsEditor: Locator = root.getByTestId("cs-js-filter") + + def addFilter(): Unit = addButton.click() + def setOp(label: String): Unit = opSelect.first().selectOption(new SelectOption().setLabel(label)) + def setValue(v: String): Unit = valueInput.first().fill(v) + + /** The Basic<->JS mode toggle lives in the LibraryBrowserPanel's hover-gated `postItemType`, + * so hover the (always-visible) enable toggle to reveal it before clicking. */ + private def clickMode(): Unit = + enableToggle.first().hover() + modeToggle.first().click() + def switchToJs(): Unit = clickMode() + def switchToBasic(): Unit = clickMode() + + /** Replace the JS body. Monaco auto-close is idempotent (type-over) for well-formed input. */ + def writeJs(code: String): Unit = + jsEditor.locator(".monaco-editor").click() + page.keyboard().press("ControlOrMeta+A") + page.keyboard().press("Delete") + page.keyboard().`type`(code) diff --git a/e2e/src/main/scala/features/consumersession/TargetSelector.scala b/e2e/src/main/scala/features/consumersession/TargetSelector.scala new file mode 100644 index 000000000..d7cbebe1b --- /dev/null +++ b/e2e/src/main/scala/features/consumersession/TargetSelector.scala @@ -0,0 +1,23 @@ +package features.consumersession + +import com.microsoft.playwright.{Locator, Page} +import com.microsoft.playwright.options.{AriaRole, SelectOption} + +/** Topic-selector controls of ONE target, scoped to its `cs-target` column. */ +final case class TargetSelector(page: Page, root: Locator): + def modeSelect: Locator = root.getByTestId("cs-target-mode") + def fqnList: Locator = root.getByTestId("cs-target-fqn-list") + def fqnInput: Locator = root.getByTestId("cs-target-fqn-input") + def regexPattern: Locator = root.getByTestId("cs-target-regex-pattern") + def notApplicable: Locator = root.getByTestId("cs-target-not-applicable") + def resolveCount: Locator = root.getByTestId("cs-resolve-count") + def moveLeft: Locator = root.getByTestId("cs-target-move-left") + def moveRight: Locator = root.getByTestId("cs-target-move-right") + def remove: Locator = root.getByTestId("cs-target-remove") + + def addFqnButton: Locator = + fqnList.getByRole(AriaRole.BUTTON, new Locator.GetByRoleOptions().setName("Add Topic")) + + def setMode(label: String): Unit = modeSelect.selectOption(new SelectOption().setLabel(label)) + def typeFqn(fqn: String): Unit = fqnInput.fill(fqn) + def addFqn(fqn: String): Unit = { fqnInput.fill(fqn); addFqnButton.click() } diff --git a/e2e/src/main/scala/features/consumersession/ToolsPanel.scala b/e2e/src/main/scala/features/consumersession/ToolsPanel.scala new file mode 100644 index 000000000..cf91e5e17 --- /dev/null +++ b/e2e/src/main/scala/features/consumersession/ToolsPanel.scala @@ -0,0 +1,18 @@ +package features.consumersession + +import com.microsoft.playwright.{Locator, Page} + +/** The Tools/Console bottom panel (toggled by `cs-tools`). */ +final case class ToolsPanel(page: Page): + val produceTab: Locator = page.getByTestId("console-tab-produce") + val replTab: Locator = page.getByTestId("console-tab-repl") + val logsTab: Locator = page.getByTestId("console-tab-logs") + + val produceSend: Locator = page.getByTestId("produce-send") // pre-existing (Producer) + + val replEditor: Locator = page.getByTestId("cs-repl-editor") + val replRun: Locator = page.getByTestId("cs-repl-run") + val replClear: Locator = page.getByTestId("cs-repl-clear") + val replLogs: Locator = page.getByTestId("cs-repl-logs") + + val logs: Locator = page.getByTestId("cs-logs") diff --git a/e2e/src/main/scala/features/library/LibraryBrowser.scala b/e2e/src/main/scala/features/library/LibraryBrowser.scala new file mode 100644 index 000000000..089502c0a --- /dev/null +++ b/e2e/src/main/scala/features/library/LibraryBrowser.scala @@ -0,0 +1,39 @@ +package features.library + +import com.microsoft.playwright.{Locator, Page} +import com.microsoft.playwright.options.SelectOption +import ui.ConfirmationDialog + +/** The Browse-Library dialog: search-results list with client-side filter/sort, per-row edit/delete, + * and (for consumer-session-config) a Select button. Scoped to the browse dialog root so it never + * matches an Overwrite dialog's rows stacked on top. */ +final case class LibraryBrowser(page: Page): + private val root: Locator = page.getByTestId("lib-browse-dialog") + val filterInput: Locator = root.getByTestId("lib-search-filter") + val sortSelect: Locator = root.getByTestId("lib-search-sort") + val results: Locator = root.getByTestId("lib-search-result") + val selectButton: Locator = page.getByTestId("lib-browse-select") + + def result(name: String): Locator = + results.filter(new Locator.FilterOptions().setHasText(name)) + + def filter(text: String): Unit = filterInput.fill(text) + + /** value = "-", e.g. "Name-asc", "Name-desc", "Last Modified-desc". */ + def sortBy(value: String): Unit = sortSelect.selectOption(new SelectOption().setValue(value)) + + def editItem(name: String): Unit = result(name).getByTestId("lib-item-edit").click() + + def deleteItem(name: String): Unit = + result(name).getByTestId("lib-item-delete").click() + ConfirmationDialog(page).confirmButton.click() // shared confirmation-dialog-confirm-button + +/** The Overwrite-Another dialog (OverwriteExistingItemDialog); its result rows are scoped to its own root. */ +final case class LibraryOverwriteDialog(page: Page): + private val root: Locator = page.getByTestId("lib-overwrite-dialog") + val results: Locator = root.getByTestId("lib-search-result") + val overwriteButton: Locator = page.getByTestId("lib-overwrite-confirm") + val cancelButton: Locator = page.getByTestId("lib-overwrite-cancel") + + def select(name: String): Unit = + results.filter(new Locator.FilterOptions().setHasText(name)).click() diff --git a/e2e/src/main/scala/features/library/LibrarySaveDialog.scala b/e2e/src/main/scala/features/library/LibrarySaveDialog.scala new file mode 100644 index 000000000..59b433a5c --- /dev/null +++ b/e2e/src/main/scala/features/library/LibrarySaveDialog.scala @@ -0,0 +1,20 @@ +package features.library + +import com.microsoft.playwright.{Locator, Page} + +/** The Save/Create library-item dialog (SaveItemDialog) + its shared Set-Name dialog (EditNameDialog). */ +final case class LibrarySaveDialog(page: Page): + val saveButton: Locator = page.getByTestId("lib-save-dialog-save") // "Create" (new) / "Save" (existing) + val saveAsNewButton: Locator = page.getByTestId("lib-save-dialog-save-as-new") + val overwriteAnotherButton: Locator = page.getByTestId("lib-save-dialog-overwrite-another") + val cancelButton: Locator = page.getByTestId("lib-save-dialog-cancel") + // Shared Set-Name / rename dialog (EditNameDialog) + val nameInput: Locator = page.getByTestId("lib-name-input") + val nameConfirm: Locator = page.getByTestId("lib-name-confirm") + val nameCancel: Locator = page.getByTestId("lib-name-cancel") + + /** Clone an existing item under a new name (Save as New -> Set-Name). */ + def saveAsNew(name: String): Unit = + saveAsNewButton.click() + nameInput.fill(name) + nameConfirm.click() diff --git a/e2e/src/main/scala/features/library/LibrarySidebar.scala b/e2e/src/main/scala/features/library/LibrarySidebar.scala new file mode 100644 index 000000000..d0fdb763a --- /dev/null +++ b/e2e/src/main/scala/features/library/LibrarySidebar.scala @@ -0,0 +1,71 @@ +package features.library + +import com.microsoft.playwright.{Locator, Page} +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions + +/** Component object for the Library sidebar (mounted in the RightPanel of resource overview pages). */ +final case class LibrarySidebar(page: Page): + // Outer tabs + val libraryTab: Locator = page.getByTestId("lib-tab-library") + val notesTab: Locator = page.getByTestId("lib-tab-notes") + // Library sub-tabs + val consumerSessionsSubtab: Locator = page.getByTestId("lib-subtab-consumer-sessions") + val allItemsSubtab: Locator = page.getByTestId("lib-subtab-all-items") + // Consumer Sessions sub-tab + val newConsumerSessionButton: Locator = page.getByTestId("lib-new-consumer-session") + // Notes + val createFirstNoteButton: Locator = page.getByTestId("lib-create-first-note") + val newNoteButton: Locator = page.getByTestId("lib-new-note") + val noteTabs: Locator = page.getByTestId("lib-note-tab") + + def openLibraryTab(): Unit = libraryTab.click() + def openNotesTab(): Unit = notesTab.click() + def openConsumerSessionsSubtab(): Unit = consumerSessionsSubtab.click() + def openAllItemsSubtab(): Unit = allItemsSubtab.click() + + /** From the Notes tab: create a note (first-note button when empty, else the "+" new-note button). */ + def createNote(): Unit = + if createFirstNoteButton.isVisible then createFirstNoteButton.click() + else newNoteButton.click() + + /** A note tab addressed by its visible title. */ + def noteTab(name: String): Locator = + noteTabs.filter(new Locator.FilterOptions().setHasText(name)) + + // ----- All Items sub-tab ----- + + /** A managed-type row (itemType = the kebab enum, e.g. "message-filter", "consumer-session-config"). */ + def typeRow(itemType: String): Locator = page.getByTestId(s"lib-type-row-$itemType") + + /** Wait until a type row's count has actually LOADED (`data-loaded=true`) - the UI collapses an + * undefined/errored count to 0, so a bare `hasCount(0)` could otherwise pass before the fetch. */ + def awaitTypeLoaded(itemType: String, timeoutMs: Double = 15000): Unit = + assertThat(page.locator(s"[data-testid=lib-type-row-$itemType][data-loaded=true]")) + .isVisible(new LocatorAssertions.IsVisibleOptions().setTimeout(timeoutMs)) + + /** The "N found" element inside a type row: hasCount(0) when the type has none (NoData "-"). */ + def typeFound(itemType: String): Locator = typeRow(itemType).getByTestId("lib-item-count") + + // ----- "Search in Contexts" (All Items sub-tab) ----- + val addContextButton: Locator = page.getByTestId("lib-add-context") + val matcherCategorySelects: Locator = page.getByTestId("lib-matcher-category") + val matcherNamespaceInputs: Locator = page.getByTestId("lib-matcher-namespace") + + def addSearchContext(): Unit = addContextButton.click() + + /** Full create flow via the row "Create" button -> Save dialog -> name dialog. Waits for the dialog to close. */ + def createItemNamed(itemType: String, name: String): Unit = + openAllItemsSubtab() + typeRow(itemType).getByTestId("lib-create-item").click() + val dlg = LibrarySaveDialog(page) + dlg.saveButton.click() // "Create" with an empty name -> opens the Set-Name dialog + dlg.nameInput.fill(name) + dlg.nameConfirm.click() // sets the name and saves; both dialogs close + assertThat(dlg.saveButton).hasCount(0, new LocatorAssertions.HasCountOptions().setTimeout(15000)) + + /** Open the Browse dialog for a type by clicking its "N found" (requires count >= 1). */ + def browseType(itemType: String): LibraryBrowser = + openAllItemsSubtab() + typeFound(itemType).click() + LibraryBrowser(page) diff --git a/e2e/src/main/scala/harness/Cli.scala b/e2e/src/main/scala/harness/Cli.scala new file mode 100644 index 000000000..535297535 --- /dev/null +++ b/e2e/src/main/scala/harness/Cli.scala @@ -0,0 +1,232 @@ +package harness + +import com.sun.net.httpserver.{HttpExchange, HttpServer} +import java.net.InetSocketAddress +import java.nio.file.{Files, Path, Paths} +import scala.jdk.CollectionConverters.* + +/** Open the Trace Viewer (a local web app: scrubable timeline + a DOM snapshot at every step + + * network/console/source). With no args it serves EVERY recorded trace merged into one timeline; + * with args it serves exactly those. Args may contain `*` globs - expanded here, since the quoted + * sbt command shields them from the shell. Filenames are `____.zip`, so a + * one-test pick is e.g. the glob "star CS-10 star .zip" (spelled out - a literal star after the + * directory slash would nest this comment) under target/traces: + * sbt "runMain harness.ShowTrace" # all traces merged + * Traces are only recorded for FAILING tests by default - record every test with `TRACE=on`. */ +object ShowTrace: + def main(args: Array[String]): Unit = + val traces = if args.nonEmpty then args.toSeq.flatMap(Trace.expandArg) else Trace.recordedTraces.map(_.toString) + if traces.isEmpty then Trace.warnNoTraces() + else com.microsoft.playwright.CLI.main(Array("show-trace", "--host", "127.0.0.1", "--port", "9323") ++ traces) + +/** A browse-and-pick dashboard over every recorded test - the closest thing to Node's `--ui` mode. + * Serves the GENERIC Playwright trace viewer at `/` (same PWA as trace.playwright.dev, so it honors + * `?trace=` and switches between tests) plus a clickable index at `/dashboard`, all from one + * local http origin (no HTTPS→HTTP mixed-content block, no cross-origin). + * TRACE=on sbt test # record a trace per test first + * sbt "runMain harness.TraceDashboard" # then open http://127.0.0.1:9500/dashboard (TRACE_PORT) */ +object TraceDashboard: + def main(args: Array[String]): Unit = + val port = sys.env.getOrElse("TRACE_PORT", "9500").toInt + val traces = Trace.recordedTraces + if traces.isEmpty then { Trace.warnNoTraces(); return } + + // Creating a Playwright instance extracts the driver bundle - which contains the trace-viewer + // assets - into a temp dir that lives as long as THIS JVM. Keep the instance so the dir persists. + val pw = + try com.microsoft.playwright.Playwright.create() + catch case t: Throwable => { System.err.println(s"Could not start Playwright to locate the viewer: $t"); return } + val viewerDir = Trace.locateViewerDir.getOrElse { + System.err.println("Could not locate the bundled trace viewer assets."); pw.close(); return + } + + val server = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 0) + server.createContext("/", (ex: HttpExchange) => + // Everything is same-origin (viewer at `/`, traces at `/traces/`) - deliberately NO CORS header, + // so other origins the user's browser visits cannot read the trace archives. + try + ex.getRequestURI.getPath match + case "/dashboard" | "/dashboard.html" => + // Rebuilt per request: the suite renames/replaces status-stamped traces on every re-run, + // and a stale one-shot index would link to files that no longer exist. + val index = Trace.indexHtml(Trace.recordedTraces, port).getBytes("UTF-8") + ex.getResponseHeaders.add("Content-Type", "text/html; charset=utf-8") + ex.sendResponseHeaders(200, index.length); ex.getResponseBody.write(index) + case p if p.startsWith("/traces/") => + Trace.serveFrom(ex, Trace.tracesDir, p.stripPrefix("/traces/")) + case "/" | "" => + Trace.serveFrom(ex, viewerDir, "index.html") // the generic viewer at root + case p => + Trace.serveFrom(ex, viewerDir, p.stripPrefix("/")) // viewer assets (sw.bundle.js, /assets, …) + catch + case t: Throwable => + System.err.println(s"[trace-dashboard] ${ex.getRequestURI} failed: $t") + try ex.sendResponseHeaders(500, -1) catch case _: Throwable => () // headers may already be sent + finally ex.close() + ) + server.start() + val url = s"http://127.0.0.1:$port/dashboard" + println(s"\n Trace dashboard: $url (${traces.size} tests)\n Ctrl-C to stop.\n") + Trace.openBrowser(url) + Thread.currentThread.join() + +/** Record UI interactions into selectors/code: + * sbt "runMain harness.Codegen http://localhost:8090" */ +object Codegen: + def main(args: Array[String]): Unit = + com.microsoft.playwright.CLI.main("codegen" +: args) + +private object Trace: + val tracesDir: Path = Paths.get("target", "traces").toAbsolutePath.normalize + + def recordedTraces: Seq[Path] = + if !Files.isDirectory(tracesDir) then Seq.empty + else Files.list(tracesDir).iterator().asScala.filter(_.toString.endsWith(".zip")).toSeq.sortBy(_.getFileName.toString) + + def warnNoTraces(): Unit = + System.err.println(s"No traces in $tracesDir.\nRecord one per test with: TRACE=on sbt test") + + /** The extracted `…/package/lib/vite/traceViewer` dir (newest), created by `Playwright.create()`. */ + def locateViewerDir: Option[Path] = + val tmp = Paths.get(System.getProperty("java.io.tmpdir")) + if !Files.isDirectory(tmp) then None + else + Files.list(tmp).iterator().asScala + .filter(_.getFileName.toString.startsWith("playwright-java-")) + .map(_.resolve("package/lib/vite/traceViewer")) + .filter(d => Files.isRegularFile(d.resolve("index.html"))) + .toSeq + .sortBy(d => -Files.getLastModifiedTime(d.resolve("index.html")).toMillis) + .headOption + + /** Expand a `*` glob against its parent directory (the quoted sbt command shields it from the + * shell); non-glob args pass through untouched. */ + def expandArg(arg: String): Seq[String] = + if !arg.contains('*') then Seq(arg) + else + val p = Paths.get(arg) + val dir = Option(p.getParent).getOrElse(Paths.get(".")) + if !Files.isDirectory(dir) then Seq.empty + else + val matcher = dir.getFileSystem.getPathMatcher(s"glob:${p.getFileName}") + Files.list(dir).iterator().asScala + .filter(f => matcher.matches(f.getFileName)).map(_.toString).toSeq.sorted + + /** Serve `relative` under `baseDir` (path-traversal guarded) with a sensible content type. + * Bytes are read before any headers are sent, so a trace replaced mid-request by a concurrently + * running suite yields a clean 404 instead of a dropped connection. */ + def serveFrom(ex: HttpExchange, baseDir: Path, relative: String): Unit = + val file = baseDir.resolve(relative).normalize + if !file.startsWith(baseDir) then ex.sendResponseHeaders(404, -1) + else + val bytes = + try if Files.isRegularFile(file) then Some(Files.readAllBytes(file)) else None + catch case _: java.nio.file.NoSuchFileException => None + bytes match + case None => ex.sendResponseHeaders(404, -1) + case Some(b) => + ex.getResponseHeaders.add("Content-Type", mime(file.getFileName.toString)) + ex.sendResponseHeaders(200, b.length) + ex.getResponseBody.write(b) + + private def mime(name: String): String = name.toLowerCase match + case n if n.endsWith(".html") => "text/html; charset=utf-8" + case n if n.endsWith(".js") => "text/javascript; charset=utf-8" + case n if n.endsWith(".mjs") => "text/javascript; charset=utf-8" + case n if n.endsWith(".css") => "text/css; charset=utf-8" + case n if n.endsWith(".svg") => "image/svg+xml" + case n if n.endsWith(".ttf") => "font/ttf" + case n if n.endsWith(".woff2") => "font/woff2" + case n if n.endsWith(".webmanifest") => "application/manifest+json" + case n if n.endsWith(".json") => "application/json" + case n if n.endsWith(".zip") => "application/zip" + case _ => "application/octet-stream" + + private val Statuses = Set("PASS", "FAIL", "BUG", "SKIP") + + /** Filename shape is `____.zip` (status stamped by DekafSuite). + * Old traces without a status prefix parse as `("", suite, test)`. Returns (status, suite, test). */ + private def parse(p: Path): (String, String, String) = + val base = p.getFileName.toString.stripSuffix(".zip") + val head = base.split("__", 3) + if head.length == 3 && Statuses.contains(head(0)) then (head(0), head(1), head(2).replace('_', ' ')) + else base.split("__", 2) match + case Array(suite, test) => ("", suite, test.replace('_', ' ')) + case _ => ("", "(other)", base) + + private def statusLabel(st: String): String = st match + case "PASS" => "passed"; case "FAIL" => "failed"; case "BUG" => "known bug" + case "SKIP" => "skipped"; case _ => "" + + def indexHtml(traces: Seq[Path], port: Int): String = + val esc = (s: String) => s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """) + val entries = traces.map { p => val (st, suite, test) = parse(p); (st, suite, test, p.getFileName.toString) } + def count(st: String) = entries.count(_._1 == st) + // A filter chip per status that actually occurs, in severity order, plus an always-present "All". + val chipDefs = Seq("FAIL" -> "Failed", "BUG" -> "Known bug", "PASS" -> "Passed", "SKIP" -> "Skipped") + val chips = (s"""""" +: + chipDefs.collect { case (st, name) if count(st) > 0 => + s"""""" + }).mkString("\n") + + val bySuite = entries.groupBy(_._2).toSeq.sortBy(_._1) + val sections = bySuite.map { (suite, es) => + val rows = es.sortBy(_._3).map { case (st, _, test, file) => + // Same-origin: the generic viewer at `/` loads the trace served at `/traces/`. + val href = s"/?trace=http://127.0.0.1:$port/traces/${java.net.URLEncoder.encode(file, "UTF-8")}" + val badge = if st.isEmpty then "" else s"""${statusLabel(st)}""" + s"""
  • ${esc(test)}$badge
  • """ + }.mkString("\n") + s"""

    ${esc(suite)} ${es.size}

      $rows
    """ + }.mkString("\n") + + s"""Dekaf E2E - trace dashboard + | + |

    Dekaf E2E - trace dashboard

    + |

    ${entries.size} recorded traces · click any to open its trace (timeline · DOM snapshots · network · console) in the Playwright viewer. Filter with the chips.

    + |
    $chips
    + |$sections + | + |""".stripMargin + + def openBrowser(url: String): Unit = + val os = sys.props.getOrElse("os.name", "").toLowerCase + val cmd = if os.contains("mac") then Array("open", url) else Array("xdg-open", url) + try Runtime.getRuntime.exec(cmd) catch case _: Throwable => println(s" Open manually: $url") diff --git a/e2e/src/main/scala/harness/Config.scala b/e2e/src/main/scala/harness/Config.scala new file mode 100644 index 000000000..58414a7a6 --- /dev/null +++ b/e2e/src/main/scala/harness/Config.scala @@ -0,0 +1,30 @@ +package harness + +import java.nio.file.{Path, Paths} + +/** Env-driven configuration. Defaults match the shipped local stack (Dekaf on :8090; Pulsar + * standalone from `scripts/stack-up.sh` publishes admin on :18080, broker on :6650). */ +object Config: + private def env(key: String, default: String): String = sys.env.getOrElse(key, default) + + /** Lenient boolean: accepts 1/true/yes/on (any case). Avoids `"1".toBoolean` throwing. */ + private def envFlag(key: String): Boolean = + sys.env.get(key).map(_.trim.toLowerCase).exists(Set("1", "true", "yes", "on")) + + val baseUrl: String = env("DEKAF_BASE_URL", "http://localhost:8090") + val pulsarAdminUrl: String = env("PULSAR_ADMIN_URL", "http://localhost:18080") + val pulsarServiceUrl: String = env("PULSAR_SERVICE_URL", "pulsar://localhost:6650") + + // Headed when explicitly asked, or whenever the Playwright Inspector is on. + val headed: Boolean = envFlag("HEADED") || sys.env.contains("PWDEBUG") + val slowMoMs: Double = env("SLOWMO", "0").toDouble + + enum TraceMode: + case On, Off, RetainOnFailure + + val trace: TraceMode = env("TRACE", "retain-on-failure").toLowerCase match + case "on" => TraceMode.On + case "off" => TraceMode.Off + case _ => TraceMode.RetainOnFailure + + val tracesDir: Path = Paths.get("target", "traces") diff --git a/e2e/src/main/scala/harness/Eventually.scala b/e2e/src/main/scala/harness/Eventually.scala new file mode 100644 index 000000000..cb9601267 --- /dev/null +++ b/e2e/src/main/scala/harness/Eventually.scala @@ -0,0 +1,15 @@ +package harness + +/** Poll an admin-side assertion until it passes or the deadline elapses. + * Used to wait out admin/gRPC propagation without fixed sleeps. */ +object Eventually: + def eventually[T](timeoutMs: Long = 15000, intervalMs: Long = 300)(op: => T): T = + val deadline = System.currentTimeMillis() + timeoutMs + var last: Throwable = new AssertionError("eventually: assertion never evaluated") + while System.currentTimeMillis() < deadline do + try return op + catch + case t: Throwable => + last = t + Thread.sleep(intervalMs) + throw last diff --git a/e2e/src/main/scala/harness/PulsarFixtures.scala b/e2e/src/main/scala/harness/PulsarFixtures.scala new file mode 100644 index 000000000..8c72c349d --- /dev/null +++ b/e2e/src/main/scala/harness/PulsarFixtures.scala @@ -0,0 +1,178 @@ +package harness + +import net.datafaker.Faker +import org.apache.pulsar.client.admin.PulsarAdmin +import org.apache.pulsar.client.api.{PulsarClient, Schema} +import org.apache.pulsar.common.policies.data.{ClusterData, ResourceGroup, TenantInfo} + +import scala.collection.mutable +import scala.jdk.CollectionConverters.* + +/** Per-suite Pulsar admin + client, unique-name minting, and a teardown registry. + * `PulsarAdmin` is the oracle for every mutation; `PulsarClient` generates test messages + * (schema'd / keyed / delayed) - the JVM clients are why we stayed all-JVM. */ +class PulsarFixtures: + private val faker = new Faker() + + val admin: PulsarAdmin = + PulsarAdmin.builder().serviceHttpUrl(Config.pulsarAdminUrl).build() + + PulsarFixtures.sweepStaleClusters(admin) + + // Opened lazily - only tests that produce messages pay the connection cost. + private var clientOpt: Option[PulsarClient] = None + def client: PulsarClient = + clientOpt.getOrElse: + val c = PulsarClient.builder().serviceUrl(Config.pulsarServiceUrl).build() + clientOpt = Some(c) + c + + private val createdTenants = mutable.ListBuffer.empty[String] + private val createdClusters = mutable.ListBuffer.empty[String] + private val createdResourceGroups = mutable.ListBuffer.empty[String] + private val extraCleanups = mutable.ListBuffer.empty[() => Unit] + // Cleanup is best-effort per test (teardown must never fail the OWNING test), but failures leak + // resources that can contaminate later tests - so they accumulate here and the suite surfaces + // them at the end (see DekafSuite.afterAll) instead of vanishing into stderr. + private val cleanupFailures = mutable.ListBuffer.empty[String] + def cleanupFailuresSnapshot: List[String] = cleanupFailures.synchronized(cleanupFailures.toList) + + def unique(prefix: String): String = + s"$prefix-${System.currentTimeMillis()}-${faker.number().numberBetween(1000L, 9999L)}" + + /** Register a tenant created through the UI (not via `createTenant`) for teardown. */ + def trackTenant(name: String): Unit = createdTenants += name + + /** Register an arbitrary best-effort cleanup action (e.g. delete a resource group). */ + def onCleanup(f: () => Unit): Unit = extraCleanups += f + + /** The real serving cluster - never a test-registered `cluster-*` peer. Plain `get(0)` is + * order-unstable once a geo-rep test registers a second cluster, which would drop the serving + * cluster from a tenant's allowed set and break namespace creation. */ + def firstCluster: String = + val all = admin.clusters().getClusters.asScala + all.find(!_.startsWith("cluster-")).getOrElse(all.head) + + /** Create an isolated tenant (registered for teardown) and return its name. `extraClusters` widens + * the tenant's allowed-clusters beyond the local one - needed to exercise geo-replication, whose + * replication set must be a subset of the tenant's allowed clusters. */ + def createTenant(extraClusters: String*): String = + val tenant = unique("t") + admin.tenants().createTenant( + tenant, + TenantInfo.builder() + .adminRoles(Set("admin").asJava) + .allowedClusters((firstCluster +: extraClusters).toSet.asJava) + .build() + ) + createdTenants += tenant + tenant + + /** Register a second cluster so multi-cluster UI paths (geo-replication) can be driven. The + * service URL is deliberately UNROUTABLE (`.invalid` TLD, RFC 2606): pointing it at the live + * broker's own admin URL would make any topic produced into a geo-replicated namespace spin up a + * real replicator against an alias of the same broker (loop or ever-growing cursor backlog). + * With an inert URL a replicator merely fails to connect; tests should still avoid producing into + * geo-replicated namespaces. Deleted in teardown AFTER namespaces that reference it. */ + def createCluster(): String = + val name = unique("cluster") + admin.clusters().createCluster(name, ClusterData.builder().serviceUrl("http://geo-peer.invalid:8080").build()) + createdClusters += name + name + + /** Create a resource group (registered for teardown) so the Compute-Resources policy tab has one to pick. */ + def createResourceGroup(): String = + val name = unique("rg") + admin.resourcegroups().createResourceGroup(name, new ResourceGroup()) + createdResourceGroups += name + name + + def createNamespace(tenant: String): String = + val ns = unique("ns") + admin.namespaces().createNamespace(s"$tenant/$ns") + ns + + /** Create a non-partitioned persistent topic and return its FQN. */ + def createTopic(tenant: String, namespace: String): String = + val topic = unique("topic") + val fqn = s"persistent://$tenant/$namespace/$topic" + admin.topics().createNonPartitionedTopic(fqn) + fqn + + /** Convenience: fresh tenant → namespace → topic, returns the topic FQN. */ + def freshTopic(): String = + val t = createTenant() + val ns = createNamespace(t) + createTopic(t, ns) + + /** Fresh tenant → namespace → non-partitioned topic; returns (tenant, namespace, shortTopic). */ + def freshTopicParts(): (String, String, String) = + val t = createTenant() + val ns = createNamespace(t) + val topic = unique("topic") + admin.topics().createNonPartitionedTopic(s"persistent://$t/$ns/$topic") + (t, ns, topic) + + /** Produce `n` simple string messages to a topic FQN. */ + def produceStrings(topicFqn: String, n: Int): Unit = + val producer = client.newProducer(Schema.STRING).topic(topicFqn).create() + try (1 to n).foreach(i => producer.send(s"msg-$i")) + finally producer.close() + + /** Best-effort teardown of everything this fixture created. Runs after each test. + * Failures are logged (not thrown - teardown must not fail a test) so leaks aren't silent. */ + def cleanup(): Unit = + def warn(what: String, t: Throwable): Unit = + val msg = s"$what failed: ${t.getMessage}" + System.err.println(s"[cleanup] $msg") + cleanupFailures.synchronized(cleanupFailures += msg) + extraCleanups.reverse.foreach(f => try f() catch case t: Throwable => warn("onCleanup action", t)) + extraCleanups.clear() + // A namespace whose replication references a cluster we're about to delete can't be deleted; drop + // its replication back to the local cluster first. Only geo-rep tests register extra clusters. + val resetReplication = createdClusters.nonEmpty + createdTenants.foreach: tenant => + try + admin.namespaces().getNamespaces(tenant).asScala.foreach: ns => + try + if resetReplication then + try admin.namespaces().setNamespaceReplicationClusters(ns, Set(firstCluster).asJava) + catch case _: Throwable => () // best-effort - the ns may have no replication set + admin.namespaces().deleteNamespace(ns, true) + catch case t: Throwable => warn(s"deleteNamespace($ns)", t) + admin.tenants().deleteTenant(tenant) + catch case t: Throwable => warn(s"deleteTenant($tenant)", t) + createdTenants.clear() + createdResourceGroups.foreach: rg => + try admin.resourcegroups().deleteResourceGroup(rg) catch case t: Throwable => warn(s"deleteResourceGroup($rg)", t) + createdResourceGroups.clear() + // Clusters last - after every namespace that referenced them is gone. + createdClusters.foreach: c => + try admin.clusters().deleteCluster(c) catch case t: Throwable => warn(s"deleteCluster($c)", t) + createdClusters.clear() + + /** Close clients at suite end. */ + def close(): Unit = + try admin.close() + finally clientOpt.foreach(_.close()) + +object PulsarFixtures: + private val staleClustersSwept = new java.util.concurrent.atomic.AtomicBoolean(false) + private val StaleClusterName = """cluster-(\d+)-\d+""".r + + /** Once per JVM: best-effort delete of `cluster--` peers older than an hour, left by + * crashed runs whose cleanup never ran. Tests are immune to them (`firstCluster` filters the + * prefix) but the shared broker's cluster list - and the geo-replication UI dropdown - would + * otherwise grow forever. In-use clusters (still referenced by a leaked tenant) fail to delete + * and are silently skipped; the concurrently-running run that owns a fresh one is protected by + * the one-hour age gate. */ + private def sweepStaleClusters(admin: PulsarAdmin): Unit = + if staleClustersSwept.compareAndSet(false, true) then + try + val cutoff = System.currentTimeMillis() - 60 * 60 * 1000 + admin.clusters().getClusters.asScala.foreach { + case name @ StaleClusterName(millis) if millis.toLong < cutoff => + try admin.clusters().deleteCluster(name) catch case _: Throwable => () + case _ => () + } + catch case _: Throwable => () // the sweep must never fail a run diff --git a/e2e/src/main/scala/harness/PwRuntime.scala b/e2e/src/main/scala/harness/PwRuntime.scala new file mode 100644 index 000000000..4c1973e1a --- /dev/null +++ b/e2e/src/main/scala/harness/PwRuntime.scala @@ -0,0 +1,21 @@ +package harness + +import com.microsoft.playwright.{Browser, BrowserType, Playwright} + +/** One Playwright + Browser shared across the whole run (initialized on first use, closed on JVM exit). + * Each test gets its own BrowserContext (see DekafSuite), so isolation is per-test while the expensive + * browser launch happens once. */ +object PwRuntime: + private val playwright: Playwright = Playwright.create() + + private val browserInstance: Browser = + val opts = BrowserType.LaunchOptions().setHeadless(!Config.headed) + if Config.slowMoMs > 0 then opts.setSlowMo(Config.slowMoMs) + playwright.chromium().launch(opts) + + Runtime.getRuntime.addShutdownHook(new Thread(() => + try browserInstance.close() + finally playwright.close() + )) + + def browser: Browser = browserInstance diff --git a/e2e/src/main/scala/test_env/TestEnv.scala b/e2e/src/main/scala/test_env/TestEnv.scala deleted file mode 100644 index f93e05b20..000000000 --- a/e2e/src/main/scala/test_env/TestEnv.scala +++ /dev/null @@ -1,39 +0,0 @@ -package test_env - -import com.microsoft.playwright.* -import com.microsoft.playwright.Browser.NewPageOptions -import com.microsoft.playwright.options.ViewportSize -import org.apache.pulsar.client.admin.PulsarAdmin - -case class PulsarCluster( - brokersCount: Int, - bookiesCount: Int, - brokerConf: String -) - -case class TextEnvConfig(pulsarClusters: List[PulsarCluster]) - -case class TestEnv(config: TextEnvConfig): - private val publicBaseUrl = "http://localhost:8090" - private val isDebug = true - private val playwright: Playwright = Playwright.create - private val browser: Browser = playwright.chromium.launch(new BrowserType.LaunchOptions().setHeadless(!isDebug)) - - def createPulsarAdminClient(): PulsarAdmin = - PulsarAdmin.builder().serviceHttpUrl("http://localhost:8080").build() - - def createNewPage(): Page = - val newPageOptions = new NewPageOptions() - .setViewportSize(new ViewportSize(1280, 800)) - .setBaseURL(publicBaseUrl) - browser.newPage(newPageOptions) - -def createPulsarStandaloneEnv = TestEnv(TextEnvConfig( - pulsarClusters = List( - PulsarCluster( - brokersCount = 1, - bookiesCount = 1, - brokerConf = "" - ) - ) -)) diff --git a/e2e/src/main/scala/ui/ConfirmationDialog.scala b/e2e/src/main/scala/ui/ConfirmationDialog.scala new file mode 100644 index 000000000..76a6cd003 --- /dev/null +++ b/e2e/src/main/scala/ui/ConfirmationDialog.scala @@ -0,0 +1,17 @@ +package ui + +import com.microsoft.playwright.Page +import com.microsoft.playwright.Locator + +/** The shared destructive-action dialog (delete topic/namespace/tenant/subscription/schema, + * unload, expire, skip, reset-cursor, revoke, …). Already instrumented in the app. */ +final case class ConfirmationDialog(page: Page): + val guardInput: Locator = page.getByTestId("confirmation-dialog-guard-input") + val confirmButton: Locator = page.getByTestId("confirmation-dialog-confirm-button") + val forceDeleteCheckbox: Locator = page.getByTestId("confirm-dialog-force-delete-checkbox") + + /** Type the guard (when the dialog requires it), optionally tick force-delete, then confirm. */ + def confirm(guard: Option[String] = None, force: Boolean = false): Unit = + if force then forceDeleteCheckbox.click() + guard.foreach(guardInput.fill) + confirmButton.click() diff --git a/e2e/src/main/scala/ui/SubscriptionOverviewPage.scala b/e2e/src/main/scala/ui/SubscriptionOverviewPage.scala new file mode 100644 index 000000000..5f1ef1394 --- /dev/null +++ b/e2e/src/main/scala/ui/SubscriptionOverviewPage.scala @@ -0,0 +1,14 @@ +package ui + +import com.microsoft.playwright.{Locator, Page} + +/** Locators for the subscription Overview action buttons + their modal controls. + * Buttons come from ActionButton (which emits `data-testid`); the modals wrap + * their content in the shared ui.ConfirmationDialog (guard = "CONFIRM"). */ +final case class SubscriptionOverviewPage(page: Page): + val expireButton: Locator = page.getByTestId("expire-subscription-messages-button") + val skipButton: Locator = page.getByTestId("skip-subscription-messages-button") + val resetButton: Locator = page.getByTestId("reset-subscription-cursor-button") + + def open(tenant: String, namespace: String, topic: String, sub: String): Unit = + page.navigate(s"/tenants/$tenant/namespaces/$namespace/topics/persistent/$topic/subscriptions/$sub/overview") diff --git a/e2e/src/test/scala/features/consumersession/ConsumerSessionConfigSpec.scala b/e2e/src/test/scala/features/consumersession/ConsumerSessionConfigSpec.scala new file mode 100644 index 000000000..2ee1c70c3 --- /dev/null +++ b/e2e/src/test/scala/features/consumersession/ConsumerSessionConfigSpec.scala @@ -0,0 +1,29 @@ +package features.consumersession + +import harness.DekafSuite +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions +import scala.jdk.CollectionConverters.* + +class ConsumerSessionConfigSpec extends DekafSuite: + private def count(n: Int) = new LocatorAssertions.HasCountOptions().setTimeout(20000) + + test("CS-1: Start-From offers all 7 options") { + val (t, ns, topic) = fixtures.freshTopicParts() + ConsumerSessionPage(page).openForTopic(t, ns, topic) + val opts = page.getByTestId("cs-start-from").locator("option").allTextContents().asScala.toList + assert(opts.size == 7, s"got: $opts") + assert(opts.contains("Earliest message") && opts.contains("Latest message") && opts.contains("Message with specific ID")) + } + + test("CS-17: Stop clears the loaded messages") { + val (t, ns, topic) = fixtures.freshTopicParts() + fixtures.produceStrings(s"persistent://$t/$ns/$topic", 3) + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + cs.setStartFrom("Earliest message") + cs.play() + assertThat(cs.messages).hasCount(3, count(3)) + cs.stop() + assertThat(cs.messages).hasCount(0, count(0)) + } diff --git a/e2e/src/test/scala/features/consumersession/ConsumerSessionSpec.scala b/e2e/src/test/scala/features/consumersession/ConsumerSessionSpec.scala new file mode 100644 index 000000000..4135e4b83 --- /dev/null +++ b/e2e/src/test/scala/features/consumersession/ConsumerSessionSpec.scala @@ -0,0 +1,46 @@ +package features.consumersession + +import harness.DekafSuite +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions + +class ConsumerSessionSpec extends DekafSuite: + + /** Arrange a fresh topic pre-loaded with `n` messages; returns (tenant, namespace, shortTopic). */ + private def topicWithMessages(n: Int): (String, String, String) = + val tenant = fixtures.createTenant() + val ns = fixtures.createNamespace(tenant) + val topic = fixtures.unique("topic") + admin.topics().createNonPartitionedTopic(s"persistent://$tenant/$ns/$topic") + fixtures.produceStrings(s"persistent://$tenant/$ns/$topic", n) + (tenant, ns, topic) + + test("CS-16 + CS-23: run from earliest, stream messages, open message details") { + val (tenant, ns, topic) = topicWithMessages(5) + + val cs = ConsumerSessionPage(page) + cs.openForTopic(tenant, ns, topic) + cs.setStartFrom("Earliest message") + cs.play() + + // All 5 pre-produced messages stream in. + assertThat(cs.messages).hasCount(5, new LocatorAssertions.HasCountOptions().setTimeout(20000)) + + // Clicking a message opens the details panel. + cs.clickFirstMessage() + assertThat(cs.messageDetails).isVisible() + } + + test("CS-27: search-in-loaded filters by message value") { + val (tenant, ns, topic) = topicWithMessages(5) // values are "msg-1".."msg-5" + + val cs = ConsumerSessionPage(page) + cs.openForTopic(tenant, ns, topic) + cs.setStartFrom("Earliest message") + cs.play() + assertThat(cs.messages).hasCount(5, new LocatorAssertions.HasCountOptions().setTimeout(20000)) + + // "msg-3" matches exactly one message (search is a case-sensitive substring on key/value). + cs.searchInResults("msg-3") + assertThat(cs.messages).hasCount(1, new LocatorAssertions.HasCountOptions().setTimeout(5000)) + } diff --git a/e2e/src/test/scala/features/consumersession/CsConsoleSpec.scala b/e2e/src/test/scala/features/consumersession/CsConsoleSpec.scala new file mode 100644 index 000000000..f147b4d10 --- /dev/null +++ b/e2e/src/test/scala/features/consumersession/CsConsoleSpec.scala @@ -0,0 +1,63 @@ +package features.consumersession + +import harness.DekafSuite +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions + +class CsConsoleSpec extends DekafSuite: + + test("CS-30: Tools panel exposes Produce / REPL / Logs tabs (Produce present on a topic)") { + val (t, ns, topic) = fixtures.freshTopicParts() + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + cs.openTools() + + val tools = ToolsPanel(page) + assertThat(tools.produceTab).isVisible() + assertThat(tools.replTab).isVisible() + assertThat(tools.logsTab).isVisible() + + assertThat(tools.produceSend).isVisible() // Produce is the default active tab + tools.replTab.click(); assertThat(tools.replRun).isVisible() + tools.logsTab.click(); assertThat(page.getByText("logDebug")).isVisible() + } + + test("CS-31: REPL is disabled until a run, then executes and clears") { + val (t, ns, topic) = fixtures.freshTopicParts() + fixtures.produceStrings(s"persistent://$t/$ns/$topic", 1) + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + cs.openTools() + val tools = ToolsPanel(page) + tools.replTab.click() + assertThat(tools.replRun).isDisabled() // isConsumerCreated === false before running + + cs.setStartFrom("Earliest message") + cs.play() + cs.assertState("running") + assertThat(tools.replRun).isEnabled() + + // Click the Monaco editor surface (not just the wrapper) so the hidden textarea receives focus. + tools.replEditor.locator(".monaco-editor").click() + page.waitForTimeout(300) + page.keyboard().`type`("2 + 2") + page.waitForTimeout(300) + tools.replRun.click() + assertThat(tools.replLogs).containsText("4", new LocatorAssertions.ContainsTextOptions().setTimeout(15000)) + + tools.replClear.click() + assertThat(tools.replLogs).hasText("") + assertThat(tools.replClear).isDisabled() + } + + test("CS-32: Context Logs render (empty-state placeholder for logDebug output)") { + val (t, ns, topic) = fixtures.freshTopicParts() + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + cs.openTools() + val tools = ToolsPanel(page) + tools.logsTab.click() + assertThat(tools.logs).isVisible() + assertThat(page.getByText("logDebug")).isVisible() + // NOTE: no text/level filter control exists; generating real debugStdout needs a JS filter/projection (see NOTES). + } diff --git a/e2e/src/test/scala/features/consumersession/CsDetailsSpec.scala b/e2e/src/test/scala/features/consumersession/CsDetailsSpec.scala new file mode 100644 index 000000000..06794228e --- /dev/null +++ b/e2e/src/test/scala/features/consumersession/CsDetailsSpec.scala @@ -0,0 +1,61 @@ +package features.consumersession + +import harness.DekafSuite +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import java.util.regex.Pattern + +class CsDetailsSpec extends DekafSuite: + private def loadedPaused(n: Int): ConsumerSessionPage = + val (t, ns, topic) = fixtures.freshTopicParts() + fixtures.produceStrings(s"persistent://$t/$ns/$topic", n) + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + cs.setStartFrom("Earliest message") + cs.play() + cs.waitMessages(n) + cs.pauseFromToolbar() + cs.assertState("paused") + cs + + test("CS-24: MessageDetails closes only via its close button") { + val cs = loadedPaused(3) + cs.clickFirstMessage() + assertThat(cs.messageDetails).isVisible() + + // Clicking elsewhere keeps a single selection -> panel stays open. + cs.messages.nth(1).click() + assertThat(cs.messageDetails).isVisible() + cs.th("key").click() + assertThat(cs.messageDetails).isVisible() + + cs.messageDetailsClose.click() + assertThat(cs.messageDetails).isHidden() + } + + test("CS-25: click-to-copy a cell copies + toasts; the Topic cell is a link") { + context.grantPermissions(java.util.List.of("clipboard-read", "clipboard-write")) + val cs = loadedPaused(3) + + cs.cell("value").first().click() // Field onClick copies the displayed value (JSON-quoted for a STRING schema) + assertThat(page.getByText("copied to clipboard")).isVisible() + val copied = page.evaluate("() => navigator.clipboard.readText()").toString + assert(copied.contains("msg-1"), s"clipboard mismatch: $copied") + + // Topic renders as an with an href (FQN -> route link). + assertThat(cs.cell("topic").first()).hasAttribute("href", Pattern.compile(".+")) + } + + test("CS-26: ArrowUp/k and ArrowDown/j move the selection and wrap at ends") { + val cs = loadedPaused(3) + cs.clickFirstMessage() + assertThat(cs.selectedIndexCell).hasText("1") + + // Real keyboard path: focus the (now focusable) table, then press keys - a keyboard user must be + // able to reach it. This would have been impossible before the tabIndex fix. + cs.focusTable() + cs.pressKeyOnTable("ArrowDown"); assertThat(cs.selectedIndexCell).hasText("2") + cs.pressKeyOnTable("j"); assertThat(cs.selectedIndexCell).hasText("3") + cs.pressKeyOnTable("ArrowDown"); assertThat(cs.selectedIndexCell).hasText("1") // wrap + cs.pressKeyOnTable("ArrowUp"); assertThat(cs.selectedIndexCell).hasText("3") // wrap + cs.pressKeyOnTable("k"); assertThat(cs.selectedIndexCell).hasText("2") + } diff --git a/e2e/src/test/scala/features/consumersession/CsExportSpec.scala b/e2e/src/test/scala/features/consumersession/CsExportSpec.scala new file mode 100644 index 000000000..154232e0f --- /dev/null +++ b/e2e/src/test/scala/features/consumersession/CsExportSpec.scala @@ -0,0 +1,89 @@ +package features.consumersession + +import harness.DekafSuite +import com.microsoft.playwright.Download +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import org.apache.pulsar.client.api.Schema +import scala.jdk.CollectionConverters.* + +class CsExportSpec extends DekafSuite: + + /** Export runs JSON.parse(message.value) - values MUST be valid JSON, else the export throws. */ + private def produceJson(fqn: String, n: Int): Unit = + val p = client.newProducer(Schema.STRING).topic(fqn).create() + try (0 until n).foreach(i => p.send(s"""{"n":$i}""")) + finally p.close() + + private def loadedPaused(t: String, ns: String, topic: String, n: Int): ConsumerSessionPage = + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + cs.setStartFrom("Earliest message") + cs.play() + cs.waitMessages(n) + cs.pauseFromToolbar() + cs.assertState("paused") + cs + + test("CS-28: Export modal offers 4 formats and downloads a .zip containing the messages") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + produceJson(fqn, 5) + val cs = loadedPaused(t, ns, topic, 5) + + cs.exportOpen.click() + val modal = ExportModal(page) + assertThat(modal.root).isVisible() + assert(modal.formatOptionCount == 4, s"expected 4 formats, got ${modal.formatOptionCount}") + assertThat(modal.fieldRows.first()).isVisible() // field-config list present (reorder disabled - see NOTES) + + val download: Download = page.waitForDownload(() => modal.runButton.click()) + assert(download.suggestedFilename().endsWith(".zip"), download.suggestedFilename()) + + val zipPath = java.nio.file.Files.createTempFile("cs-export", ".zip") + download.saveAs(zipPath) + fixtures.onCleanup(() => java.nio.file.Files.deleteIfExists(zipPath)) + + val zip = new java.util.zip.ZipFile(zipPath.toFile) + try + val text = zip.entries().asScala + .filterNot(_.isDirectory) + .map(e => new String(zip.getInputStream(e).readAllBytes(), java.nio.charset.StandardCharsets.UTF_8)) + .mkString("\n") + assert(text.nonEmpty, "empty export") + // The default format exports the full message descriptor; the raw value is JSON-escaped inside + // a string field, so assert the (unescaped) per-message index 1..5 is present. + (1 to 5).foreach(i => + assert(text.contains(s"\"index\":$i"), s"missing message index $i in export")) + finally zip.close() + } + + test("CS-29: export config persists across reopen") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + fixtures.produceStrings(fqn, 3) // persistence path never exports -> plain values are fine + val cs = loadedPaused(t, ns, topic, 3) + + cs.exportOpen.click() + val modal = ExportModal(page) + modal.selectFormat("json-value-per-entry") + modal.close() + + cs.exportOpen.click() + assertThat(ExportModal(page).format).hasValue("json-value-per-entry") + } + + test("CS-29: a poisoned export config resets via the ErrorBoundary") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + fixtures.produceStrings(fqn, 3) + val cs = loadedPaused(t, ns, topic, 3) + + // Corrupt the stored config so _MessagesExporter throws on render (config.format.type on null). + page.evaluate( + """() => localStorage.setItem('messageExportConfig', + | JSON.stringify({ format: null, fields: { fields: [] }, filePerRawValueConfig: { fileExtension: '' } }))""".stripMargin) + + cs.exportOpen.click() + // The ErrorBoundary catches the poisoned config and degrades gracefully instead of crashing. + assertThat(page.getByText("Resetting to default")).isVisible() + } diff --git a/e2e/src/test/scala/features/consumersession/CsFiltersSpec.scala b/e2e/src/test/scala/features/consumersession/CsFiltersSpec.scala new file mode 100644 index 000000000..d3454fc82 --- /dev/null +++ b/e2e/src/test/scala/features/consumersession/CsFiltersSpec.scala @@ -0,0 +1,129 @@ +package features.consumersession + +import harness.DekafSuite +import harness.Eventually.eventually +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions +import org.apache.pulsar.client.api.Schema + +class CsFiltersSpec extends DekafSuite: + private def count(n: Int) = new LocatorAssertions.HasCountOptions().setTimeout(20000) + private def vis = new LocatorAssertions.IsVisibleOptions().setTimeout(20000) + private def hasText = new LocatorAssertions.HasTextOptions().setTimeout(10000) + private def hasV = new LocatorAssertions.HasValueOptions().setTimeout(10000) + + private def produce(fqn: String, values: Seq[String]): Unit = + val p = client.newProducer(Schema.STRING).topic(fqn).create() + try values.foreach(p.send) finally p.close() + + test("CS-8: add a filter, switch Basic<->JS, enable/negate, AND/OR group") { + val (t, ns, topic) = fixtures.freshTopicParts() + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + cs.revealAdvanced() + val f = cs.sessionFilterPanel + + f.addFilter() + assertThat(f.opSelect).isVisible(vis) // filter added -> op select present + + f.switchToJs() // Basic -> JavaScript + assertThat(f.jsEditor).isVisible(vis) + f.switchToBasic() // back to Basic + assertThat(f.opSelect).isVisible(vis) + + // enable toggle flips its state (OnOffToggle tooltip text is state-specific). + assertThat(f.enableToggle.first()).hasAttribute("data-tooltip-html", "Enabled") + f.enableToggle.first().click() + assertThat(f.enableToggle.first()).hasAttribute("data-tooltip-html", "Disabled") + f.enableToggle.first().click() + + f.negateToggle.first().click() // exercise negate (no throw) + + // AND/OR group appears only with >= 2 filters. + f.addFilter() + assertThat(f.logicToggle).hasText("AND", hasText) + f.logicToggle.click() + assertThat(f.logicToggle).hasText("OR", hasText) + } + + test("CS-9: choosing an operator reveals its value input and holds the entered value") { + val (t, ns, topic) = fixtures.freshTopicParts() + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + cs.revealAdvanced() + val f = cs.sessionFilterPanel + + f.addFilter() + f.setOp("string includes") + f.setValue("abc") + assertThat(f.valueInput.first()).hasValue("abc", hasV) + } + + // The messages are interleaved and the LAST one is a matching "keep-last" sentinel. Because a + // single-partition topic delivers in order, the sentinel appearing means every earlier message was + // already processed - so we can then assert the EXACT retained set. A no-op filter would also show + // the skips (fails the set); an inverted filter would drop the sentinel (times out) - neither can + // pass by reaching a transient count of 3, which the old `hasCount(3)` allowed. + private val filterInput = Seq("keep-1", "skip-1", "keep-2", "skip-2", "keep-last") + private val filterKept = Set("keep-1", "keep-2", "keep-last") + + private def assertFilteredToKept(cs: ConsumerSessionPage): Unit = + cs.setStartFrom("Earliest message") + cs.play() + val values = eventually() { + val vs = cs.columnValues("value") + assert(vs.contains("keep-last"), s"sentinel not loaded yet: $vs") + vs + } + assert(values.toSet == filterKept, s"filtered values were: $values (expected exactly $filterKept)") + assert(!values.exists(_.startsWith("skip")), s"a skip-* leaked through the filter: $values") + + // CS-10/CS-11 stream through the PER-TARGET filter chain (the primary, always-visible path a user + // drives); CS-10s covers the SESSION-level chain. Both now filter correctly. A session-level miss + // previously HALTED the stream at the first non-matching message - ConsumerSessionRunner emitted a + // zero-message response that the client reads as end-of-stream; fixed 2026-07-19 to emit a count-only + // placeholder like the per-target path. CS-10s is the regression guarding that fix. + test("CS-10 (P0): a BASIC filter actually filters the streamed rows") { + val (t, ns, topic) = fixtures.freshTopicParts() + produce(s"persistent://$t/$ns/$topic", filterInput) + + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + val f = cs.targetFilterPanel + f.addFilter() + f.setOp("string includes") + f.setValue("keep") + + assertFilteredToKept(cs) + } + + test("CS-11: a JS filter filters the streamed rows") { + val (t, ns, topic) = fixtures.freshTopicParts() + produce(s"persistent://$t/$ns/$topic", filterInput) + + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + val f = cs.targetFilterPanel + f.addFilter() + f.switchToJs() + f.writeJs("(v) => v.includes(\"keep\")") + + assertFilteredToKept(cs) + } + + // Regression for the session-level filter halt (ConsumerSessionRunner zero-message response): the + // SESSION chain must skip non-matching messages and keep streaming, not stop at the first miss. + test("CS-10s: a SESSION-level basic filter skips non-matches and keeps streaming") { + val (t, ns, topic) = fixtures.freshTopicParts() + produce(s"persistent://$t/$ns/$topic", filterInput) + + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + cs.revealAdvanced() + val f = cs.sessionFilterPanel + f.addFilter() + f.setOp("string includes") + f.setValue("keep") + + assertFilteredToKept(cs) + } diff --git a/e2e/src/test/scala/features/consumersession/CsLifecycleSpec.scala b/e2e/src/test/scala/features/consumersession/CsLifecycleSpec.scala new file mode 100644 index 000000000..57f9707aa --- /dev/null +++ b/e2e/src/test/scala/features/consumersession/CsLifecycleSpec.scala @@ -0,0 +1,62 @@ +package features.consumersession + +import harness.DekafSuite +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions + +class CsLifecycleSpec extends DekafSuite: + private def topicWith(n: Int): (String, String, String) = + val (t, ns, topic) = fixtures.freshTopicParts() + fixtures.produceStrings(s"persistent://$t/$ns/$topic", n) + (t, ns, topic) + + private def runningSession(n: Int): ConsumerSessionPage = + val (t, ns, topic) = topicWith(n) + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + cs.setStartFrom("Earliest message") + cs.play() + cs.waitMessages(n) + cs.assertState("running") + cs + + test("CS-18: wheel-scroll (up) on a running session transitions to paused") { + val cs = runningSession(5) + cs.wheelUpOverTable() + cs.assertState("paused") + } + + test("CS-18: clicking a row on a running session transitions to paused") { + val cs = runningSession(5) + cs.clickFirstMessage() + cs.assertState("paused") + } + + test("CS-18: window/visibility blur transitions a running session to paused") { + val cs = runningSession(5) + cs.blurWindow() + cs.assertState("paused") + } + + test("CS-19: running caps the displayed set at 250; pausing reveals all") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + fixtures.produceStrings(fqn, 300) // values "msg-1".."msg-300", all contain "msg-" + + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + cs.setStartFrom("Earliest message") + cs.play() + + // Ensure all 300 are loaded BEFORE pausing (otherwise "paused" could show < 300). + assertThat(cs.loaded).hasText("300", new LocatorAssertions.HasTextOptions().setTimeout(30000)) + cs.assertState("running") + + // A search matching every message surfaces the (non-virtualized) count. + cs.searchInResults("msg-") + assertThat(cs.numFound).hasText("250", new LocatorAssertions.HasTextOptions().setTimeout(10000)) // running slice = last 250 + + cs.pauseFromToolbar() + cs.assertState("paused") + assertThat(cs.numFound).hasText("300", new LocatorAssertions.HasTextOptions().setTimeout(10000)) // cap lifted + } diff --git a/e2e/src/test/scala/features/consumersession/CsProjectionColoringSpec.scala b/e2e/src/test/scala/features/consumersession/CsProjectionColoringSpec.scala new file mode 100644 index 000000000..fe04cba17 --- /dev/null +++ b/e2e/src/test/scala/features/consumersession/CsProjectionColoringSpec.scala @@ -0,0 +1,122 @@ +package features.consumersession + +import harness.DekafSuite +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions +import org.apache.pulsar.client.api.Schema + +class CsProjectionColoringSpec extends DekafSuite: + private def count(n: Int) = new LocatorAssertions.HasCountOptions().setTimeout(20000) + private def vis = new LocatorAssertions.IsVisibleOptions().setTimeout(20000) + private def contains = new LocatorAssertions.ContainsTextOptions().setTimeout(20000) + private def css = new LocatorAssertions.HasCSSOptions().setTimeout(20000) + + private def produce(fqn: String, values: Seq[String]): Unit = + val p = client.newProducer(Schema.STRING).topic(fqn).create() + try values.foreach(p.send) finally p.close() + + test("CS-12 (P0): a projection adds a column whose header equals the projection label") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + produce(fqn, (1 to 3).map(i => s"m-$i")) + + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + cs.revealAdvanced() + cs.addProjection() + cs.projectionLabel.first().fill("MyCol") + + cs.setStartFrom("Earliest message") + cs.play() + assertThat(cs.messages).hasCount(3, count(3)) + assertThat(cs.projectionColumnHeader).hasCount(1, count(1)) + assertThat(cs.projectionColumnHeader).containsText("MyCol", contains) + } + + test("CS-13: coloring modal picks a swatch, applies it, and a matching row is colored") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + produce(fqn, (1 to 3).map(i => s"c-$i")) + + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + cs.revealAdvanced() + cs.addColoringRule() // default rule = empty filter chain (matches all) + + cs.coloringBg.first().click() // open the "Pick a Color" modal + // 242 built-in swatches (+2 theme) carry data-tooltip-content. + assert(page.locator("[data-tooltip-content]").count() >= 242) + cs.pickSwatch("red-500") // select + auto-apply + close + assertThat(cs.coloringBg.first()).hasCSS("background-color", "rgb(239, 68, 68)", css) + + cs.setStartFrom("Earliest message") + cs.play() + assertThat(cs.messages).hasCount(3, count(3)) + // Row cells carry the rule background (server evaluates the empty chain as match-all). + assertThat(cs.messages.first()).hasCSS("background-color", "rgb(239, 68, 68)", css) + } + + test("CS-13b: a per-target coloring rule beats the session coloring rule") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + produce(fqn, (1 to 2).map(i => s"p-$i")) + + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + cs.revealAdvanced() + + // Session rule -> red. + cs.addColoringRule() + cs.coloringBg.first().click(); cs.pickSwatch("red-500") + + // Target rule -> green (cs-target-coloring container). + val targetColoring = cs.target(0).root.getByTestId("cs-target-coloring") + targetColoring.getByRole( + com.microsoft.playwright.options.AriaRole.BUTTON, + new com.microsoft.playwright.Locator.GetByRoleOptions().setName("Add Coloring Rule") + ).click() + targetColoring.getByTestId("cs-coloring-bg").first().click(); cs.pickSwatch("green-500") + + cs.setStartFrom("Earliest message") + cs.play() + assertThat(cs.messages).hasCount(2, count(2)) + assertThat(cs.messages.first()).hasCSS("background-color", "rgb(34, 197, 94)", css) // green wins + } + + test("CS-14: changing the deserializer changes how values decode") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + produce(fqn, Seq("\"apple\"")) // raw bytes are the JSON string "apple" (with quotes) + + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + cs.setStartFrom("Earliest message") + cs.play() + assertThat(cs.messages).hasCount(1, count(1)) + val schemaRendered = cs.firstValueCell.innerText() // topic STRING schema -> "apple" (quoted) + + cs.stop() // back to config view + cs.setDeserializer("Treat raw bytes as JSON") + cs.play() + assertThat(cs.messages).hasCount(1, count(1)) + val jsonRendered = cs.firstValueCell.innerText() // JSON parse -> apple (unquoted) + + assert(schemaRendered != jsonRendered, + s"deserializer change should alter value rendering: schema='$schemaRendered' json='$jsonRendered'") + } + + test("CS-15: the Advanced reveal is one-way (once revealed, stays)") { + val (t, ns, topic) = fixtures.freshTopicParts() + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + + assertThat(cs.advancedToggle).isVisible(vis) + assertThat(cs.sessionFilters).hasCount(0, count(0)) + + cs.revealAdvanced() + assertThat(cs.sessionFilters).isVisible(vis) + + cs.sessionFilterPanel.addFilter() // adds advanced config -> reveal locks on + assertThat(cs.advancedToggle).hasCount(0, count(0)) // toggle is gone + assertThat(cs.sessionFilters).isVisible(vis) // advanced section stays + } diff --git a/e2e/src/test/scala/features/consumersession/CsStartFromSpec.scala b/e2e/src/test/scala/features/consumersession/CsStartFromSpec.scala new file mode 100644 index 000000000..7a72baf21 --- /dev/null +++ b/e2e/src/test/scala/features/consumersession/CsStartFromSpec.scala @@ -0,0 +1,69 @@ +package features.consumersession + +import harness.DekafSuite +import harness.Eventually.eventually +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions +import org.apache.pulsar.client.api.Schema + +class CsStartFromSpec extends DekafSuite: + private def count(n: Int) = new LocatorAssertions.HasCountOptions().setTimeout(20000) + private def vis = new LocatorAssertions.IsVisibleOptions().setTimeout(20000) + private def hasV = new LocatorAssertions.HasValueOptions().setTimeout(10000) + + private def produce(fqn: String, values: Seq[String]): Unit = + val p = client.newProducer(Schema.STRING).topic(fqn).create() + try values.foreach(p.send) finally p.close() + + test("CS-2: switching Start-From resets the additional input to its default") { + val (t, ns, topic) = fixtures.freshTopicParts() + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + + cs.setStartFrom("Skip first n messages") + assertThat(cs.startFromN).hasValue("5", hasV) // default n = 5 + cs.startFromN.fill("20") + assertThat(cs.startFromN).hasValue("20", hasV) + + cs.setStartFrom("Latest message") // no additional input + assertThat(cs.startFromN).hasCount(0, count(0)) + + cs.setStartFrom("Skip first n messages") // reselect -> fresh default + assertThat(cs.startFromN).hasValue("5", hasV) + } + + test("CS-3: Earliest loads pre-existing set; Latest loads only messages produced after play") { + // --- Earliest: all 4 pre-produced load --- + val (tA, nsA, topicA) = fixtures.freshTopicParts() + val fqnA = s"persistent://$tA/$nsA/$topicA" + produce(fqnA, (1 to 4).map(i => s"a-$i")) + + val cs = ConsumerSessionPage(page) + cs.openForTopic(tA, nsA, topicA) + cs.setStartFrom("Earliest message") + cs.play() + assertThat(cs.messages).hasCount(4, count(4)) + + // --- Latest: pre-existing are NOT loaded; only post-play are --- + val (tB, nsB, topicB) = fixtures.freshTopicParts() + val fqnB = s"persistent://$tB/$nsB/$topicB" + produce(fqnB, (1 to 4).map(i => s"old-$i")) // produced BEFORE play + + cs.openForTopic(tB, nsB, topicB) + cs.setStartFrom("Latest message") + cs.play() + // Subscription is now at 'latest' - the empty state confirms nothing old was loaded. + assertThat(cs.awaitingText).isVisible(vis) + assertThat(cs.messages).hasCount(0, count(0)) + + produce(fqnB, Seq("new-1", "new-2", "new-last")) // produced AFTER play; last is a sentinel + // When the sentinel arrives every post-play message is in, so assert the EXACT loaded set: only + // the new ones, none of the old - a bare hasCount(3) could pass transiently even if old loaded too. + val values = eventually() { + val vs = cs.columnValues("value") + assert(vs.contains("new-last"), s"sentinel not loaded yet: $vs") + vs + } + assert(values.toSet == Set("new-1", "new-2", "new-last"), s"loaded values were: $values") + assert(!values.exists(_.startsWith("old")), s"an old (pre-play) message loaded despite Latest: $values") + } diff --git a/e2e/src/test/scala/features/consumersession/CsTableSpec.scala b/e2e/src/test/scala/features/consumersession/CsTableSpec.scala new file mode 100644 index 000000000..992c63c95 --- /dev/null +++ b/e2e/src/test/scala/features/consumersession/CsTableSpec.scala @@ -0,0 +1,113 @@ +package features.consumersession + +import harness.DekafSuite +import org.apache.pulsar.client.api.Schema + +class CsTableSpec extends DekafSuite: + private def loaded(n: Int): ConsumerSessionPage = + val (t, ns, topic) = fixtures.freshTopicParts() + fixtures.produceStrings(s"persistent://$t/$ns/$topic", n) + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + cs.setStartFrom("Earliest message") + cs.play() + cs.waitMessages(n) + cs + + /** Load a session over messages with the given distinctive keys, in the given (publish) order. */ + private def loadedKeyed(keys: Seq[String]): ConsumerSessionPage = + val (t, ns, topic) = fixtures.freshTopicParts() + val producer = client.newProducer(Schema.STRING).topic(s"persistent://$t/$ns/$topic").create() + try keys.foreach(k => producer.newMessage().key(k).value(s"v-$k").send()) + finally producer.close() + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + cs.setStartFrom("Earliest message") + cs.play() + cs.waitMessages(keys.size) + cs + + test("CS-20: the table has 17 columns and a header that stays fixed while the rows scroll") { + // Enough rows to make the message viewport scrollable. NOTE: the table is virtualized + // (react-virtuoso), so the DOM row count is ~viewport-sized - wait on the loaded counter, not rows. + val (t, ns, topic) = fixtures.freshTopicParts() + fixtures.produceStrings(s"persistent://$t/$ns/$topic", 60) + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + cs.setStartFrom("Earliest message") + cs.play() + cs.waitHeader() // the table is rendered (headerCount uses count(), which does not auto-wait) + cs.awaitLoaded(60) + assert(cs.headerCount == 17, s"expected 17 columns, got ${cs.headerCount}") + + // Vertical stickiness: the header keeps its on-screen y position WHILE the rows actually scroll. + // A running session auto-scrolls to the bottom, so scroll to the TOP first - otherwise a + // downward scroll from the bottom moves nothing and the header trivially stays put (false pass). + cs.pauseFromToolbar() // stop the stream so the scroll position is stable + cs.assertState("paused") + cs.scrollTableToTop() + val indexBefore = cs.firstRenderedIndex + val topBefore = cs.th("publishTime").boundingBox().y + cs.scrollTableDown(600) + val indexAfter = cs.firstRenderedIndex + val topAfter = cs.th("publishTime").boundingBox().y + assert(indexAfter > indexBefore, + s"the rows did not actually scroll ($indexBefore -> $indexAfter) - stickiness can't be proven") + assert(math.abs(topAfter - topBefore) <= 2, + s"header moved when the rows scrolled (not vertically sticky): $topBefore -> $topAfter") + } + + test("CS-21: clicking a sortable header re-orders the rows; a drag-resize does NOT sort") { + // Deliberately unsorted publish order, so an ascending sort has to actually move rows. + val cs = loadedKeyed(Seq("k-3", "k-1", "k-2")) + cs.assertSort("publishTime:asc") // default + assert(cs.columnValues("key") == List("k-3", "k-1", "k-2"), s"publish order was: ${cs.columnValues("key")}") + + cs.sortBy("key") + cs.assertSort("key:asc") + assert(cs.columnValues("key") == List("k-1", "k-2", "k-3"), s"asc order was: ${cs.columnValues("key")}") + + cs.sortBy("key") + cs.assertSort("key:desc") + assert(cs.columnValues("key") == List("k-3", "k-2", "k-1"), s"desc order was: ${cs.columnValues("key")}") + + // Dragging the "value" resize handle changes its width but must NOT re-sort (suppressSortClickRef). + cs.resizeColumn("value", 80) + cs.assertSort("key:desc") + assert(cs.columnValues("key") == List("k-3", "k-2", "k-1"), "a drag-resize re-ordered the rows") + assert(cs.storedColumnWidth("value") > 240, s"value width not increased: ${cs.storedColumnWidth("value")}") + } + + test("CS-22: a resized column width persists across reload and is re-applied to the DOM") { + val (t, ns, topic) = fixtures.freshTopicParts() + fixtures.produceStrings(s"persistent://$t/$ns/$topic", 3) + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + cs.setStartFrom("Earliest message") + cs.play() + cs.waitMessages(3) + + // Baseline the LIVE DOM width BEFORE the drag, then prove the drag actually widened the rendered + // column - otherwise an implementation that only writes localStorage (never applying it) would + // leave both the pre- and post-reload widths at the default and pass. + val domBaseline = cs.columnDomWidth("value") + cs.resizeColumn("value", 80) + val stored = cs.storedColumnWidth("value") + assert(stored > 240, s"width not persisted after resize: $stored") + val domAfterResize = cs.columnDomWidth("value") + assert(domAfterResize > domBaseline + 20, + s"resize was not applied to the live DOM: ${domBaseline}px -> ${domAfterResize}px") + + // Reload resets the session to its config view, so replay to render the table again. + page.reload() + cs.setStartFrom("Earliest message") + cs.play() + cs.waitMessages(3) + + // Storage kept it AND the restored column renders at the widened width, not the default. + assert(cs.storedColumnWidth("value") == stored, + s"stored width not stable across reload: $stored -> ${cs.storedColumnWidth("value")}") + val domAfterReload = cs.columnDomWidth("value") + assert(math.abs(domAfterReload - domAfterResize) <= 3, + s"restored column rendered at ${domAfterReload}px, expected ~${domAfterResize}px (persisted width not re-applied)") + } diff --git a/e2e/src/test/scala/features/consumersession/CsTargetsSpec.scala b/e2e/src/test/scala/features/consumersession/CsTargetsSpec.scala new file mode 100644 index 000000000..2afb628cd --- /dev/null +++ b/e2e/src/test/scala/features/consumersession/CsTargetsSpec.scala @@ -0,0 +1,103 @@ +package features.consumersession + +import harness.DekafSuite +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions +import scala.jdk.CollectionConverters.* + +class CsTargetsSpec extends DekafSuite: + private def count(n: Int) = new LocatorAssertions.HasCountOptions().setTimeout(20000) + private def hasText = new LocatorAssertions.HasTextOptions().setTimeout(20000) + private def vis = new LocatorAssertions.IsVisibleOptions().setTimeout(20000) + + test("CS-4: add / remove / move targets; boundary move buttons disabled at the ends") { + val (t, ns, topic) = fixtures.freshTopicParts() + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + + // One target by default; no move buttons with a single target. + assertThat(cs.targets).hasCount(1, count(1)) + assertThat(cs.target(0).moveLeft).hasCount(0, count(0)) + + cs.addTarget() + assertThat(cs.targets).hasCount(2, count(2)) + + // Boundary disabled states. + assertThat(cs.target(0).moveLeft).isDisabled() + assertThat(cs.target(1).moveRight).isDisabled() + assertThat(cs.target(0).moveRight).isEnabled() + + // Make target 0 distinguishable, then move it right and assert order swapped. + cs.target(0).setMode("Specific Topic(s)") + assertThat(cs.target(0).modeSelect).hasValue("multi-topic-selector") + assertThat(cs.target(1).modeSelect).hasValue("current-topic") + + cs.target(0).moveRight.click() + assertThat(cs.target(0).modeSelect).hasValue("current-topic") + assertThat(cs.target(1).modeSelect).hasValue("multi-topic-selector") + + // Remove one target -> back to a single target, move buttons gone. + cs.target(0).remove.click() + assertThat(cs.targets).hasCount(1, count(1)) + assertThat(cs.target(0).moveLeft).hasCount(0, count(0)) + } + + test("CS-5: target modes Current / Specific (FQN validate + duplicate reject) / RegExp") { + val (t, ns, topic) = fixtures.freshTopicParts() + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + val ts = cs.target(0) + + ts.setMode("Specific Topic(s)") + val good = "persistent://a/b/c" + ts.typeFqn(good) + assertThat(ts.addFqnButton).isEnabled() + ts.addFqnButton.click() + assertThat(cs.target(0).root.getByText(good)).isVisible(vis) + + // Duplicate is rejected: error shown, Add disabled. + ts.typeFqn(good) + assertThat(page.getByText("The topic is already in the list.")).isVisible(vis) + assertThat(ts.addFqnButton).isDisabled() + + // Bad format is rejected. + ts.fqnInput.fill("not-a-fqn") + assertThat(page.getByText("Expecting the following topic name format")).isVisible(vis) + assertThat(ts.addFqnButton).isDisabled() + + // RegExp mode reveals the pattern input. + ts.setMode("Namespaced RegExp") + assertThat(ts.regexPattern).isVisible(vis) + + ts.setMode("Current Topic") + assertThat(ts.modeSelect).hasValue("current-topic") + } + + test("CS-6: resolve-info matched-topic count equals the admin oracle") { + val t = fixtures.createTenant() + val ns = fixtures.createNamespace(t) + val fqn1 = fixtures.createTopic(t, ns) + val fqn2 = fixtures.createTopic(t, ns) + val topic1 = fqn1.substring(fqn1.lastIndexOf('/') + 1) + + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic1) + val ts = cs.target(0) + ts.setMode("Specific Topic(s)") + ts.addFqn(fqn1) + ts.addFqn(fqn2) + + val expected = 2 // exactly the two real topics we added + assertThat(ts.resolveCount).hasText(expected.toString, hasText) + } + + test("CS-7: a Current-Topic target shows 'not applicable' when opened off a non-topic route") { + val t = fixtures.createTenant() + val ns = fixtures.createNamespace(t) + + val cs = ConsumerSessionPage(page) + cs.openForNamespace(t, ns) // no current topic in this context + val ts = cs.target(0) + ts.setMode("Current Topic") + assertThat(ts.notApplicable).isVisible(vis) + } diff --git a/e2e/src/test/scala/features/library/LibraryBrowseSpec.scala b/e2e/src/test/scala/features/library/LibraryBrowseSpec.scala new file mode 100644 index 000000000..b40c86435 --- /dev/null +++ b/e2e/src/test/scala/features/library/LibraryBrowseSpec.scala @@ -0,0 +1,116 @@ +package features.library + +import harness.DekafSuite +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions +import com.microsoft.playwright.options.SelectOption + +class LibraryBrowseSpec extends DekafSuite: + + private def overviewUrl(t: String, ns: String, topic: String): String = + s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/overview" + + private def openTopicOverview(): (String, String, String) = + val t = fixtures.createTenant() + val ns = fixtures.createNamespace(t) + val topic = fixtures.unique("topic") + admin.topics().createNonPartitionedTopic(s"persistent://$t/$ns/$topic") + page.navigate(overviewUrl(t, ns, topic)) + (t, ns, topic) + + test("LIB-3: Library sub-tab selection survives outer-tab switches") { + openTopicOverview() + val lib = LibrarySidebar(page) + lib.openLibraryTab() + assertThat(lib.newConsumerSessionButton).isVisible() // consumer-sessions is the default sub-tab + lib.openAllItemsSubtab() + assertThat(lib.newConsumerSessionButton).hasCount(0) // consumer-sessions content unmounted + lib.openNotesTab() + lib.openLibraryTab() // leave and return to the Library outer tab + assertThat(lib.newConsumerSessionButton).hasCount(0) // All Items sub-tab was preserved + assertThat(lib.typeRow("message-filter")).isVisible() + } + + test("LIB-4: All Items lists the 13 managed types, NoData on a fresh context") { + openTopicOverview() + val lib = LibrarySidebar(page) + lib.openLibraryTab() + lib.openAllItemsSubtab() + assertThat(page.locator("[data-testid^='lib-type-row-']")).hasCount(13) + assertThat(lib.typeRow("consumer-session-config")).isVisible() + assertThat(lib.typeRow("deserializer")).isVisible() + lib.awaitTypeLoaded("message-filter") // wait for a real loaded count, not undefined→0 + assertThat(lib.typeFound("message-filter")).hasCount(0) // fresh scope -> no "N found", NoData shown + } + + test("LIB-5: the library re-fetches when navigating to a different scope") { + val (t, ns, topic) = openTopicOverview() + val lib = LibrarySidebar(page) + lib.openLibraryTab() + lib.createItemNamed("message-filter", "scoped-here") + page.navigate(overviewUrl(t, ns, topic)) + lib.openLibraryTab(); lib.openAllItemsSubtab() + assertThat(lib.typeFound("message-filter")).containsText("1") // present in this topic scope + openTopicOverview() // a different fresh topic + lib.openLibraryTab(); lib.openAllItemsSubtab() + assertThat(lib.typeFound("message-filter")).hasCount(0) // re-fetched, out of scope + } + + test("LIB-19: Browse filters results client-side by name") { + val (t, ns, topic) = openTopicOverview() + val lib = LibrarySidebar(page) + lib.openLibraryTab() + lib.createItemNamed("message-filter", "alpha-filter") + lib.createItemNamed("message-filter", "beta-filter") + page.navigate(overviewUrl(t, ns, topic)); lib.openLibraryTab() + val browser = lib.browseType("message-filter") + assertThat(browser.results).hasCount(2, new LocatorAssertions.HasCountOptions().setTimeout(10000)) + browser.filter("alpha") + assertThat(browser.result("alpha-filter")).hasCount(1) + assertThat(browser.result("beta-filter")).hasCount(0) + } + + test("LIB-20: Browse sorts results by name ascending/descending") { + val (t, ns, topic) = openTopicOverview() + val lib = LibrarySidebar(page) + lib.openLibraryTab() + lib.createItemNamed("message-filter", "aaa-first") + lib.createItemNamed("message-filter", "zzz-last") + page.navigate(overviewUrl(t, ns, topic)); lib.openLibraryTab() + val browser = lib.browseType("message-filter") + browser.sortBy("Name-asc") + assertThat(browser.results.first()).containsText("aaa-first") + browser.sortBy("Name-desc") + assertThat(browser.results.first()).containsText("zzz-last") + } + + // LIB-6 - cross-category cascade. Faithful to resourceMatchers.scala (strict same-category .test), + // NOT the plan's "all topics in N" wording: a NamespaceMatcher never matches a TopicMatcher target, + // so the observable reveal is giving the search a NAMESPACE-category context. + test("LIB-6: a namespace-scoped item is hidden on a child topic until a namespace search context is added") { + val t = fixtures.createTenant() + val ns = fixtures.createNamespace(t) + val topic = fixtures.unique("topic") + admin.topics().createNonPartitionedTopic(s"persistent://$t/$ns/$topic") + + // Arrange: create a message-filter scoped to the NAMESPACE (derive-from-context on the namespace overview). + page.navigate(s"/tenants/$t/namespaces/$ns/overview") + val libNs = LibrarySidebar(page) + libNs.openLibraryTab() + libNs.createItemNamed("message-filter", "ns-scoped") + + // On a CHILD topic the namespace-scoped item is NOT listed by default (topic context != namespace context). + page.navigate(s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/overview") + val lib = LibrarySidebar(page) + lib.openLibraryTab(); lib.openAllItemsSubtab() + assertThat(lib.typeFound("message-filter")).hasCount(0) + + // Add a search context and switch its category to Namespace(s) -> derives this topic's namespace. + lib.addSearchContext() + assertThat(lib.matcherCategorySelects).hasCount(2) + lib.matcherCategorySelects.last().selectOption(new SelectOption().setValue("namespace-matcher")) + assertThat(lib.matcherNamespaceInputs.last()).hasValue(ns) + + // Revealed: the namespace-scoped item now matches the search. + assertThat(lib.typeFound("message-filter")).containsText("1") + } diff --git a/e2e/src/test/scala/features/library/LibraryCrudSpec.scala b/e2e/src/test/scala/features/library/LibraryCrudSpec.scala new file mode 100644 index 000000000..28a709240 --- /dev/null +++ b/e2e/src/test/scala/features/library/LibraryCrudSpec.scala @@ -0,0 +1,112 @@ +package features.library + +import harness.DekafSuite +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions + +class LibraryCrudSpec extends DekafSuite: + + private def overviewUrl(t: String, ns: String, topic: String): String = + s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/overview" + + private def openTopicOverview(): (String, String, String) = + val t = fixtures.createTenant() + val ns = fixtures.createNamespace(t) + val topic = fixtures.unique("topic") + admin.topics().createNonPartitionedTopic(s"persistent://$t/$ns/$topic") + page.navigate(overviewUrl(t, ns, topic)) + (t, ns, topic) + + test("LIB-8: create a consumer-session-config via the Save dialog persists (P0)") { + val (t, ns, topic) = openTopicOverview() + val lib = LibrarySidebar(page) + lib.openLibraryTab() + lib.createItemNamed("consumer-session-config", "my-saved-session") + // reload -> the LibraryService re-fetch reports it under this topic scope + page.navigate(overviewUrl(t, ns, topic)) + lib.openLibraryTab(); lib.openAllItemsSubtab() + assertThat(lib.typeFound("consumer-session-config")).containsText("1") + } + + test("LIB-12: deleting a library item removes it (P0)") { + val (t, ns, topic) = openTopicOverview() + val lib = LibrarySidebar(page) + lib.openLibraryTab() + lib.createItemNamed("message-filter", "to-delete") + page.navigate(overviewUrl(t, ns, topic)); lib.openLibraryTab() + val browser = lib.browseType("message-filter") + assertThat(browser.result("to-delete")).hasCount(1, new LocatorAssertions.HasCountOptions().setTimeout(10000)) + browser.deleteItem("to-delete") + assertThat(browser.result("to-delete")).hasCount(0) + // reload -> gone from the source of truth (no "N found" for the type) + page.navigate(overviewUrl(t, ns, topic)) + lib.openLibraryTab(); lib.openAllItemsSubtab() + assertThat(lib.typeFound("message-filter")).hasCount(0) + } + + test("LIB-13: creating with an empty name is intercepted (nothing is created)") { + val (t, ns, topic) = openTopicOverview() + val lib = LibrarySidebar(page) + lib.openLibraryTab(); lib.openAllItemsSubtab() + lib.typeRow("message-filter").getByTestId("lib-create-item").click() + val dlg = LibrarySaveDialog(page) + dlg.saveButton.click() // "Create" with the default empty name + assertThat(dlg.nameInput).isVisible() // name is intercepted by the Set-Name dialog + assertThat(dlg.nameConfirm).isDisabled() // Confirm blocked while the name is empty (the intercept) + // Reload to abandon the stacked dialogs and confirm nothing was persisted. + page.navigate(overviewUrl(t, ns, topic)) + lib.openLibraryTab(); lib.openAllItemsSubtab() + assertThat(lib.typeFound("message-filter")).hasCount(0) // never created + } + + test("LIB-9: editing renames a library item") { + val (t, ns, topic) = openTopicOverview() + val lib = LibrarySidebar(page) + lib.openLibraryTab() + lib.createItemNamed("message-filter", "old-name") + page.navigate(overviewUrl(t, ns, topic)); lib.openLibraryTab() + val browser = lib.browseType("message-filter") + browser.editItem("old-name") // opens the Save dialog for the existing item + val dlg = LibrarySaveDialog(page) + page.getByTestId("lib-item-rename").click() // in-editor rename -> Set-Name dialog + dlg.nameInput.fill("new-name") + dlg.nameConfirm.click() + dlg.saveButton.click() // "Save" + page.navigate(overviewUrl(t, ns, topic)); lib.openLibraryTab() + val browser2 = lib.browseType("message-filter") + assertThat(browser2.result("new-name")).hasCount(1, new LocatorAssertions.HasCountOptions().setTimeout(10000)) + assertThat(browser2.result("old-name")).hasCount(0) + } + + test("LIB-10: Save as New clones the item under a new id/name") { + val (t, ns, topic) = openTopicOverview() + val lib = LibrarySidebar(page) + lib.openLibraryTab() + lib.createItemNamed("message-filter", "original") + page.navigate(overviewUrl(t, ns, topic)); lib.openLibraryTab() + lib.browseType("message-filter").editItem("original") + LibrarySaveDialog(page).saveAsNew("cloned") + page.navigate(overviewUrl(t, ns, topic)); lib.openLibraryTab() + val browser = lib.browseType("message-filter") + assertThat(browser.result("original")).hasCount(1, new LocatorAssertions.HasCountOptions().setTimeout(10000)) + assertThat(browser.result("cloned")).hasCount(1) + } + + test("LIB-11: Overwrite Another saves the current item under a target id") { + val (t, ns, topic) = openTopicOverview() + val lib = LibrarySidebar(page) + lib.openLibraryTab() + lib.createItemNamed("message-filter", "source") + lib.createItemNamed("message-filter", "target") + page.navigate(overviewUrl(t, ns, topic)); lib.openLibraryTab() + lib.browseType("message-filter").editItem("source") + LibrarySaveDialog(page).overwriteAnotherButton.click() + val ow = LibraryOverwriteDialog(page) + ow.select("target") // choose the item to overwrite + ow.overwriteButton.click() + // "source"'s content is now stored under target's id -> two items carry the "source" name, "target" is gone + page.navigate(overviewUrl(t, ns, topic)); lib.openLibraryTab() + val browser = lib.browseType("message-filter") + assertThat(browser.result("source")).hasCount(2, new LocatorAssertions.HasCountOptions().setTimeout(10000)) + assertThat(browser.result("target")).hasCount(0) + } diff --git a/e2e/src/test/scala/features/library/LibraryNotesSpec.scala b/e2e/src/test/scala/features/library/LibraryNotesSpec.scala new file mode 100644 index 000000000..c2229fe52 --- /dev/null +++ b/e2e/src/test/scala/features/library/LibraryNotesSpec.scala @@ -0,0 +1,63 @@ +package features.library + +import harness.DekafSuite +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions +import ui.ConfirmationDialog + +class LibraryNotesSpec extends DekafSuite: + + private def overviewUrl(t: String, ns: String, topic: String): String = + s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/overview" + + private def openTopicOverview(): (String, String, String) = + val t = fixtures.createTenant() + val ns = fixtures.createNamespace(t) + val topic = fixtures.unique("topic") + admin.topics().createNonPartitionedTopic(s"persistent://$t/$ns/$topic") + page.navigate(overviewUrl(t, ns, topic)) + (t, ns, topic) + + private def ct = new LocatorAssertions.HasCountOptions().setTimeout(10000) + + test("LIB-14: Create First Note yields a persistent 'Note 1'") { + val (t, ns, topic) = openTopicOverview() + val lib = LibrarySidebar(page) + lib.openNotesTab() + lib.createNote() + assertThat(lib.noteTab("Note 1")).hasCount(1, ct) + page.navigate(overviewUrl(t, ns, topic)) + lib.openNotesTab() + assertThat(lib.noteTab("Note 1")).hasCount(1, ct) + } + + test("LIB-15: renaming then deleting a note returns to the empty state") { + openTopicOverview() + val lib = LibrarySidebar(page) + lib.openNotesTab() + lib.createNote() + lib.noteTab("Note 1").getByTestId("lib-note-rename").click() + page.getByTestId("lib-name-input").fill("Renamed Note") + page.getByTestId("lib-name-confirm").click() + assertThat(lib.noteTab("Renamed Note")).hasCount(1, ct) + lib.noteTab("Renamed Note").getByTestId("lib-note-delete").click() + ConfirmationDialog(page).confirmButton.click() + assertThat(lib.createFirstNoteButton).isVisible() // back to empty state + } + + // NOTE: instance-scope leg is NOT parallel-safe and leaks one instance note (no LibraryService teardown). + // Run in a serial lane. See packet NOTES. + test("LIB-16: the '⭐️ Updates' pseudo-note appears only on the Instance scope") { + // Topic scope: a real note, but no Updates pseudo-note. + openTopicOverview() + val libT = LibrarySidebar(page) + libT.openNotesTab() + libT.createNote() + assertThat(libT.noteTab("⭐️ Updates")).hasCount(0) + // Instance scope: once >=1 note exists, the Updates pseudo-note is concatenated in. + page.navigate("/overview") + val libI = LibrarySidebar(page) + libI.openNotesTab() + libI.createNote() + assertThat(libI.noteTab("⭐️ Updates")).hasCount(1, ct) + } diff --git a/e2e/src/test/scala/features/library/LibraryScopeSpec.scala b/e2e/src/test/scala/features/library/LibraryScopeSpec.scala new file mode 100644 index 000000000..fa8c6e6a0 --- /dev/null +++ b/e2e/src/test/scala/features/library/LibraryScopeSpec.scala @@ -0,0 +1,19 @@ +package features.library + +import harness.DekafSuite +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions +import org.apache.pulsar.client.api.MessageId + +class LibraryScopeSpec extends DekafSuite: + + test("LIB-1: Library sidebar is present on a topic overview, absent on a subscription overview") { + val (t, ns, topic) = fixtures.freshTopicParts() + page.navigate(s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/overview") + assertThat(page.getByTestId("lib-tab-library")).isVisible() + + val sub = fixtures.unique("sub") + admin.topics().createSubscription(s"persistent://$t/$ns/$topic", sub, MessageId.earliest) + page.navigate(s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/subscriptions/$sub/overview") + assertThat(page.getByTestId("lib-tab-library")).hasCount(0) + } diff --git a/e2e/src/test/scala/features/library/LibrarySelectSpec.scala b/e2e/src/test/scala/features/library/LibrarySelectSpec.scala new file mode 100644 index 000000000..d9b9b191c --- /dev/null +++ b/e2e/src/test/scala/features/library/LibrarySelectSpec.scala @@ -0,0 +1,59 @@ +package features.library + +import harness.DekafSuite +import features.consumersession.ConsumerSessionPage +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions +import java.util.regex.Pattern + +class LibrarySelectSpec extends DekafSuite: + + private def overviewUrl(t: String, ns: String, topic: String): String = + s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/overview" + + private def openTopicOverview(): (String, String, String) = + val t = fixtures.createTenant() + val ns = fixtures.createNamespace(t) + val topic = fixtures.unique("topic") + admin.topics().createNonPartitionedTopic(s"persistent://$t/$ns/$topic") + page.navigate(overviewUrl(t, ns, topic)) + (t, ns, topic) + + test("LIB-18: picking a saved session navigates with a managedConsumerSessionId and loads it (P0)") { + val (t, ns, topic) = openTopicOverview() + val lib = LibrarySidebar(page) + lib.openLibraryTab() + lib.createItemNamed("consumer-session-config", "picked-session") + // fresh navigation so the Consumer Sessions sub-tab list re-fetches + page.navigate(overviewUrl(t, ns, topic)) + lib.openLibraryTab() + lib.openConsumerSessionsSubtab() + val browser = LibraryBrowser(page) + browser.result("picked-session").click() // select the saved session + browser.selectButton.click() // "Select" + assertThat(page).hasURL(Pattern.compile(".*/consumer-session\\?id=.+")) + assertThat(ConsumerSessionPage(page).playButton).isVisible() // the session config loaded + } + + // LIB-21 - reference -> value. Loading a session by `?id=` mounts the top-level config as + // { type: 'reference', ref: id } (TopicPage.tsx:296-299), which renders the reference icon in + // SessionConfiguration's LibraryBrowserPanel; clicking it converts the reference to a value. + test("LIB-21: a referenced session config can be converted to a value") { + val (t, ns, topic) = openTopicOverview() + val lib = LibrarySidebar(page) + lib.openLibraryTab() + lib.createItemNamed("consumer-session-config", "ref-session") + // Pick it -> navigates to /consumer-session?id= -> the config is loaded by reference. + page.navigate(overviewUrl(t, ns, topic)) + lib.openLibraryTab() + lib.openConsumerSessionsSubtab() + val browser = LibraryBrowser(page) + browser.result("ref-session").click() + browser.selectButton.click() + // The top-level config is a reference -> exactly one reference icon is shown once it resolves. + val referenceIcon = page.getByTestId("lib-reference-icon") + assertThat(referenceIcon).hasCount(1, new LocatorAssertions.HasCountOptions().setTimeout(15000)) + // Convert reference -> value; the icon disappears (managedItemReference becomes undefined). + referenceIcon.click() + assertThat(referenceIcon).hasCount(0) + } diff --git a/e2e/src/test/scala/features/library/LibrarySpec.scala b/e2e/src/test/scala/features/library/LibrarySpec.scala new file mode 100644 index 000000000..da7dd0050 --- /dev/null +++ b/e2e/src/test/scala/features/library/LibrarySpec.scala @@ -0,0 +1,49 @@ +package features.library + +import harness.DekafSuite +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions +import java.util.regex.Pattern + +class LibrarySpec extends DekafSuite: + + /** Fresh topic, navigate to its overview (where the Library sidebar mounts); returns (tenant, ns, topic). */ + private def openTopicOverview(): (String, String, String) = + val tenant = fixtures.createTenant() + val ns = fixtures.createNamespace(tenant) + val topic = fixtures.unique("topic") + admin.topics().createNonPartitionedTopic(s"persistent://$tenant/$ns/$topic") + page.navigate(s"/tenants/$tenant/namespaces/$ns/topics/persistent/$topic/overview") + (tenant, ns, topic) + + test("LIB-17: 'New Consumer Session' navigates to the topic's consumer-session route") { + openTopicOverview() + val lib = LibrarySidebar(page) + lib.openLibraryTab() + lib.newConsumerSessionButton.click() + assertThat(page).hasURL(Pattern.compile(".*/consumer-session.*")) + } + + test("LIB-2: Library and Notes tabs switch content") { + openTopicOverview() + val lib = LibrarySidebar(page) + lib.openNotesTab() + assertThat(lib.createFirstNoteButton).isVisible() // Notes content (fresh topic → 0 notes) + lib.openLibraryTab() + assertThat(lib.newConsumerSessionButton).isVisible() // Library content + } + + test("LIB-7: creating a note persists across reload") { + val (tenant, ns, topic) = openTopicOverview() + val overviewUrl = s"/tenants/$tenant/namespaces/$ns/topics/persistent/$topic/overview" + + val lib = LibrarySidebar(page) + lib.openNotesTab() + lib.createNote() + assertThat(lib.noteTabs).hasCount(1, new LocatorAssertions.HasCountOptions().setTimeout(10000)) + + // Reload the page - the note is server-persisted, scoped to this topic's context. + page.navigate(overviewUrl) + lib.openNotesTab() + assertThat(lib.noteTabs).hasCount(1, new LocatorAssertions.HasCountOptions().setTimeout(10000)) + } diff --git a/e2e/src/test/scala/features/producer/ProducerBytesSpec.scala b/e2e/src/test/scala/features/producer/ProducerBytesSpec.scala new file mode 100644 index 000000000..4bd7306b5 --- /dev/null +++ b/e2e/src/test/scala/features/producer/ProducerBytesSpec.scala @@ -0,0 +1,84 @@ +package features.producer + +import harness.DekafSuite +import features.consumersession.ConsumerSessionPage +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.Page.GetByRoleOptions +import com.microsoft.playwright.options.{AriaRole, SelectOption} +import org.apache.pulsar.client.api.{Schema, SubscriptionInitialPosition} +import java.util.concurrent.TimeUnit +import scala.jdk.CollectionConverters.* + +class ProducerBytesSpec extends DekafSuite: + + /** First-run onboarding modal; dismiss if present (see ConsumerSessionPage.play). */ + private def dismissCredentialsIfPresent(): Unit = + page.waitForTimeout(500) + if page.getByText("Pulsar Credentials").isVisible then + page.getByRole(AriaRole.BUTTON, new GetByRoleOptions().setName("Done").setExact(true)).click() + + private def publisherCount(fqn: String): Int = + admin.topics().getStats(fqn).getPublishers.size + + private def awaitPublishers(fqn: String, pred: Int => Boolean, timeoutMs: Long = 15000): Int = + val deadline = System.currentTimeMillis() + timeoutMs + var c = publisherCount(fqn) + while !pred(c) && System.currentTimeMillis() < deadline do + Thread.sleep(200); c = publisherCount(fqn) + c + + test("PRD-2: producing in Bytes (hex) mode yields a consumable message") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + cs.openTools() // reveal the (already-mounted) Produce console + dismissCredentialsIfPresent() + + page.getByTestId("produce-encoding").selectOption(new SelectOption().setLabel("Bytes (hex)")) + page.getByTestId("produce-key").fill("k-bytes") + val editor = page.getByTestId("produce-value").locator(".monaco-editor").first() + editor.click() + page.keyboard().`type`("6162") // hex for ASCII "ab" + page.getByTestId("produce-send").click() + assertThat(page.getByText("Message successfully sent")).isVisible() + + // Client cross-check: the raw bytes are consumable and match exactly. + val consumer = client.newConsumer(Schema.BYTES) + .topic(fqn) + .subscriptionName(fixtures.unique("verify")) + .subscriptionInitialPosition(SubscriptionInitialPosition.Earliest) + .subscribe() + try + val msg = consumer.receive(10, TimeUnit.SECONDS) + assert(msg != null, "expected to consume the produced bytes message") + assert(new String(msg.getValue) == "ab") // bytes 0x61 0x62 + assert(msg.getKey == "k-bytes") + finally consumer.close() + } + + test("PRD-4: the produce console's producer is route-scoped - present on the route, gone on leave") { + // The Producer mounts with the consumer-session route (isRenderAlways=true), NOT on openTools, so + // the honest claim is a route-scoped lifecycle: a `__dekaf_*` producer exists while the route is + // mounted and is torn down (React cleanup -> deleteProducer) when we navigate away. + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) // mounts ConsumerSession -> Producer -> createProducer + dismissCredentialsIfPresent() + + // Present-on-route (created at mount, before any Tools interaction). + assert(awaitPublishers(fqn, _ >= 1) >= 1) + assert( + admin.topics().getStats(fqn).getPublishers.asScala + .exists(p => Option(p.getProducerName).exists(_.startsWith("__dekaf_"))) + ) + + // Gone-on-leave: an in-app nav away unmounts the console (React cleanup -> deleteProducer). + // Dispatch the click directly on the (react-router handles it) - the tools tooltip can overlay + // the toolbar, so a coordinate-based click may land on the tooltip instead of the link. + page.getByRole(AriaRole.LINK, new GetByRoleOptions().setName("Overview").setExact(true)).dispatchEvent("click") + assert(awaitPublishers(fqn, _ == 0) == 0) + } diff --git a/e2e/src/test/scala/features/producer/ProducerSpec.scala b/e2e/src/test/scala/features/producer/ProducerSpec.scala new file mode 100644 index 000000000..eb140a003 --- /dev/null +++ b/e2e/src/test/scala/features/producer/ProducerSpec.scala @@ -0,0 +1,48 @@ +package features.producer + +import harness.DekafSuite +import features.consumersession.ConsumerSessionPage +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.Page.GetByRoleOptions +import com.microsoft.playwright.options.{AriaRole, SelectOption} +import org.apache.pulsar.client.api.{Schema, SubscriptionInitialPosition} +import java.util.concurrent.TimeUnit + +class ProducerSpec extends DekafSuite: + + test("PRD-1: producing a JSON message publishes it to the broker") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + cs.openTools() // open the console (force-click; its tooltip can overlay the button) + + // A first Pulsar op may raise the credentials onboarding modal - dismiss it. + page.waitForTimeout(500) + if page.getByText("Pulsar Credentials").isVisible then + page.getByRole(AriaRole.BUTTON, new GetByRoleOptions().setName("Done").setExact(true)).click() + + // JSON encoding (the default); 4242 is valid JSON (a number) and free of Monaco brace/quote auto-close. + page.getByTestId("produce-encoding").selectOption(new SelectOption().setValue("json")) + page.getByTestId("produce-key").fill("k1") + val editor = page.getByTestId("produce-value").locator(".monaco-editor").first() + editor.click() + page.keyboard().`type`("4242") + page.getByTestId("produce-send").click() + assertThat(page.getByText("Message successfully sent")).isVisible() + + // Broker oracle: the message is actually published - consume it and assert value + key + // (JSON mode sends the raw UTF-8 string bytes after JSON.parse validation - lib.ts:20). + val consumer = client.newConsumer(Schema.BYTES) + .topic(fqn) + .subscriptionName(fixtures.unique("verify")) + .subscriptionInitialPosition(SubscriptionInitialPosition.Earliest) + .subscribe() + try + val msg = consumer.receive(10, TimeUnit.SECONDS) + assert(msg != null, "expected the produced JSON message on the broker") + assert(new String(msg.getValue) == "4242", s"value was: ${new String(msg.getValue)}") + assert(msg.getKey == "k1", s"key was: ${msg.getKey}") + finally consumer.close() + } diff --git a/e2e/src/test/scala/features/producer/ProducerValidationSpec.scala b/e2e/src/test/scala/features/producer/ProducerValidationSpec.scala new file mode 100644 index 000000000..eb163c5a2 --- /dev/null +++ b/e2e/src/test/scala/features/producer/ProducerValidationSpec.scala @@ -0,0 +1,47 @@ +package features.producer + +import harness.DekafSuite +import features.consumersession.ConsumerSessionPage +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions +import com.microsoft.playwright.Page.GetByRoleOptions +import com.microsoft.playwright.options.AriaRole +import org.apache.pulsar.client.api.{Schema, SubscriptionInitialPosition} +import java.util.concurrent.TimeUnit +import java.util.regex.Pattern + +class ProducerValidationSpec extends DekafSuite: + + test("PRD-3: invalid JSON is rejected with a specific error and nothing is published") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + cs.openTools() + page.waitForTimeout(500) + if page.getByText("Pulsar Credentials").isVisible then + page.getByRole(AriaRole.BUTTON, new GetByRoleOptions().setName("Done").setExact(true)).click() + + // JSON encoding is the default; a bare word is invalid JSON (and free of brace auto-closing). + val editor = page.getByTestId("produce-value").locator(".monaco-editor").first() + editor.click() + page.keyboard().`type`("notjson") + page.getByTestId("produce-send").click() + + // The specific validation error is shown (Producer.tsx:84) and the success toast never appears. + // (Use the CASE_INSENSITIVE flag, not an inline (?i) - the browser JS regex engine rejects (?i).) + assertThat(page.getByText(Pattern.compile("Unable to send message", Pattern.CASE_INSENSITIVE))) + .isVisible(new LocatorAssertions.IsVisibleOptions().setTimeout(10000)) + assertThat(page.getByText("Message successfully sent")).hasCount(0) + + // Broker oracle: nothing was published - a short consume must time out empty. + val consumer = client.newConsumer(Schema.BYTES) + .topic(fqn) + .subscriptionName(fixtures.unique("verify")) + .subscriptionInitialPosition(SubscriptionInitialPosition.Earliest) + .subscribe() + try + val msg = consumer.receive(3, TimeUnit.SECONDS) + assert(msg == null, s"expected no published message, but consumed: ${if msg != null then new String(msg.getValue) else ""}") + finally consumer.close() + } diff --git a/e2e/src/test/scala/harness/DekafSuite.scala b/e2e/src/test/scala/harness/DekafSuite.scala new file mode 100644 index 000000000..6b385f84b --- /dev/null +++ b/e2e/src/test/scala/harness/DekafSuite.scala @@ -0,0 +1,111 @@ +package harness + +import com.microsoft.playwright.{BrowserContext, Page, Tracing} +import com.microsoft.playwright.Browser.NewContextOptions +import org.scalatest.{BeforeAndAfterAll, Outcome, Tag} +import org.scalatest.funsuite.AnyFunSuite + +import java.nio.file.Files + +/** Tag for deliberately-failing bug regressions (see e2e/README.md §6): while an app bug is open its + * test asserts the CORRECT behavior, carries this tag, and stays red outside the green lane; once + * the bug is fixed the test is untagged and joins the green lane. Run only tagged: `-- -n KnownBug` + * (currently empty - all catalogued bugs were fixed 2026-07-19); exclude: `-- -l KnownBug`. */ +object KnownBug extends Tag("KnownBug") + +/** Base for every UI spec. + * - fresh `BrowserContext` + `Page` per test (clean cookies + localStorage), + * - a Playwright trace recorded per test (kept on failure, or always when `TRACE=on`), + * - a `PulsarFixtures` (admin oracle + producer) with per-test teardown. + * Tests use `page` to drive the UI and `admin` / `client` to arrange + assert server state. */ +trait DekafSuite extends AnyFunSuite with BeforeAndAfterAll: + protected val fixtures: PulsarFixtures = new PulsarFixtures + protected def admin = fixtures.admin + protected def client = fixtures.client + + protected var context: BrowserContext = scala.compiletime.uninitialized + protected var page: Page = scala.compiletime.uninitialized + + override def withFixture(test: NoArgTest): Outcome = + context = PwRuntime.browser.newContext( + NewContextOptions() + .setBaseURL(Config.baseUrl) + .setViewportSize(1280, 800) + .setPermissions(java.util.List.of("clipboard-read", "clipboard-write")) + ) + // Everything after context creation runs inside the try: a throw from tracing().start or + // newPage() must still tear the context down, or each such failure leaks a live (possibly + // tracing) context and accelerates the shared browser's decline. + page = null + var tracingStarted = false + var outcome: Outcome = null + try + if Config.trace != Config.TraceMode.Off then + context.tracing().start( + new Tracing.StartOptions().setScreenshots(true).setSnapshots(true).setSources(true) + ) + tracingStarted = true + page = context.newPage() + outcome = super.withFixture(test) + outcome + finally + val keep = Config.trace match + case Config.TraceMode.On => true + case Config.TraceMode.Off => false + case Config.TraceMode.RetainOnFailure => outcome == null || !outcome.isSucceeded + // Every step attempts regardless of an earlier failure, so one broken step can't leak the rest. + // Ordering is deliberate: delete the broker resources while the page is STILL OPEN, so a live + // Consumer Session observes the deletion and the server tears its consumer down promptly. + // (Closing the browser first was tried and reverted: the server-side sessions linger, and after + // a handful of tests page loads start timing out.) + def attempt(label: String)(f: => Unit): Unit = + try f catch case t: Throwable => System.err.println(s"[teardown:$label] ${t.getMessage}") + attempt("tracing")(if tracingStarted then stopTracing(test, outcome, keep)) + attempt("fixtures.cleanup")(fixtures.cleanup()) + attempt("page.close")(if page != null then page.close()) + attempt("context.close")(context.close()) + + /** PASS / FAIL / BUG (a KnownBug that failed as designed) / SKIP - stamped into the trace filename + * so the dashboard can colorize without re-deriving the outcome from the trace. */ + private def statusOf(test: NoArgTest, outcome: Outcome): String = + if outcome == null then "FAIL" // aborted before an outcome + else if outcome.isSucceeded then "PASS" + else if outcome.isCanceled || outcome.isPending then "SKIP" + else if test.tags.contains("KnownBug") then "BUG" + else "FAIL" + + private def stopTracing(test: NoArgTest, outcome: Outcome, keep: Boolean): Unit = + if Config.trace != Config.TraceMode.Off then + val base = s"${suiteName}__${test.name}".replaceAll("[^A-Za-z0-9._-]", "_") + // One trace per test: the dir mirrors the LATEST run. All possible prior names for this test - + // the four status prefixes plus the pre-status legacy `.zip` - are pruned, EXCEPT the one + // just written. Pruning runs only after `stop` succeeds (or after a deliberate no-write), so a + // crash inside `stop` can no longer destroy the previous trace along with the new one. + def pruneOthers(justWritten: Option[String]): Unit = + if Files.isDirectory(Config.tracesDir) then + val candidates = Seq("PASS", "FAIL", "BUG", "SKIP").map(st => s"${st}__$base.zip") :+ s"$base.zip" + candidates.filterNot(justWritten.contains) + .foreach(n => Files.deleteIfExists(Config.tracesDir.resolve(n))) + if keep then + Files.createDirectories(Config.tracesDir) + val name = s"${statusOf(test, outcome)}__$base.zip" + context.tracing().stop(new Tracing.StopOptions().setPath(Config.tracesDir.resolve(name))) + pruneOthers(Some(name)) + else + // Deliberate: in retain-on-failure mode a now-passing test removes its stale FAIL trace - + // the dir reflects current reality; re-record with TRACE=on if you want passing traces too. + context.tracing().stop() + pruneOthers(None) + + override def afterAll(): Unit = + // Surface any per-test cleanup failures here, at suite granularity: a leak must not silently + // pass (it can contaminate later tests), but attributing it to the individual owning test would + // wrongly fail an otherwise-passing test - so the SUITE goes red with the aggregate instead. + val leaks = fixtures.cleanupFailuresSnapshot + try + fixtures.close() + if leaks.nonEmpty then + throw new RuntimeException( + s"${leaks.size} resource-cleanup failure(s) in ${suiteName} (possible leak):\n - ${leaks.mkString("\n - ")}" + ) + finally super.afterAll() diff --git a/e2e/src/test/scala/knownbugs/KnownBugSpec.scala b/e2e/src/test/scala/knownbugs/KnownBugSpec.scala new file mode 100644 index 000000000..6c9523ec2 --- /dev/null +++ b/e2e/src/test/scala/knownbugs/KnownBugSpec.scala @@ -0,0 +1,52 @@ +package knownbugs + +import harness.DekafSuite +import harness.Eventually.eventually +import features.consumersession.ConsumerSessionPage +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions +import com.microsoft.playwright.options.{AriaRole, SelectOption} +import com.microsoft.playwright.Page.GetByRoleOptions +import scala.jdk.CollectionConverters.* + +/** Regression tests for formerly-known bugs (all fixed 2026-07-19 - see e2e/README.md §6). They keep + * their BUG-N ids for traceability and now run in the ordinary green lane. */ +class KnownBugSpec extends DekafSuite: + + test("BUG-6: Start-From option reads 'Skip first n messages'") { + // FIXED: StartFromInput.tsx - label typo "fist" corrected. + val (t, ns, topic) = fixtures.freshTopicParts() + ConsumerSessionPage(page).openForTopic(t, ns, topic) + val select = page.getByTestId("cs-start-from") + assertThat(select).isVisible(new LocatorAssertions.IsVisibleOptions().setTimeout(15000)) + // The options render asynchronously after the select mounts - poll instead of a one-shot read. + eventually() { + val options = select.locator("option").allTextContents().asScala.toList + assert(options.contains("Skip first n messages"), s"start-from options were: $options") + } + } + + test("BUG-4: play/pause tooltip reflects state (running ⇒ 'Pause')") { + // FIXED: ConsumerSession Toolbar.tsx - the title was `state ? "Start or Resume" : "Pause"`, + // always truthy; now Pause/Resume/Start by actual state. + val (t, ns, topic) = fixtures.freshTopicParts() + fixtures.produceStrings(s"persistent://$t/$ns/$topic", 3) + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + cs.setStartFrom("Earliest message") + cs.play() + assertThat(cs.messages).hasCount(3, new LocatorAssertions.HasCountOptions().setTimeout(20000)) + val tooltip = cs.playButton.getAttribute("data-tooltip-html") + assert(tooltip == "Pause", s"while running the tooltip was: '$tooltip'") + } + + test("BUG-3: Create Subscription blocks Create for an empty 'Message with specific ID'") { + // FIXED: CreateSubscription.tsx - isFormValid compared the id VALUE against the literal + // "messageId" (never gating); now an invalid/empty id disables Create. + val (t, ns, topic) = fixtures.freshTopicParts() + page.navigate(s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/create-subscription") + page.getByTestId("create-subscription-name").fill(fixtures.unique("sub")) + page.getByTestId("create-subscription-cursor").selectOption(new SelectOption().setLabel("Message with specific ID")) + val createButton = page.getByRole(AriaRole.BUTTON, new GetByRoleOptions().setName("Create").setExact(true)) + assertThat(createButton).isDisabled(new LocatorAssertions.IsDisabledOptions().setTimeout(3000)) + } diff --git a/e2e/src/test/scala/knownbugs/MoreKnownBugsSpec.scala b/e2e/src/test/scala/knownbugs/MoreKnownBugsSpec.scala new file mode 100644 index 000000000..245adc82a --- /dev/null +++ b/e2e/src/test/scala/knownbugs/MoreKnownBugsSpec.scala @@ -0,0 +1,197 @@ +package knownbugs + +import harness.DekafSuite +import features.consumersession.ConsumerSessionPage +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions +import com.microsoft.playwright.options.AriaRole +import com.microsoft.playwright.Page.GetByRoleOptions +import org.apache.pulsar.common.policies.data.AuthAction +import org.apache.pulsar.client.api.MessageId +import java.util.EnumSet +import java.util.regex.Pattern +import scala.jdk.CollectionConverters.* + +/** Regression tests for formerly-known bugs (all fixed 2026-07-19 - see e2e/README.md §6). They keep + * their BUG-N ids for traceability and now run in the ordinary green lane. */ +class MoreKnownBugsSpec extends DekafSuite: + private def vis(ms: Int) = new LocatorAssertions.IsVisibleOptions().setTimeout(ms.toDouble) + + test("BUG-14: no stray console.log('overlay', …) is emitted on render") { + // FIXED: HealthCheckContext.tsx - the render-path console.log was removed. + val msgs = java.util.Collections.synchronizedList(new java.util.ArrayList[String]()) + page.onConsoleMessage(m => msgs.add(m.text())) + page.navigate("/overview") + page.waitForTimeout(2500) + val overlayLogs = msgs.asScala.filter(_.toLowerCase.contains("overlay")).toList + assert(overlayLogs.isEmpty, s"stray console logs present: $overlayLogs") + } + + test("BUG-15: Export is disabled while paused with 0 messages") { + // FIXED: ExportMessagesButton.tsx - disabled is now simply `messages.length === 0`. + val (t, ns, topic) = fixtures.freshTopicParts() + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + cs.setStartFrom("Latest message") + cs.play() + cs.assertState("running") + cs.pauseFromToolbar() + cs.assertState("paused") + assertThat(cs.exportOpen).isDisabled() + } + + test("BUG-5: the 'Message Id' column is sortable (asc is the reverse of desc)") { + // FIXED: ConsumerSession.tsx - messageId (and orderingKey) got sortKey + a byte-order comparator. + // Assert the comparator actually ORDERS: sort asc, then desc, and require the row order to be + // exactly reversed. A no-op comparator (returns 0) would leave asc == desc and fail. + val (t, ns, topic) = fixtures.freshTopicParts() + // Distinct keys so we can read a stable per-row identity; messageIds are monotonic per message. + val producer = client.newProducer(org.apache.pulsar.client.api.Schema.STRING) + .topic(s"persistent://$t/$ns/$topic").create() + try Seq("k-a", "k-b", "k-c").foreach(k => producer.newMessage().key(k).value(s"v-$k").send()) + finally producer.close() + + val cs = ConsumerSessionPage(page) + cs.openForTopic(t, ns, topic) + cs.setStartFrom("Earliest message") + cs.play() + cs.waitMessages(3) + + cs.sortBy("messageId"); cs.assertSort("messageId:asc") + val asc = cs.columnValues("key") + cs.sortBy("messageId"); cs.assertSort("messageId:desc") + val desc = cs.columnValues("key") + assert(asc.toSet == Set("k-a", "k-b", "k-c"), s"unexpected keys: $asc") + assert(desc == asc.reverse, s"messageId desc is not the reverse of asc (comparator is a no-op?): asc=$asc desc=$desc") + } + + test("BUG-10: the permission revoke button is row-unique") { + // FIXED: Permissions.tsx - update/revoke testIds are suffixed with the row's role. + val tenant = fixtures.createTenant() + val ns = fixtures.createNamespace(tenant) + val fqn = s"$tenant/$ns" + val role = fixtures.unique("role") + admin.namespaces().grantPermissionOnNamespace(fqn, role, EnumSet.of(AuthAction.produce)) + page.navigate(s"/tenants/$tenant/namespaces/$ns/details?category=access-control") + assertThat(page.getByText(role).first()).isVisible(vis(15000)) + assertThat(page.getByTestId(s"permission-revoke-button-$role")).hasCount(1) + } + + test("BUG-1: a transport failure on a create form shows an error and does not silently 'succeed'") { + // FIXED: the discarded-catch pattern (CreateNamespace / CreateTenantPage / CreateSubscription / + // ResourceGroupForm - RG DeleteDialog shares it) now notifies AND stays on the form. Asserting + // the error NOTIFICATION is what distinguishes the fix from a silent early return, which would + // also stay on the page and create nothing - i.e. pass the old (notification-less) assertion. + def createButton = page.getByRole(AriaRole.BUTTON, new GetByRoleOptions().setName("Create").setExact(true)) + + // (label, setup that lands on the form + fills required inputs, RPC to abort, URL fragment to stay on) + val tenant = fixtures.createTenant() + val (subT, subNs, subTopic) = fixtures.freshTopicParts() + val cases: Seq[(String, () => Unit, String, String)] = Seq( + ("Create Namespace", + () => { page.navigate(s"/tenants/$tenant/create-namespace"); page.getByTestId("create-namespace-name").fill(fixtures.unique("ns")) }, + ".*NamespaceService/CreateNamespace", "/create-namespace"), + ("Create Tenant", + () => { page.navigate("/instance/create-tenant"); page.getByTestId("tenant-name").fill(fixtures.unique("t")) }, + ".*TenantService/CreateTenant", "/create-tenant"), + ("Create Subscription", + () => { page.navigate(s"/tenants/$subT/namespaces/$subNs/topics/persistent/$subTopic/create-subscription"); page.getByTestId("create-subscription-name").fill(fixtures.unique("sub")) }, + ".*TopicService/CreateSubscription", "/create-subscription"), + ("Create Resource Group", + () => { page.navigate("/instance/resource-groups/create"); page.getByTestId("resource-group-name").fill(fixtures.unique("rg")) }, + ".*BrokersService/CreateResourceGroup", "/resource-groups/create"), + ) + + for (label, setup, abortRpc, stayFragment) <- cases do + setup() + val pattern = Pattern.compile(abortRpc) + page.route(pattern, r => r.abort()) + createButton.click() + // An error notification must appear (the whole point of the fix). + assertThat(page.getByText(Pattern.compile("Unable to (create|delete)", Pattern.CASE_INSENSITIVE)).first()) + .isVisible(vis(10000)) + // …and we must NOT have navigated to the never-created resource. + assert(page.url().contains(stayFragment), s"[$label] navigated away despite the failed create: ${page.url()}") + page.unroute(pattern) + } + + test("auto-refresh is ONE global preference shared by all tables (BUG-11 reclassified: by design)") { + // Owner decision 2026-07-19: auto-refresh is deliberately app-global - "we either want to + // refresh any table, or not". (BUG-11 originally called the global flag a bug; reclassified.) + // This pins the intended semantics: flip the toggle on one table -> every table follows. + fixtures.createTenant() + val (t, ns, topic) = fixtures.freshTopicParts() + + // Topics table: remember the shared state. + page.navigate(s"/tenants/$t/namespaces/$ns/topics") + val topicsToggle = page.getByTestId("table-auto-refresh") + assertThat(topicsToggle).isVisible(vis(15000)) + val before = topicsToggle.getAttribute("data-checked") + + // Tenants table starts in the SAME shared state; flip it there. + page.navigate("/tenants") + val tenantsToggle = page.getByTestId("table-auto-refresh") + assertThat(tenantsToggle).isVisible(vis(15000)) + assertThat(tenantsToggle).hasAttribute("data-checked", before) + tenantsToggle.click() + assertThat(tenantsToggle).not().hasAttribute("data-checked", before) + val flipped = tenantsToggle.getAttribute("data-checked") + + // The topics table followed: one global preference, not a per-table one. + page.navigate(s"/tenants/$t/namespaces/$ns/topics") + assertThat(page.getByTestId("table-auto-refresh")).hasAttribute("data-checked", flipped) + } + + test("BUG-12: the tree loading text reads 'Navigating to the selected resource'") { + // FIXED: NavigationTree.tsx - missing 't' typo corrected. The indicator is a sub-second + // transient during tree navigation (a visibility wait races it), so assert the SERVED BUNDLE: + // deterministic, and exactly what a copy regression needs. + page.navigate("/overview") + val bundle = page.request().get(s"${harness.Config.baseUrl}/ui/static/dist/entrypoint.js").text() + assert(bundle.contains("Navigating to the selected resource"), "corrected loading text missing from the served bundle") + assert(!bundle.contains("Navigating o the selected resource"), "the 'Navigating o the' typo is back in the served bundle") + } + + test("BUG-13: the Subscription page highlights the active toolbar tab") { + // FIXED: SubscriptionPage.tsx - Overview/Consumers buttons now carry `active` like TopicPage. + val (t, ns, topic) = fixtures.freshTopicParts() + val sub = fixtures.unique("sub") + admin.topics().createSubscription(s"persistent://$t/$ns/$topic", sub, MessageId.earliest) + page.navigate(s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/subscriptions/$sub/overview") + val overviewTab = page.locator("[data-testid=toolbar-button][data-tab='Overview']") + assertThat(overviewTab).isVisible(vis(15000)) + assertThat(overviewTab).hasAttribute("data-active", "true") + } + + test("BUG-18: pasting a topic FQN into the tree filter does not duplicate the topic segment") { + // FIXED: NavigationTree.tsx - partition parsing now uses a fresh non-global regex match; the + // no-match String.replace passthrough that rewrote every FQN as t/ns/topic/topic is gone. + page.navigate("/overview") + val filter = page.getByTestId("nav-tree-filter") + filter.fill("persistent://public/default/some-topic") + assertThat(filter).hasValue("public/default/some-topic", + new com.microsoft.playwright.assertions.LocatorAssertions.HasValueOptions().setTimeout(10000)) + } + + // --- Fixed app-side, but still not driveable from this JVM/UI harness ------------------------- + // The app defects below were fixed on 2026-07-19; the tests stay `ignore`d because faithfully + // DRIVING them still needs machinery this harness doesn't have. Each notes what would unlock it. + + // FIXED in KeyValueEditor.tsx (JsonView onChange now applies edits; readonly follows mode). + // Still unautomated: asserting it needs Monaco-level editing/read-only introspection. + ignore("BUG-2: 'As JSON' KeyValueEditor edits persist and respect readonly") {} + + // FIXED in AvailableInContextsButton.tsx (Confirm now pops the modal like Cancel). + // Still unautomated: reaching the dialog requires the un-instrumented LibraryItemEditor flow. + ignore("BUG-7: 'Available in Contexts' Confirm closes the modal") {} + + // FIXED server-side (Library.scala / LibraryServiceImpl.scala): empty-context items are rejected + // (8), empty search-contexts is INVALID_ARGUMENT - distinguishable from no-match (9), and item + // ids are charset-validated against path traversal (17). Still unautomated here: exercising the + // service API directly needs a generated LibraryService gRPC stub the e2e module doesn't have. + // BUG-16 (per-connection storage scoping) was RECLASSIFIED as by-design and reverted: a Dekaf + // instance always serves one Pulsar; isolation lives at the deployment layer (the desktop app + // gives each saved connection its own DEKAF_DATA_DIR; docker runs one dekaf per pulsar). + ignore("BUG-8: a zero-context library item is rejected, not orphaned (Library.scala)") {} + ignore("BUG-9: empty 'Search in Contexts' differs from 'no matches' (Library.scala)") {} + ignore("BUG-17: library id is sanitized against path traversal (Library.scala)") {} diff --git a/e2e/src/test/scala/primitives/PrimitivesSpec.scala b/e2e/src/test/scala/primitives/PrimitivesSpec.scala new file mode 100644 index 000000000..41bdd7fc7 --- /dev/null +++ b/e2e/src/test/scala/primitives/PrimitivesSpec.scala @@ -0,0 +1,72 @@ +package primitives + +import harness.DekafSuite +import harness.Eventually.eventually +import ui.ConfirmationDialog +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions +import com.microsoft.playwright.options.SelectOption +import scala.jdk.CollectionConverters.* + +/** X-1/X-2/X-3 - the cross-cutting form primitives (WithUpdateConfirmation, KeyValueEditor, ConfirmationDialog). */ +class PrimitivesSpec extends DekafSuite: + private def vis(ms: Int) = new LocatorAssertions.IsVisibleOptions().setTimeout(ms.toDouble) + private def cnt(ms: Int) = new LocatorAssertions.HasCountOptions().setTimeout(ms.toDouble) + + test("X-1: WithUpdateConfirmation - Save appears when dirty, validation disables it, Reset clears it") { + val tenant = fixtures.createTenant() + val ns = fixtures.createNamespace(tenant) + page.navigate(s"/tenants/$tenant/namespaces/$ns/details?category=retention") + + val typeSelect = page.getByTestId("retention-type-select") + assertThat(typeSelect).isVisible(vis(15000)) + + // Dirty + valid -> Save enabled. + typeSelect.selectOption(new SelectOption().setLabel("Custom")) + assertThat(page.getByTestId("update-confirm-save")).isEnabled() + + // Cross-field validation (a single 0 limit is invalid) -> Save disabled. + page.getByTestId("retention-size-value").locator("input").fill("0") + assertThat(page.getByTestId("update-confirm-save")).isDisabled() + + // Reset reverts to the loaded value -> no longer dirty -> the confirmation disappears. + page.getByTestId("update-confirm-reset").click() + assertThat(page.getByTestId("update-confirm-save")).hasCount(0, cnt(8000)) + } + + test("X-2: KeyValueEditor 'As List' adds + removes an entry; readonly view hides the add controls") { + val tenant = fixtures.createTenant() + val ns = fixtures.createNamespace(tenant) + val fqn = s"$tenant/$ns" + val save = page.getByTestId("update-confirm-save") + + page.navigate(s"/tenants/$tenant/namespaces/$ns/overview") + // Readonly (view) mode: no add controls. + assertThat(page.getByTestId("namespace-properties-edit-toggle")).isVisible(vis(15000)) + assertThat(page.getByTestId("new-key-properties")).hasCount(0) + + // Edit mode -> add a key/value -> save. + page.getByTestId("namespace-properties-edit-toggle").click() + page.getByTestId("new-key-properties").fill("envx") + page.getByTestId("new-value-properties").fill("prod") + page.getByTestId("key-value-add-properties").click() + save.click() + eventually() { assert(admin.namespaces().getProperties(fqn).asScala.get("envx").contains("prod")) } + + // Re-open in edit mode and remove the entry. + page.navigate(s"/tenants/$tenant/namespaces/$ns/overview") + page.getByTestId("namespace-properties-edit-toggle").click() + page.getByTestId("key-value-delete-envx-properties").click() + page.getByTestId("update-confirm-save").click() + eventually() { assert(!admin.namespaces().getProperties(fqn).asScala.contains("envx")) } + } + + test("X-3: ConfirmationDialog force-delete removes a non-empty tenant (force cascades)") { + val tenant = fixtures.createTenant() + fixtures.createNamespace(tenant) // makes the tenant non-empty + page.navigate(s"/tenants/$tenant/overview") + + page.getByTestId("tenant-page-delete-button").click() + ConfirmationDialog(page).confirm(guard = Some(tenant), force = true) + eventually() { assert(!admin.tenants().getTenants.asScala.contains(tenant)) } + } diff --git a/e2e/src/test/scala/routes/BreadCrumbsSpec.scala b/e2e/src/test/scala/routes/BreadCrumbsSpec.scala new file mode 100644 index 000000000..3f75cda95 --- /dev/null +++ b/e2e/src/test/scala/routes/BreadCrumbsSpec.scala @@ -0,0 +1,29 @@ +package routes + +import harness.DekafSuite +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions +import java.util.regex.Pattern + +/** NAV-5 - breadcrumbs reflect the resource, navigate on click, and copy the FQN. */ +class BreadCrumbsSpec extends DekafSuite: + test("NAV-5: breadcrumbs reflect the resource, navigate on click, and copy the FQN") { + val t = fixtures.createTenant() + val ns = fixtures.createNamespace(t) + val topic = fixtures.unique("topic") + admin.topics().createNonPartitionedTopic(s"persistent://$t/$ns/$topic") + page.navigate(s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/overview") + + assertThat(page.getByTestId("breadcrumbs")).isVisible(new LocatorAssertions.IsVisibleOptions().setTimeout(15000)) + assertThat(page.locator("[data-testid=breadcrumb][data-crumb-type=tenant]")).containsText(t) + assertThat(page.locator("[data-testid=breadcrumb][data-crumb-type=namespace]")).containsText(ns) + + // Copy FQN -> success toast. (JS regex engine rejects (?i) inline; use CASE_INSENSITIVE.) + page.getByTestId("breadcrumbs-copy-fqn").click() + assertThat(page.getByText(Pattern.compile("copied to clipboard", Pattern.CASE_INSENSITIVE))) + .isVisible(new LocatorAssertions.IsVisibleOptions().setTimeout(10000)) + + // Click the tenant crumb -> navigate to the tenant overview. + page.locator("[data-testid=breadcrumb][data-crumb-type=tenant]").click() + page.waitForURL(s"**/tenants/$t/overview**") + } diff --git a/e2e/src/test/scala/routes/CredentialsSpec.scala b/e2e/src/test/scala/routes/CredentialsSpec.scala new file mode 100644 index 000000000..e87d2ef6c --- /dev/null +++ b/e2e/src/test/scala/routes/CredentialsSpec.scala @@ -0,0 +1,110 @@ +package routes + +import harness.DekafSuite +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions +import com.microsoft.playwright.options.SelectOption +import java.nio.file.Files + +/** NAV-9/12/13/14 - Pulsar credentials. State lives in the per-browser `pulsar_auth` HttpOnly + * cookie (every pulsar-auth route reads/writes `ctx.cookie` - nothing persists server-side), + * so the fresh BrowserContext each test gets IS the isolation: no cleanup, no cross-test or + * cross-run interference. */ +class CredentialsSpec extends DekafSuite: + private def vis(ms: Int) = new LocatorAssertions.IsVisibleOptions().setTimeout(ms.toDouble) + private def cnt(ms: Int) = new LocatorAssertions.HasCountOptions().setTimeout(ms.toDouble) + + private def openEditor(): Unit = + page.navigate("/overview") + page.getByTestId("credentials-button").click() + assertThat(page.getByTestId("modal")).isVisible(vis(10000)) + + private def credRow(name: String) = + page.locator(s"[data-testid=credentials-row][data-cred-name='$name']") + + private def currentMarker(row: com.microsoft.playwright.Locator) = + row.getByText("Current", new com.microsoft.playwright.Locator.GetByTextOptions().setExact(true)) + + test("NAV-9: add, set-current both directions, and delete a credential; Default is undeletable") { + val name = fixtures.unique("cred").replaceAll("[^A-Za-z0-9_-]", "-") + openEditor() + + // Add (Empty type is the default). The add route makes the new credential current - assert it. + page.getByTestId("credentials-add").click() + page.getByTestId("credentials-name").fill(name) + page.getByTestId("credentials-save").click() + val row = credRow(name) + assertThat(row).isVisible(vis(10000)) + assertThat(currentMarker(row)).isVisible(vis(10000)) + + // The seeded Default credential cannot be deleted. + val defaultRow = credRow("Default") + assertThat(defaultRow.getByTestId("credentials-delete")).isDisabled() + + // Switch current away to Default, then BACK to ours - both directions from a real not-current state. + defaultRow.getByTestId("credentials-set-current").click() + assertThat(currentMarker(defaultRow)).isVisible(vis(10000)) + row.getByTestId("credentials-set-current").click() + assertThat(currentMarker(row)).isVisible(vis(10000)) + + // Restore Default as current, then delete ours. + defaultRow.getByTestId("credentials-set-current").click() + assertThat(currentMarker(defaultRow)).isVisible(vis(10000)) + row.getByTestId("credentials-delete").click() + assertThat(row).hasCount(0, cnt(10000)) + } + + test("NAV-12: adding a JWT credential drives the method-specific token form") { + val name = fixtures.unique("cred").replaceAll("[^A-Za-z0-9_-]", "-") + openEditor() + + page.getByTestId("credentials-add").click() + page.getByTestId("credentials-name").fill(name) + // Switch the method to JWT - its token field only renders for the JWT type. + page.getByTestId("credentials-method-select").selectOption(new SelectOption().setLabel("JWT")) + page.getByTestId("credentials-jwt-token").fill(s"header.$name.signature") + page.getByTestId("credentials-save").click() + + assertThat(credRow(name)).isVisible(vis(10000)) + } + + test("NAV-13: adding an OAuth2 credential drives its inputs + private-key file upload") { + val name = fixtures.unique("cred").replaceAll("[^A-Za-z0-9_-]", "-") + // A valid, small JSON key file for the UploadZone (rc-upload reads it, parses JSON, base64s it). + val keyFile = Files.createTempFile("e2e-oauth2-key", ".json") + Files.writeString(keyFile, """{"client_id":"e2e","client_secret":"secret"}""") + fixtures.onCleanup(() => Files.deleteIfExists(keyFile)) + + openEditor() + page.getByTestId("credentials-add").click() + page.getByTestId("credentials-name").fill(name) + page.getByTestId("credentials-method-select").selectOption(new SelectOption().setLabel("OAuth2")) + page.getByTestId("credentials-oauth2-issuer").fill("https://issuer.example.com/") + page.getByTestId("credentials-oauth2-audience").fill("urn:e2e:audience") + // Drive the hidden rc-upload file input (scoped to the modal) and confirm the key registered. + page.getByTestId("modal").locator("input[type=file]").setInputFiles(keyFile) + assertThat(page.getByText("File loaded!")).isVisible(vis(10000)) + page.getByTestId("credentials-save").click() + + assertThat(credRow(name)).isVisible(vis(10000)) + } + + test("NAV-14: adding an Auth-Params-String credential drives its plugin/params form") { + // Formerly BUG-19: `maskedCredentialsFromPb` had no AUTH_PARAMS_STRING case and default-threw, + // so the saved credential never rendered. Fixed in conversions.ts - this is now the ordinary + // coverage for the fourth credential method. + val name = fixtures.unique("cred").replaceAll("[^A-Za-z0-9_-]", "-") + openEditor() + + page.getByTestId("credentials-add").click() + page.getByTestId("credentials-name").fill(name) + page.getByTestId("credentials-method-select").selectOption(new SelectOption().setLabel("Auth Params String")) + page.getByTestId("credentials-authparams-plugin").fill("org.apache.pulsar.client.impl.auth.AuthenticationBasic") + page.getByTestId("credentials-authparams-params").fill("userId:e2e,password:secret") + page.getByTestId("credentials-save").click() + + val row = credRow(name) + assertThat(row).isVisible(vis(10000)) + // The type cell must show the real label, not "Unknown" (credentialsTypeToLabel had no case). + assertThat(row).containsText("Auth Params String") + } diff --git a/e2e/src/test/scala/routes/HealthCheckSpec.scala b/e2e/src/test/scala/routes/HealthCheckSpec.scala new file mode 100644 index 000000000..925cc3458 --- /dev/null +++ b/e2e/src/test/scala/routes/HealthCheckSpec.scala @@ -0,0 +1,25 @@ +package routes + +import harness.DekafSuite +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions +import java.util.regex.Pattern + +/** NAV-10 - the periodic broker health check drives a full-screen overlay on connectivity loss, + * which clears (via reload) once connectivity recovers. Simulated by aborting the poll request. */ +class HealthCheckSpec extends DekafSuite: + // The gRPC-web endpoint is /api/tools.teal.pulsar.ui.brokers.v1.BrokersService/HealthCheck - + // the service segment contains dots, so match by regex rather than a path-segment glob. + private val healthCheckUrl = Pattern.compile(".*BrokersService/HealthCheck") + + test("NAV-10: a connectivity drop shows the health overlay, which clears on recovery") { + page.navigate("/overview") + + // Force the periodic health check (polls every 5s) to fail. + page.route(healthCheckUrl, route => route.abort()) + assertThat(page.getByTestId("health-overlay")).isVisible(new LocatorAssertions.IsVisibleOptions().setTimeout(20000)) + + // Recover: the next successful poll reloads the page and the overlay is gone. + page.unroute(healthCheckUrl) + assertThat(page.getByTestId("health-overlay")).hasCount(0, new LocatorAssertions.HasCountOptions().setTimeout(20000)) + } diff --git a/e2e/src/test/scala/routes/InstanceConfigSpec.scala b/e2e/src/test/scala/routes/InstanceConfigSpec.scala new file mode 100644 index 000000000..f0bb948ef --- /dev/null +++ b/e2e/src/test/scala/routes/InstanceConfigSpec.scala @@ -0,0 +1,57 @@ +package routes + +import harness.DekafSuite +import harness.Eventually.eventually +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions +import scala.jdk.CollectionConverters.* + +/** INS-5 - the dynamic broker configuration page (/instance/configuration): filter, dynamic-only + * toggle, and inline edit persisted to the broker. */ +class InstanceConfigSpec extends DekafSuite: + private def vis(ms: Int) = new LocatorAssertions.IsVisibleOptions().setTimeout(ms.toDouble) + + test("INS-5: dynamic configuration filters by name and toggles dynamic-only") { + page.navigate("/instance/configuration") + val rows = page.locator("[data-testid=config-row]") + assertThat(rows.first()).isVisible(vis(15000)) + val allCount = rows.count() + assert(allCount > 5, s"expected many config rows, got $allCount") + + // Filter narrows the table. + page.getByTestId("config-filter").fill("maxConcurrentLookupRequest") + assertThat(page.locator("[data-testid=config-row][data-config-key='maxConcurrentLookupRequest']")).isVisible(vis(10000)) + eventually() { assert(rows.count() < allCount, s"filter did not reduce rows: ${rows.count()} !< $allCount") } + + // Show-dynamic-only reduces further (dynamic keys are a subset of all keys). + page.getByTestId("config-filter").fill("") + assertThat(rows.first()).isVisible(vis(10000)) + val beforeToggle = rows.count() + page.getByTestId("config-toggle-dynamic").click() + eventually() { assert(rows.count() < beforeToggle, s"dynamic-only did not reduce rows: ${rows.count()} !< $beforeToggle") } + } + + test("INS-5: editing a dynamic configuration value persists to the broker") { + val key = "maxConcurrentLookupRequest" + // Snapshot the prior value and restore it exactly on teardown (don't clobber pre-existing state). + val prior = admin.brokers().getAllDynamicConfigurations().asScala.get(key) + fixtures.onCleanup { () => + prior match + case Some(v) => admin.brokers().updateDynamicConfiguration(key, v) + case None => admin.brokers().deleteDynamicConfiguration(key) + } + + page.navigate("/instance/configuration") + page.getByTestId("config-filter").fill(key) + val row = page.locator(s"[data-testid=config-row][data-config-key='$key']") + assertThat(row).isVisible(vis(10000)) + row.getByTestId("config-edit").click() + row.getByTestId("config-value-input").fill("50001") + row.getByTestId("config-update").click() + + assertThat(page.getByText("has been successfully update")).isVisible(vis(10000)) + eventually() { + assert(admin.brokers().getAllDynamicConfigurations().asScala.get(key).contains("50001"), + s"broker dynamic config was: ${admin.brokers().getAllDynamicConfigurations()}") + } + } diff --git a/e2e/src/test/scala/routes/ModalsSpec.scala b/e2e/src/test/scala/routes/ModalsSpec.scala new file mode 100644 index 000000000..59cc3c1ab --- /dev/null +++ b/e2e/src/test/scala/routes/ModalsSpec.scala @@ -0,0 +1,29 @@ +package routes + +import harness.DekafSuite +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions + +/** NAV-7 - modal stack: Escape closes the top modal; a route change clears the whole stack. */ +class ModalsSpec extends DekafSuite: + private def vis(ms: Int) = new LocatorAssertions.IsVisibleOptions().setTimeout(ms.toDouble) + private def cnt(ms: Int) = new LocatorAssertions.HasCountOptions().setTimeout(ms.toDouble) + + test("NAV-7: Escape closes an open modal") { + page.navigate("/overview") + page.getByTestId("credentials-button").click() + val modal = page.getByTestId("modal") + assertThat(modal).isVisible(vis(10000)) + modal.press("Escape") + assertThat(modal).hasCount(0, cnt(8000)) + } + + test("NAV-7: navigating to another route clears the modal stack") { + val t = fixtures.createTenant() + page.navigate(s"/tenants/$t/overview") + page.getByTestId("credentials-button").click() + assertThat(page.getByTestId("modal")).isVisible(vis(10000)) + // SPA navigation via the instance breadcrumb. + page.locator("[data-testid=breadcrumb][data-crumb-type=instance]").click() + assertThat(page.getByTestId("modal")).hasCount(0, cnt(8000)) + } diff --git a/e2e/src/test/scala/routes/NamespaceBundlesSpec.scala b/e2e/src/test/scala/routes/NamespaceBundlesSpec.scala new file mode 100644 index 000000000..e35b25be7 --- /dev/null +++ b/e2e/src/test/scala/routes/NamespaceBundlesSpec.scala @@ -0,0 +1,70 @@ +package routes + +import harness.DekafSuite +import harness.Eventually.eventually +import ui.ConfirmationDialog +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions +import com.microsoft.playwright.options.AriaRole +import com.microsoft.playwright.Page.GetByRoleOptions +import org.apache.pulsar.client.api.MessageId + +class NamespaceBundlesSpec extends DekafSuite: + + private def button(name: String) = + page.getByRole(AriaRole.BUTTON, new GetByRoleOptions().setName(name).setExact(true)) + + test("NS-8: bundles tab renders the actions and a confirm flow opens and closes cleanly") { + val tenant = fixtures.createTenant() + val ns = fixtures.createNamespace(tenant) + + page.navigate(s"/tenants/$tenant/namespaces/$ns/details?category=bundles") + + // Namespace-wide + per-bundle actions render (per-row buttons repeat, so use .first()). + assertThat(button("Unload all")).isVisible(new LocatorAssertions.IsVisibleOptions().setTimeout(10000)) + assertThat(button("Clear backlog").first()).isVisible() + assertThat(button("Split").first()).isVisible() + assertThat(button("Unload").first()).isVisible() + + // Confirm flow opens and closes cleanly (no crash), no side effect. + button("Unload all").click() + val confirm = page.getByTestId("confirmation-dialog-confirm-button") + assertThat(confirm).isVisible() + button("Cancel").click() + assertThat(confirm).isHidden() + } + + test("NS-8: splitting a bundle increases the bundle count") { + val tenant = fixtures.createTenant() + val ns = fixtures.createNamespace(tenant) + val fqn = s"$tenant/$ns" + + val initial = admin.namespaces().getBundles(fqn).getNumBundles + + page.navigate(s"/tenants/$tenant/namespaces/$ns/details?category=bundles") + assertThat(button("Split").first()).isVisible(new LocatorAssertions.IsVisibleOptions().setTimeout(10000)) + button("Split").first().click() + ConfirmationDialog(page).confirm() // default split algorithm pre-selected. + + eventually() { + val now = admin.namespaces().getBundles(fqn).getNumBundles + assert(now > initial, s"bundle count did not increase: $initial -> $now") + } + } + + test("NS-17: namespace-wide Clear backlog empties a subscription's backlog") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + val sub = fixtures.unique("sub") + admin.topics().createSubscription(fqn, sub, MessageId.earliest) + fixtures.produceStrings(fqn, 5) // 5 unconsumed messages => backlog of 5 + + def backlog: Long = admin.topics().getStats(fqn).getSubscriptions.get(sub).getMsgBacklog + eventually() { assert(backlog == 5L, s"pre-clear backlog: $backlog") } + + page.navigate(s"/tenants/$t/namespaces/$ns/details?category=bundles") + page.getByTestId("ns-clear-backlog-button").click() + ConfirmationDialog(page).confirm(guard = Some("CONFIRM")) // ns-wide Clear backlog is guarded. + + eventually() { assert(backlog == 0L, s"post-clear backlog: $backlog") } + } diff --git a/e2e/src/test/scala/routes/NamespaceGeoReplicationSpec.scala b/e2e/src/test/scala/routes/NamespaceGeoReplicationSpec.scala new file mode 100644 index 000000000..4dd1a2836 --- /dev/null +++ b/e2e/src/test/scala/routes/NamespaceGeoReplicationSpec.scala @@ -0,0 +1,39 @@ +package routes + +import harness.DekafSuite +import harness.Eventually.eventually +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions +import com.microsoft.playwright.options.{AriaRole, SelectOption} +import com.microsoft.playwright.Page.GetByRoleOptions +import scala.jdk.CollectionConverters.* + +/** Geo Replication was previously untestable on the single-cluster dev stack (the add control hides + * when the namespace already replicates to every available cluster). The multi-cluster fixture + * (`createCluster` registers a second cluster's metadata) unblocks it: the tab now offers the peer, + * and the write is verified through PulsarAdmin. */ +class NamespaceGeoReplicationSpec extends DekafSuite: + private def vis(ms: Int) = new LocatorAssertions.IsVisibleOptions().setTimeout(ms.toDouble) + private def saveButton = + page.getByRole(AriaRole.BUTTON, new GetByRoleOptions().setName("Save").setExact(true)) + + test("NS-18: Geo Replication adds a second cluster to the namespace's replication set") { + val cluster2 = fixtures.createCluster() + val tenant = fixtures.createTenant(cluster2) // tenant must allow both clusters + val ns = fixtures.createNamespace(tenant) + val fqn = s"$tenant/$ns" + + page.navigate(s"/tenants/$tenant/namespaces/$ns/details?category=geo-replication") + + val select = page.getByTestId("replication-cluster-select") + assertThat(select).isVisible(vis(15000)) + select.selectOption(new SelectOption().setLabel(cluster2)) + page.getByTestId("replication-clusters-add").click() + saveButton.click() + + eventually() { + val clusters = admin.namespaces().getNamespaceReplicationClusters(fqn).asScala.toSet + assert(clusters.contains(cluster2) && clusters.contains(fixtures.firstCluster), + s"replication clusters were: $clusters") + } + } diff --git a/e2e/src/test/scala/routes/NamespaceOverviewSpec.scala b/e2e/src/test/scala/routes/NamespaceOverviewSpec.scala new file mode 100644 index 000000000..eea9e79bb --- /dev/null +++ b/e2e/src/test/scala/routes/NamespaceOverviewSpec.scala @@ -0,0 +1,43 @@ +package routes + +import harness.DekafSuite +import harness.Eventually.eventually +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions +import com.microsoft.playwright.options.AriaRole +import com.microsoft.playwright.Page.GetByRoleOptions +import scala.jdk.CollectionConverters.* + +class NamespaceOverviewSpec extends DekafSuite: + + test("NS-1: namespace overview shows stats and the Properties editor persists a property") { + val tenant = fixtures.createTenant() + val ns = fixtures.createNamespace(tenant) + val fqn = s"$tenant/$ns" + + page.navigate(s"/tenants/$tenant/namespaces/$ns/overview") + + // Statistics block renders. + assertThat(page.getByText("Namespace FQN").first()).isVisible() + assertThat(page.getByText("Topics count").first()).isVisible() + assertThat(page.getByText(fqn).first()).isVisible() + + // Enter edit mode, add a key/value, and save through the shared UpdateConfirmation. + val key = "env" + val value = fixtures.unique("val") + + page.getByTestId("namespace-properties-edit-toggle").click() + page.getByTestId("new-key-properties").fill(key) + page.getByTestId("new-value-properties").fill(value) + page.getByTestId("key-value-add-properties").click() + + val saveButton = page.getByRole(AriaRole.BUTTON, new GetByRoleOptions().setName("Save").setExact(true)) + assertThat(saveButton).isVisible(new LocatorAssertions.IsVisibleOptions().setTimeout(10000)) + saveButton.click() + + // Oracle: PulsarAdmin sees the new property. + eventually() { + val props = admin.namespaces().getProperties(fqn).asScala + assert(props.get(key).contains(value), s"properties were: $props") + } + } diff --git a/e2e/src/test/scala/routes/NamespacePermissionSpec.scala b/e2e/src/test/scala/routes/NamespacePermissionSpec.scala new file mode 100644 index 000000000..1b51b4d8d --- /dev/null +++ b/e2e/src/test/scala/routes/NamespacePermissionSpec.scala @@ -0,0 +1,103 @@ +package routes + +import harness.DekafSuite +import harness.Eventually.eventually +import ui.ConfirmationDialog +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions +import org.apache.pulsar.common.policies.data.AuthAction +import scala.jdk.CollectionConverters.* +import java.util.EnumSet + +class NamespacePermissionSpec extends DekafSuite: + + test("NS-6: granting a role permission shows the row after reload") { + val tenant = fixtures.createTenant() + val ns = fixtures.createNamespace(tenant) + val fqn = s"$tenant/$ns" + val role = fixtures.unique("role") + + page.navigate(s"/tenants/$tenant/namespaces/$ns/details?category=access-control") + + page.getByTestId("permission-new-role-input").fill(role) + page.getByTestId("permission-new-produce").click() // Pulsar rejects an empty action set. + page.getByTestId("permission-grant-button").click() + + // Oracle: PulsarAdmin sees the grant - exactly `produce`, nothing more (over-granting is a bug). + eventually() { + val actions = admin.namespaces().getPermissions(fqn).asScala + .get(role).map(_.asScala.toSet).getOrElse(Set.empty) + assert(actions == Set(AuthAction.produce), s"actions were: $actions") + } + + // Row appears after a fresh reload. + page.navigate(s"/tenants/$tenant/namespaces/$ns/details?category=access-control") + assertThat(page.getByText(role).first()) + .isVisible(new LocatorAssertions.IsVisibleOptions().setTimeout(10000)) + } + + test("NS-7: updating a role's actions via the UI persists") { + val tenant = fixtures.createTenant() + val ns = fixtures.createNamespace(tenant) + val fqn = s"$tenant/$ns" + val role = fixtures.unique("role") + + admin.namespaces().grantPermissionOnNamespace(fqn, role, EnumSet.of(AuthAction.produce)) + + page.navigate(s"/tenants/$tenant/namespaces/$ns/details?category=access-control") + assertThat(page.getByText(role).first()) + .isVisible(new LocatorAssertions.IsVisibleOptions().setTimeout(10000)) + + // Add `consume` to the existing row and persist (update button is row-unique since the BUG-10 fix). + page.getByTestId(s"permission-$role-consume").click() + page.getByTestId(s"permission-update-button-$role").click() + + eventually() { + val actions = admin.namespaces().getPermissions(fqn).asScala + .get(role).map(_.asScala.toSet).getOrElse(Set.empty) + assert(actions == Set(AuthAction.produce, AuthAction.consume), s"actions were: $actions") + } + } + + test("NS-7: revoking a role via the UI removes it") { + val tenant = fixtures.createTenant() + val ns = fixtures.createNamespace(tenant) + val fqn = s"$tenant/$ns" + val role = fixtures.unique("role") + + admin.namespaces().grantPermissionOnNamespace(fqn, role, EnumSet.of(AuthAction.produce)) + + page.navigate(s"/tenants/$tenant/namespaces/$ns/details?category=access-control") + assertThat(page.getByText(role).first()) + .isVisible(new LocatorAssertions.IsVisibleOptions().setTimeout(10000)) + + // Row-unique since the BUG-10 fix - addresses exactly this role's revoke control. + page.getByTestId(s"permission-revoke-button-$role").click() + ConfirmationDialog(page).confirm() // no guard / no force on the revoke dialog. + + eventually() { + val perms = admin.namespaces().getPermissions(fqn).asScala + assert(!perms.contains(role), s"permissions still: $perms") + } + } + + test("NS-16: granting multiple actions to a role in one row persists all of them") { + val tenant = fixtures.createTenant() + val ns = fixtures.createNamespace(tenant) + val fqn = s"$tenant/$ns" + val role = fixtures.unique("role") + + page.navigate(s"/tenants/$tenant/namespaces/$ns/details?category=access-control") + page.getByTestId("permission-new-role-input").fill(role) + // Tick three of the six auth-action columns on the new-role row, then Grant. + Seq("produce", "consume", "functions").foreach(a => page.getByTestId(s"permission-new-$a").click()) + page.getByTestId("permission-grant-button").click() + + eventually() { + val actions = admin.namespaces().getPermissions(fqn).asScala + .get(role).map(_.asScala.toSet).getOrElse(Set.empty) + // Exact equality: a wiring bug that grants EXTRA actions must fail, not slip through a subset check. + assert(actions == Set(AuthAction.produce, AuthAction.consume, AuthAction.functions), + s"actions were: $actions") + } + } diff --git a/e2e/src/test/scala/routes/NamespacePolicyBreadthSpec.scala b/e2e/src/test/scala/routes/NamespacePolicyBreadthSpec.scala new file mode 100644 index 000000000..d3aca9002 --- /dev/null +++ b/e2e/src/test/scala/routes/NamespacePolicyBreadthSpec.scala @@ -0,0 +1,182 @@ +package routes + +import harness.DekafSuite +import harness.Eventually.eventually +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions +import com.microsoft.playwright.options.{AriaRole, SelectOption} +import com.microsoft.playwright.Page.GetByRoleOptions +import org.apache.pulsar.common.policies.data.SchemaCompatibilityStrategy + +/** Namespace Details policy-editor breadth: NS-4/NS-5 prove the shared ConfigurationTable mechanism + * on two tabs; these persist-and-verify one representative field per remaining distinct field shape + * (terminal select, select+duration default, dynamic-enum select, select+numeric) and cross-check + * the write through PulsarAdmin - so a wrong field→policy wiring on any of these tabs is caught. */ +class NamespacePolicyBreadthSpec extends DekafSuite: + private def vis(ms: Int) = new LocatorAssertions.IsVisibleOptions().setTimeout(ms.toDouble) + private def saveButton = + page.getByRole(AriaRole.BUTTON, new GetByRoleOptions().setName("Save").setExact(true)) + + private def openDetails(category: String): (String, String) = + val tenant = fixtures.createTenant() + val ns = fixtures.createNamespace(tenant) + page.navigate(s"/tenants/$tenant/namespaces/$ns/details?category=$category") + (tenant, ns) + + test("NS-10: Message Deduplication tab persists an enabled policy") { + val (t, ns) = openDetails("message-deduplication") + val fqn = s"$t/$ns" + val select = page.getByTestId("deduplication-type-select") + assertThat(select).isVisible(vis(15000)) + select.selectOption(new SelectOption().setLabel("Enabled")) + saveButton.click() + eventually() { + assert(admin.namespaces().getDeduplicationStatus(fqn) == true, + s"deduplication was: ${admin.namespaces().getDeduplicationStatus(fqn)}") + } + } + + test("NS-11: Delayed Delivery tab persists an enabled policy with its tick time") { + val (t, ns) = openDetails("delayed-delivery") + val fqn = s"$t/$ns" + val select = page.getByTestId("delayed-delivery-type-select") + assertThat(select).isVisible(vis(15000)) + select.selectOption(new SelectOption().setLabel("Enabled")) + // Fill a NON-default tick (2s → 2000ms): the seed is 1000ms, so asserting 2000 proves the + // value input's wiring, not just the select's seed. + page.getByTestId("delayed-delivery-tick-input").fill("2") + saveButton.click() + eventually() { + val p = admin.namespaces().getDelayedDelivery(fqn) + assert(p != null && p.isActive && p.getTickTime == 2000L, s"delayed-delivery was: $p") + } + } + + test("NS-12: Schema tab persists the compatibility strategy") { + val (t, ns) = openDetails("schema") + val fqn = s"$t/$ns" + val select = page.getByTestId("schema-compatibility-type-select") + assertThat(select).isVisible(vis(15000)) + select.selectOption(new SelectOption().setLabel("FORWARD")) + saveButton.click() + eventually() { + assert(admin.namespaces().getSchemaCompatibilityStrategy(fqn) == SchemaCompatibilityStrategy.FORWARD, + s"schema strategy was: ${admin.namespaces().getSchemaCompatibilityStrategy(fqn)}") + } + } + + test("NS-13: Rate Limits (dispatch rate) tab persists a specified message rate") { + val (t, ns) = openDetails("rate-limits") + val fqn = s"$t/$ns" + val select = page.getByTestId("dispatch-rate-type-select") + assertThat(select).isVisible(vis(15000)) + select.selectOption(new SelectOption().setLabel("Specified")) + page.getByTestId("dispatch-rate-msg-input").fill("100") + saveButton.click() + eventually() { + val r = admin.namespaces().getDispatchRate(fqn) + assert(r != null && r.getDispatchThrottlingRateInMsg == 100, s"dispatch rate was: $r") + } + } + + test("NS-14: Tiered Storage tab reveals the driver form when a driver is chosen") { + // Render-level: a real offload persist needs broker-configured offloaders (absent on the dev + // standalone), so assert the driver switch reveals its provider-specific form instead. + openDetails("tiered-storage") + val driver = page.getByTestId("offload-driver-select") + assertThat(driver).isVisible(vis(15000)) + driver.selectOption(new SelectOption().setLabel("aws-s3")) + assertThat(page.getByText("Bucket").first()).isVisible(vis(10000)) + assertThat(page.getByText("Service endpoint").first()).isVisible(vis(10000)) + assertThat(page.getByText("Offloaders directory").first()).isVisible(vis(10000)) + } + + test("NS-15: Encryption tab persists a required-encryption policy") { + val (t, ns) = openDetails("encryption") + val fqn = s"$t/$ns" + val select = page.getByTestId("encryption-required-type-select") + assertThat(select).isVisible(vis(15000)) + select.selectOption(new SelectOption().setLabel("Required")) + saveButton.click() + eventually() { + assert(admin.namespaces().getEncryptionRequiredStatus(fqn) == true, + s"encryptionRequired was: ${admin.namespaces().getEncryptionRequiredStatus(fqn)}") + } + } + + test("NS-19: Tiered Storage persists a filesystem offload policy") { + // The standalone accepts an offload *policy* at the metadata level (no offloaders loaded needed), + // so the filesystem driver - unlike the credential-bearing cloud drivers - is a clean persist. + val (t, ns) = openDetails("tiered-storage") + val fqn = s"$t/$ns" + val driver = page.getByTestId("offload-driver-select") + assertThat(driver).isVisible(vis(15000)) + driver.selectOption(new SelectOption().setLabel("filesystem")) + page.getByTestId("offload-offloaders-directory").fill("offloaders") + page.getByTestId("offload-filesystem-profile-path").fill("conf/filesystem_offload_core_site.xml") + saveButton.click() + eventually() { + val p = admin.namespaces().getOffloadPolicies(fqn) + assert(p != null && p.getManagedLedgerOffloadDriver == "filesystem", + s"offload driver was: ${Option(p).map(_.getManagedLedgerOffloadDriver)}") + // Both typed values must round-trip - a driver-only assert would miss swapped/dropped fields. + assert(p.getOffloadersDirectory == "offloaders", s"offloaders dir was: ${p.getOffloadersDirectory}") + assert(p.getFileSystemProfilePath == "conf/filesystem_offload_core_site.xml", + s"fs profile path was: ${p.getFileSystemProfilePath}") + } + } + + test("NS-20: Compute Resources tab assigns a resource group to the namespace") { + val rg = fixtures.createResourceGroup() // must exist before the tab loads its options + val (t, ns) = openDetails("compute-resources") + val fqn = s"$t/$ns" + page.getByTestId("resource-group-type-select").selectOption(new SelectOption().setLabel("Specified for this namespace")) + page.getByTestId("resource-group-select").selectOption(new SelectOption().setLabel(rg)) + saveButton.click() + eventually() { + assert(admin.namespaces().getNamespaceResourceGroup(fqn) == rg, + s"resource group was: ${admin.namespaces().getNamespaceResourceGroup(fqn)}") + } + } + + test("NS-21: Messaging tab persists max-unacked-messages-per-consumer") { + val (t, ns) = openDetails("messaging") + val fqn = s"$t/$ns" + page.getByTestId("max-unacked-consumer-type-select").selectOption(new SelectOption().setLabel("Specified for this namespace")) + page.getByTestId("max-unacked-consumer-value-input").fill("42") + saveButton.click() + eventually() { + val v = admin.namespaces().getMaxUnackedMessagesPerConsumer(fqn) + assert(v != null && v.intValue == 42, s"maxUnacked was: $v") + } + } + + test("NS-22: Persistence tab persists bookkeeper ensemble/quorums") { + val (t, ns) = openDetails("persistence") + val fqn = s"$t/$ns" + page.getByTestId("persistence-type-select").selectOption(new SelectOption().setLabel("Specified for this namespace")) + // Fill ensemble ≥ write-quorum ≥ ack-quorum in order so the client-side check stays valid throughout. + page.getByTestId("persistence-ensemble-input").fill("2") + page.getByTestId("persistence-write-quorum-input").fill("2") + page.getByTestId("persistence-ack-quorum-input").fill("1") + saveButton.click() + eventually() { + val p = admin.namespaces().getPersistence(fqn) + assert(p != null && p.getBookkeeperEnsemble == 2 && p.getBookkeeperWriteQuorum == 2 && p.getBookkeeperAckQuorum == 1, + s"persistence was: $p") + } + } + + test("NS-23: Topic Compaction (namespace) persists a compaction threshold") { + val (t, ns) = openDetails("topic-compaction") + val fqn = s"$t/$ns" + page.getByTestId("compaction-threshold-type-select").selectOption(new SelectOption().setLabel("Specified for this namespace")) + // The seed (1024B) renders as "1 KB"; fill a NON-default "3" → 3 KB = 3072 bytes, proving the + // value input's wiring rather than the select's hardcoded seed. + page.getByTestId("compaction-threshold-value-input").fill("3") + saveButton.click() + eventually() { + assert(admin.namespaces().getCompactionThreshold(fqn) == 3072L, + s"compaction threshold was: ${admin.namespaces().getCompactionThreshold(fqn)}") + } + } diff --git a/e2e/src/test/scala/routes/NamespacePolicySpec.scala b/e2e/src/test/scala/routes/NamespacePolicySpec.scala new file mode 100644 index 000000000..9137baf93 --- /dev/null +++ b/e2e/src/test/scala/routes/NamespacePolicySpec.scala @@ -0,0 +1,74 @@ +package routes + +import harness.DekafSuite +import harness.Eventually.eventually +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions +import com.microsoft.playwright.options.{AriaRole, SelectOption} +import com.microsoft.playwright.Page.GetByRoleOptions +import scala.jdk.CollectionConverters.* + +class NamespacePolicySpec extends DekafSuite: + + private def saveButton = + page.getByRole(AriaRole.BUTTON, new GetByRoleOptions().setName("Save").setExact(true)) + + test("NS-4: retention selector offers four options with cross-field validation gating Save") { + val tenant = fixtures.createTenant() + val ns = fixtures.createNamespace(tenant) + val fqn = s"$tenant/$ns" + + page.navigate(s"/tenants/$tenant/namespaces/$ns/details?category=retention") + + val typeSelect = page.getByTestId("retention-type-select") + assertThat(typeSelect).isVisible(new LocatorAssertions.IsVisibleOptions().setTimeout(10000)) + + // Four-way selector. + assertThat(typeSelect.locator("option")).hasCount(4) + val opts = typeSelect.locator("option").allTextContents().asScala.toList + assert( + opts.contains("Inherited from broker config") && opts.contains("Disabled") && + opts.contains("Retain infinitely") && opts.contains("Custom"), + s"retention options were: $opts" + ) + + // Custom => time & size default to a limit of 1 (valid); Save appears enabled. + typeSelect.selectOption(new SelectOption().setLabel("Custom")) + assertThat(saveButton).isEnabled() + + // Drive size to 0 while time stays 1 => cross-field validation fails, Save disabled. + page.getByTestId("retention-size-value").locator("input").fill("0") + assertThat(page.getByText("Setting a single time or size limit to 0 is invalid")).isVisible() + assertThat(saveButton).isDisabled() + + // Switch to a valid selection and persist; PulsarAdmin confirms infinite retention. + typeSelect.selectOption(new SelectOption().setLabel("Retain infinitely")) + assertThat(saveButton).isEnabled() + saveButton.click() + + eventually() { + val r = admin.namespaces().getRetention(fqn) + assert(r != null && r.getRetentionSizeInMB == -1 && r.getRetentionTimeInMinutes == -1, + s"retention was: $r") + } + } + + test("NS-5: editing max-consumers-per-topic on the Limits tab persists") { + val tenant = fixtures.createTenant() + val ns = fixtures.createNamespace(tenant) + val fqn = s"$tenant/$ns" + + page.navigate(s"/tenants/$tenant/namespaces/$ns/details?category=limits") + + val typeSelect = page.getByTestId("max-consumers-per-topic-type-select") + assertThat(typeSelect).isVisible(new LocatorAssertions.IsVisibleOptions().setTimeout(10000)) + typeSelect.selectOption(new SelectOption().setLabel("Specified for this namespace")) + + page.getByTestId("max-consumers-per-topic-value-input").fill("5") + saveButton.click() + + eventually() { + val v = admin.namespaces().getMaxConsumersPerTopic(fqn) + assert(v == 5, s"maxConsumersPerTopic was: $v") + } + } diff --git a/e2e/src/test/scala/routes/NamespaceSpec.scala b/e2e/src/test/scala/routes/NamespaceSpec.scala new file mode 100644 index 000000000..6dc7b0ce9 --- /dev/null +++ b/e2e/src/test/scala/routes/NamespaceSpec.scala @@ -0,0 +1,54 @@ +package routes + +import harness.DekafSuite +import harness.Eventually.eventually +import ui.ConfirmationDialog +import com.microsoft.playwright.options.{AriaRole, SelectOption} +import com.microsoft.playwright.Page.GetByRoleOptions +import scala.jdk.CollectionConverters.* + +class NamespaceSpec extends DekafSuite: + private def createButton = page.getByRole(AriaRole.BUTTON, new GetByRoleOptions().setName("Create").setExact(true)) + + test("NS-3: create a non-partitioned topic") { + val tenant = fixtures.createTenant() + val ns = fixtures.createNamespace(tenant) + val topic = fixtures.unique("topic") + val fqn = s"persistent://$tenant/$ns/$topic" + page.navigate(s"/tenants/$tenant/namespaces/$ns/create-topic") + page.getByTestId("create-topic-name").fill(topic) + // The form defaults to 'partitioned' - select non-partitioned explicitly. + page.getByTestId("create-topic-partitioning").selectOption(new SelectOption().setValue("non-partitioned")) + createButton.click() + // Oracle: PulsarAdmin reports it as a real non-partitioned topic (partitions == 0), listed by exact FQN. + eventually() { + assert(admin.topics().getList(s"$tenant/$ns").asScala.contains(fqn), "topic not in non-partitioned list") + assert(admin.topics().getPartitionedTopicMetadata(fqn).partitions == 0, "topic is partitioned") + } + } + + test("NS-3: create a partitioned topic") { + val tenant = fixtures.createTenant() + val ns = fixtures.createNamespace(tenant) + val topic = fixtures.unique("ptopic") + val fqn = s"persistent://$tenant/$ns/$topic" + page.navigate(s"/tenants/$tenant/namespaces/$ns/create-topic") + page.getByTestId("create-topic-name").fill(topic) + page.getByTestId("create-topic-partitioning").selectOption(new SelectOption().setValue("partitioned")) + createButton.click() + // Oracle: partition metadata reports N > 0 partitions. + eventually() { + assert(admin.topics().getPartitionedTopicMetadata(fqn).partitions > 0, + s"expected partitioned, got ${admin.topics().getPartitionedTopicMetadata(fqn).partitions}") + } + } + + test("NS-9: delete a namespace") { + val tenant = fixtures.createTenant() + val ns = fixtures.createNamespace(tenant) + page.navigate(s"/tenants/$tenant/namespaces/$ns/overview") + page.getByTestId("namespace-page-delete-button").click() + ConfirmationDialog(page).confirm(guard = Some(ns), force = true) + page.waitForTimeout(1500) + assert(!admin.namespaces().getNamespaces(tenant).asScala.contains(s"$tenant/$ns")) + } diff --git a/e2e/src/test/scala/routes/NavigationSpec.scala b/e2e/src/test/scala/routes/NavigationSpec.scala new file mode 100644 index 000000000..d5d423cb7 --- /dev/null +++ b/e2e/src/test/scala/routes/NavigationSpec.scala @@ -0,0 +1,36 @@ +package routes + +import harness.DekafSuite +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions +import com.microsoft.playwright.options.AriaRole +import com.microsoft.playwright.Page.GetByRoleOptions +import java.util.regex.Pattern + +class NavigationSpec extends DekafSuite: + + test("NAV-1: root redirects to the instance overview") { + page.navigate("/") + assertThat(page).hasURL(Pattern.compile(".*/overview.*")) + assertThat(page.getByText("Pulsar Instance")).isVisible() + } + + test("NAV-8: ConfirmationDialog Confirm is gated on an exact guard match") { + val tenant = fixtures.createTenant() + page.navigate(s"/tenants/$tenant/overview") + page.getByTestId("tenant-page-delete-button").click() + + val confirm = page.getByTestId("confirmation-dialog-confirm-button") + val guard = page.getByTestId("confirmation-dialog-guard-input") + assertThat(confirm).isDisabled() + guard.fill(s"${tenant}x") // near-miss + assertThat(confirm).isDisabled() + guard.fill(tenant) // exact + assertThat(confirm).isEnabled() + } + + test("NAV-11: deep-linking a nonexistent tenant shows the 404 page") { + page.navigate(s"/tenants/${fixtures.unique("missing")}/overview") + assertThat(page.getByRole(AriaRole.BUTTON, new GetByRoleOptions().setName("Go Home"))) + .isVisible(new LocatorAssertions.IsVisibleOptions().setTimeout(15000)) + } diff --git a/e2e/src/test/scala/routes/NavigationTreeSpec.scala b/e2e/src/test/scala/routes/NavigationTreeSpec.scala new file mode 100644 index 000000000..698abdaf6 --- /dev/null +++ b/e2e/src/test/scala/routes/NavigationTreeSpec.scala @@ -0,0 +1,61 @@ +package routes + +import harness.DekafSuite +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions + +/** NAV-2/3/4 - the lazy-loading navigation tree (filter, expand-to-partitions, collapse/focus). */ +class NavigationTreeSpec extends DekafSuite: + private def vis(ms: Int) = new LocatorAssertions.IsVisibleOptions().setTimeout(ms.toDouble) + private def cnt(ms: Int) = new LocatorAssertions.HasCountOptions().setTimeout(ms.toDouble) + + test("NAV-2: the tree lazy-expands a partitioned topic to its partitions") { + val t = fixtures.createTenant() + val ns = fixtures.createNamespace(t) + val topic = fixtures.unique("ptopic") + admin.topics().createPartitionedTopic(s"persistent://$t/$ns/$topic", 3) + + page.navigate(s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/overview") + val topicNode = page.locator(s"[data-testid=nav-tree-node][data-node-type=topic][data-node-name='$topic']") + assertThat(topicNode).isVisible(vis(20000)) + page.waitForTimeout(1500) // let the tree's scroll-to-selected settle before toggling + + topicNode.getByTestId("nav-node-toggle").click() + assertThat(page.locator("[data-testid=nav-tree-node][data-node-type=topic-partition]")).hasCount(3, cnt(15000)) + } + + test("NAV-3: pasting a topic FQN into the tree filter strips the persistent:// scheme") { + page.navigate("/overview") + val filter = page.getByTestId("nav-tree-filter") + filter.fill("persistent://public/default/some-topic") + // The rewrite strips the scheme to a tenant/namespace/topic path (the app also duplicates the + // trailing topic segment for non-partitioned FQNs - a latent quirk; assert the robust invariant). + assertThat(filter).hasValue(java.util.regex.Pattern.compile("^public/default/some-topic"), + new LocatorAssertions.HasValueOptions().setTimeout(10000)) + } + + test("NAV-4: Collapse All collapses expanded nodes") { + page.navigate("/overview") + val publicTenant = page.locator("[data-testid=nav-tree-node][data-node-type=tenant][data-node-name='public']") + assertThat(publicTenant).isVisible(vis(20000)) + publicTenant.getByTestId("nav-node-toggle").click() + assertThat(page.locator("[data-testid=nav-tree-node][data-node-type=namespace]").first()).isVisible(vis(15000)) + page.getByTestId("nav-collapse-all").click() + assertThat(page.locator("[data-testid=nav-tree-node][data-node-type=namespace]")).hasCount(0, cnt(10000)) + } + + test("NAV-4: Show Current Resource re-expands the tree to the selected resource") { + val t = fixtures.createTenant() + val ns = fixtures.createNamespace(t) + val topic = fixtures.unique("topic") + admin.topics().createNonPartitionedTopic(s"persistent://$t/$ns/$topic") + page.navigate(s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/overview") + val topicNode = page.locator(s"[data-testid=nav-tree-node][data-node-type=topic][data-node-name='$topic']") + assertThat(topicNode).isVisible(vis(20000)) + page.waitForTimeout(1500) + + page.getByTestId("nav-collapse-all").click() + assertThat(topicNode).hasCount(0, cnt(10000)) + page.getByTestId("nav-show-current").click() + assertThat(topicNode).isVisible(vis(20000)) + } diff --git a/e2e/src/test/scala/routes/RenderSpec.scala b/e2e/src/test/scala/routes/RenderSpec.scala new file mode 100644 index 000000000..d6b44eebb --- /dev/null +++ b/e2e/src/test/scala/routes/RenderSpec.scala @@ -0,0 +1,65 @@ +package routes + +import harness.DekafSuite +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import org.apache.pulsar.client.api.MessageId + +/** Navigate-and-render coverage: each page loads the right resource (cross-checked by a + * resource-specific string appearing) rather than 404-ing or hanging. */ +class RenderSpec extends DekafSuite: + + test("INS-1: instance overview shows Pulsar instance info") { + page.navigate("/overview") + assertThat(page.getByText("Pulsar Instance")).isVisible() + assertThat(page.getByText("dekaf-e2e-local").first()).isVisible() + } + + test("INS-2: tenants list shows a created tenant") { + val t = fixtures.createTenant() + page.navigate("/tenants") + assertThat(page.getByText(t).first()).isVisible() + } + + test("TEN-3: namespaces list shows a created namespace") { + val t = fixtures.createTenant() + val ns = fixtures.createNamespace(t) + page.navigate(s"/tenants/$t/namespaces") + assertThat(page.getByText(ns).first()).isVisible() + } + + test("NS-2: topics list shows a created topic") { + val (t, ns, topic) = fixtures.freshTopicParts() + page.navigate(s"/tenants/$t/namespaces/$ns/topics") + assertThat(page.getByText(topic).first()).isVisible() + } + + test("TOP-1: topic overview renders topic metadata") { + val (t, ns, topic) = fixtures.freshTopicParts() + page.navigate(s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/overview") + assertThat(page.getByText("Topic Name").first()).isVisible() + assertThat(page.getByText(topic).first()).isVisible() + } + + test("TOP-6: subscriptions list shows a created subscription") { + val (t, ns, topic) = fixtures.freshTopicParts() + val sub = fixtures.unique("sub") + admin.topics().createSubscription(s"persistent://$t/$ns/$topic", sub, MessageId.earliest) + page.navigate(s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/subscriptions") + assertThat(page.getByText(sub).first()).isVisible() + } + + test("SUB-1: subscription overview renders the subscription") { + val (t, ns, topic) = fixtures.freshTopicParts() + val sub = fixtures.unique("sub") + admin.topics().createSubscription(s"persistent://$t/$ns/$topic", sub, MessageId.earliest) + page.navigate(s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/subscriptions/$sub/overview") + assertThat(page.getByText(sub).first()).isVisible() + } + + test("SUB-2: consumers list page renders") { + val (t, ns, topic) = fixtures.freshTopicParts() + val sub = fixtures.unique("sub") + admin.topics().createSubscription(s"persistent://$t/$ns/$topic", sub, MessageId.earliest) + page.navigate(s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/subscriptions/$sub/consumers") + assertThat(page.getByText(sub).first()).isVisible() + } diff --git a/e2e/src/test/scala/routes/ResilienceSpec.scala b/e2e/src/test/scala/routes/ResilienceSpec.scala new file mode 100644 index 000000000..121d2fde9 --- /dev/null +++ b/e2e/src/test/scala/routes/ResilienceSpec.scala @@ -0,0 +1,50 @@ +package routes + +import harness.DekafSuite +import features.consumersession.ConsumerSessionPage +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions +import com.microsoft.playwright.options.AriaRole +import com.microsoft.playwright.Page.GetByRoleOptions + +/** RES-1/2/3 - negative / resilience: bogus routes 404, a bad saved-session id degrades gracefully, + * and the unguarded non-persistent details route is documented. */ +class ResilienceSpec extends DekafSuite: + private def goHome = page.getByRole(AriaRole.BUTTON, new GetByRoleOptions().setName("Go Home")) + + test("RES-1: bogus deep-links under each resource family render the 404 page") { + val bogus = List( + "/instance/this-is-not-a-real-page", + "/tenants/nope-xyz/this-is-not-a-tab", + "/tenants/nope-xyz/namespaces/nope/this-is-not-a-tab", + "/tenants/nope-xyz/namespaces/nope/topics/persistent/nope/this-is-not-a-tab" + ) + for path <- bogus do + page.navigate(path) + assertThat(goHome).isVisible(new LocatorAssertions.IsVisibleOptions().setTimeout(15000)) + } + + test("RES-2: opening a consumer session with a bad saved-session id degrades gracefully") { + val (t, ns, topic) = fixtures.freshTopicParts() + // A non-existent managed session id must not crash the page - it falls back to a fresh session. + page.navigate(s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/consumer-session?id=does-not-exist-xyz") + val cs = ConsumerSessionPage(page) + // The toolbar still renders (no ErrorBoundary blank / crash) - that is the graceful degradation. + assertThat(cs.playButton).isVisible(new LocatorAssertions.IsVisibleOptions().setTimeout(15000)) + } + + test("RES-3: the unguarded non-persistent /details route renders without a blank crash") { + val t = fixtures.createTenant() + val ns = fixtures.createNamespace(t) + val topic = fixtures.unique("nptopic") + admin.topics().createNonPartitionedTopic(s"non-persistent://$t/$ns/$topic") + + page.navigate(s"/tenants/$t/namespaces/$ns/topics/non-persistent/$topic/details") + // Chrome (breadcrumbs) is not enough - it renders even if the Details BODY crashed to blank. Assert + // a Details-body-specific anchor: either the policy editor (is-global toggle / a category tab) or + // the "policies disabled" message. One of them proves the body rendered content, not a blank crash. + assertThat(page.getByTestId("breadcrumbs")).isVisible(new LocatorAssertions.IsVisibleOptions().setTimeout(15000)) + assertThat( + page.getByText(java.util.regex.Pattern.compile("Delayed delivery|Topic level policies are not enabled", java.util.regex.Pattern.CASE_INSENSITIVE)).first() + ).isVisible(new LocatorAssertions.IsVisibleOptions().setTimeout(15000)) + } diff --git a/e2e/src/test/scala/routes/SchemaSpec.scala b/e2e/src/test/scala/routes/SchemaSpec.scala new file mode 100644 index 000000000..ba1f02600 --- /dev/null +++ b/e2e/src/test/scala/routes/SchemaSpec.scala @@ -0,0 +1,113 @@ +package routes + +import harness.DekafSuite +import com.microsoft.playwright.Page.GetByRoleOptions +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions +import com.microsoft.playwright.options.{AriaRole, SelectOption} +import org.apache.pulsar.common.schema.{SchemaInfo, SchemaType} +import org.apache.pulsar.common.policies.data.SchemaCompatibilityStrategy +import java.nio.file.Files +import java.util.regex.Pattern +import scala.jdk.CollectionConverters.* + +class SchemaSpec extends DekafSuite: + + private def visible(ms: Int) = new LocatorAssertions.IsVisibleOptions().setTimeout(ms) + private def exactText(s: String) = new com.microsoft.playwright.Page.GetByTextOptions().setExact(true) + + private def eventually(timeoutMs: Long = 15000, stepMs: Long = 300)(p: => Boolean): Boolean = { + val deadline = System.currentTimeMillis() + timeoutMs + var ok = false + while (!ok && System.currentTimeMillis() < deadline) { + ok = try p catch { case _: Throwable => false } + if (!ok) Thread.sleep(stepMs) + } + ok + } + + private def createUrl(t: String, ns: String, topic: String) = + s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/schema/create" + private def createButton = + page.getByRole(AriaRole.BUTTON, new GetByRoleOptions().setName("Create").setExact(true)) + + test("SCH-2: create an AVRO schema through the UI with live compatibility feedback") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + page.navigate(createUrl(t, ns, topic)) + + page.getByTestId("schema-type-select").selectOption(new SelectOption().setLabel("AVRO")) + + // The default AVRO body is valid -> live "Compatible" verdict (see NOTES re version coupling). + assertThat(page.getByText("Compatible", exactText("Compatible"))).isVisible(visible(20000)) + assertThat(createButton).isEnabled() + createButton.click() + + assert(eventually() { admin.schemas().getSchemaInfo(fqn).getType == SchemaType.AVRO }) + } + + test("SCH-3: uploading an .avsc file populates the preview") { + val (t, ns, topic) = fixtures.freshTopicParts() + page.navigate(createUrl(t, ns, topic)) + + page.getByTestId("schema-type-select").selectOption(new SelectOption().setLabel("AVRO")) + page.getByTestId("avro-source-select").selectOption(new SelectOption().setLabel("Single .avsc file")) + + val avsc = """{"type":"record","name":"UploadedSchema","namespace":"e2e","fields":[{"name":"x","type":"string"}]}""" + val tmp = Files.createTempFile("sch3-", ".avsc") + Files.writeString(tmp, avsc) + fixtures.onCleanup(() => { Files.deleteIfExists(tmp); () }) + + page.locator("input[type='file']").setInputFiles(tmp) + + assertThat(page.getByText("UploadedSchema").first()).isVisible(visible(15000)) + } + + test("SCH-4: create a PROTOBUF_NATIVE schema via the code compile path") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + page.navigate(createUrl(t, ns, topic)) + + page.getByTestId("schema-type-select").selectOption(new SelectOption().setLabel("PROTOBUF_NATIVE")) + // Default proto is prefilled in the code editor; compile it. + page.getByRole(AriaRole.BUTTON, new GetByRoleOptions().setName("Upload").setExact(true)).click() + + assertThat(page.getByText("Compatible", exactText("Compatible"))).isVisible(visible(25000)) + assertThat(createButton).isEnabled() + createButton.click() + + assert(eventually() { admin.schemas().getSchemaInfo(fqn).getType == SchemaType.PROTOBUF_NATIVE }) + } + + test("SCH-5: KEY_VALUE schema type renders a stub without crashing") { + val (t, ns, topic) = fixtures.freshTopicParts() + page.navigate(createUrl(t, ns, topic)) + + page.getByTestId("schema-type-select").selectOption(new SelectOption().setLabel("KEY_VALUE")) + + assertThat(page.getByText(Pattern.compile("Support of KEY_VALUE schema type is coming"))).isVisible(visible(15000)) + assertThat(createButton).isDisabled() + } + + test("SCH-6: view a non-latest schema version") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + + // Permit the evolution, then arrange two AVRO versions (0 = alpha, 1 = alpha+bravo). + admin.namespaces().setSchemaCompatibilityStrategy(s"$t/$ns", SchemaCompatibilityStrategy.ALWAYS_COMPATIBLE) + val avsc0 = """{"type":"record","name":"Sch6","namespace":"e2e","fields":[{"name":"alpha","type":"string"}]}""" + val avsc1 = """{"type":"record","name":"Sch6","namespace":"e2e","fields":[{"name":"alpha","type":"string"},{"name":"bravo","type":["null","string"],"default":null}]}""" + admin.schemas().createSchema(fqn, SchemaInfo.builder().name(topic).`type`(SchemaType.AVRO).schema(avsc0.getBytes("UTF-8")).build()) + admin.schemas().createSchema(fqn, SchemaInfo.builder().name(topic).`type`(SchemaType.AVRO).schema(avsc1.getBytes("UTF-8")).build()) + + // Landing on /schema redirects to the latest version; then select version 0 in the list. + page.navigate(s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/schema") + val v0 = page.getByTestId("schema-version-0") + assertThat(v0).isVisible(visible(20000)) + v0.click() + + assertThat(page).hasURL(Pattern.compile(".*/schema/view/0.*")) + val view = page.getByTestId("schema-view") + assertThat(view).containsText("alpha") + assertThat(view).not().containsText("bravo") + } diff --git a/e2e/src/test/scala/routes/StatsRenderSpec.scala b/e2e/src/test/scala/routes/StatsRenderSpec.scala new file mode 100644 index 000000000..494bfb063 --- /dev/null +++ b/e2e/src/test/scala/routes/StatsRenderSpec.scala @@ -0,0 +1,65 @@ +package routes + +import harness.DekafSuite +import com.microsoft.playwright.Page +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions +import com.microsoft.playwright.options.AriaRole +import com.microsoft.playwright.Page.{GetByRoleOptions, GetByTextOptions} +import org.apache.pulsar.client.api.MessageId + +/** Render-smoke for read-only views that were previously uncovered. They carry no mutation, so a + * defect surfaces as a blank page / crashed ErrorBoundary rather than a wrong value - assert the + * view's stable anchor renders (heading / known label / testId), no 404, no blank. */ +class StatsRenderSpec extends DekafSuite: + private def vis(ms: Int) = new LocatorAssertions.IsVisibleOptions().setTimeout(ms.toDouble) + private def heading(name: String) = + page.getByRole(AriaRole.HEADING, new GetByRoleOptions().setName(name).setExact(true)) + private def exactText(s: String) = page.getByText(s, new GetByTextOptions().setExact(true)) + + test("INS-9: instance overview renders the Clusters section with cluster detail") { + page.navigate("/overview") + assertThat(heading("Clusters")).isVisible(vis(15000)) + // "Cluster Name" only renders once the cluster row's data actually loaded (Cluster returns null + // until then), and the value shown must be the broker's real cluster name per the admin oracle. + assertThat(page.getByText("Cluster Name").first()).isVisible(vis(10000)) + assertThat(exactText(fixtures.firstCluster).first()).isVisible(vis(10000)) + } + + test("INS-10: instance overview renders the Internal Broker Configuration table") { + page.navigate("/overview") + assertThat(heading("Internal Broker Configuration")).isVisible(vis(15000)) + } + + test("TOP-11: topic Statistics tab renders topic stats") { + val (t, ns, topic) = fixtures.freshTopicParts() + fixtures.produceStrings(s"persistent://$t/$ns/$topic", 3) + page.navigate(s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/overview") + exactText("Statistics").first().click() + // Stats-panel-ONLY labels - "Producers"/"Subscriptions" also exist as toolbar nav buttons on + // every topic route, so they can't prove the panel rendered. + assertThat(exactText("Msg Rate In").first()).isVisible(vis(15000)) + assertThat(exactText("Storage Size").first()).isVisible(vis(10000)) + } + + test("TOP-12: topic Internal Statistics tab renders the managed-ledger stats") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + admin.topics().createSubscription(fqn, fixtures.unique("sub"), MessageId.earliest) + fixtures.produceStrings(fqn, 3) + page.navigate(s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/overview") + exactText("Internal Statistics").first().click() + assertThat(heading("Managed Ledger Internal Stats")).isVisible(vis(15000)) + assertThat(heading("Ledgers")).isVisible(vis(10000)) + assertThat(heading("Cursors")).isVisible(vis(10000)) + } + + test("SUB-7: subscription overview renders the Statistics section") { + val (t, ns, topic) = fixtures.freshTopicParts() + val sub = fixtures.unique("sub") + admin.topics().createSubscription(s"persistent://$t/$ns/$topic", sub, MessageId.earliest) + page.navigate(s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/subscriptions/$sub/overview") + // the subscription must render and its Statistics panel with it (a stat row that is always present) + assertThat(page.getByText(sub).first()).isVisible(vis(15000)) + assertThat(page.getByText("Msg Rate Out").first()).isVisible(vis(10000)) + } diff --git a/e2e/src/test/scala/routes/SubscriptionActionSpec.scala b/e2e/src/test/scala/routes/SubscriptionActionSpec.scala new file mode 100644 index 000000000..9c1fa5f39 --- /dev/null +++ b/e2e/src/test/scala/routes/SubscriptionActionSpec.scala @@ -0,0 +1,171 @@ +package routes + +import harness.DekafSuite +import ui.ConfirmationDialog +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.options.SelectOption +import org.apache.pulsar.client.api.{MessageId, Schema} +import scala.jdk.CollectionConverters.* + +/** SUB-3 (expire), SUB-4 (skip), SUB-5 (reset cursor). Every mutation is driven + * through the Overview action modals and cross-checked via PulsarAdmin. */ +class SubscriptionActionSpec extends DekafSuite: + + // --- helpers ------------------------------------------------------------- + + /** Produce `n` NON-batched string messages (1 msg == 1 entry => deterministic + * backlog counts and single-message ids), returning their MessageIds in order. */ + private def produceCapturing(fqn: String, n: Int): Seq[MessageId] = + val p = client.newProducer(Schema.STRING).topic(fqn).enableBatching(false).create() + try (1 to n).map(i => p.send(s"msg-$i")) + finally p.close() + + /** Serialized MessageId as the whitespace-tolerant hex the modal inputs expect + * (hexStringToByteArray -> MessageId.fromByteArray on the server). */ + private def hexOf(id: MessageId): String = + id.toByteArray.map(b => f"${b & 0xff}%02x").mkString + + private def backlogOf(fqn: String, sub: String): Long = + val subs = admin.topics().getStats(fqn).getSubscriptions + if subs.containsKey(sub) then subs.get(sub).getMsgBacklog else -1L + + /** Poll the admin backlog until `pred` holds (never a fixed sleep). */ + private def awaitBacklog(fqn: String, sub: String, pred: Long => Boolean, timeoutMs: Long = 15000): Long = + val deadline = System.currentTimeMillis() + timeoutMs + var b = backlogOf(fqn, sub) + while !pred(b) && System.currentTimeMillis() < deadline do + Thread.sleep(200); b = backlogOf(fqn, sub) + b + + private def overviewUrl(t: String, ns: String, topic: String, sub: String): String = + s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/subscriptions/$sub/overview" + + // --- SUB-3: expire messages --------------------------------------------- + + test("SUB-3: expire messages by message ID clears the backlog (non-partitioned)") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + val ids = produceCapturing(fqn, 3) + val sub = fixtures.unique("sub") + admin.topics().createSubscription(fqn, sub, MessageId.earliest) + assert(awaitBacklog(fqn, sub, _ == 3L) == 3L) + + page.navigate(overviewUrl(t, ns, topic, sub)) + page.getByTestId("expire-subscription-messages-button").click() + // Default target is expire-by-message-id; expire up to & including the last id. + page.getByTestId("expire-message-id-input").fill(hexOf(ids.last)) + ConfirmationDialog(page).confirm(guard = Some("CONFIRM")) + + assertThat(page.getByText("Messages were successfully expired")).isVisible() + assert(awaitBacklog(fqn, sub, _ == 0L) == 0L) + } + + test("SUB-3: expire messages older than a duration runs without error (non-partitioned)") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + produceCapturing(fqn, 3) + val sub = fixtures.unique("sub") + admin.topics().createSubscription(fqn, sub, MessageId.earliest) + + page.navigate(overviewUrl(t, ns, topic, sub)) + page.getByTestId("expire-subscription-messages-button").click() + page.getByTestId("expire-target-select").selectOption(new SelectOption().setValue("expire-time-in-seconds")) + page.getByTestId("expire-duration").locator("input").first().fill("5") // any >0 enables Confirm + ConfirmationDialog(page).confirm(guard = Some("CONFIRM")) + + // Oracle for the time-based path: the action runs without error. + // (Deterministic backlog effect is asserted on the by-ID leg above - see NOTES.) + assertThat(page.getByText("Messages were successfully expired")).isVisible() + assert(admin.topics().getSubscriptions(fqn).asScala.contains(sub)) // admin cross-check: sub intact + } + + test("SUB-3: expire by message ID is disabled for a partitioned topic") { + val t = fixtures.createTenant() + val ns = fixtures.createNamespace(t) + val topic = fixtures.unique("ptopic") + val fqn = s"persistent://$t/$ns/$topic" + admin.topics().createPartitionedTopic(fqn, 3) + val sub = fixtures.unique("sub") + admin.topics().createSubscription(fqn, sub, MessageId.earliest) + + page.navigate(overviewUrl(t, ns, topic, sub)) + page.getByTestId("expire-subscription-messages-button").click() + // Default target is by-message-id; on a partitioned topic the modal blocks it. + assertThat(page.getByText("Expire by message ID is not supported for partitioned topics.")).isVisible() + assertThat(page.getByTestId("confirmation-dialog-confirm-button")).isDisabled() + } + + // --- SUB-4: skip messages ----------------------------------------------- + + test("SUB-4: skip an exact number of messages drops the backlog to zero") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + produceCapturing(fqn, 3) + val sub = fixtures.unique("sub") + admin.topics().createSubscription(fqn, sub, MessageId.earliest) + assert(awaitBacklog(fqn, sub, _ == 3L) == 3L) + + page.navigate(overviewUrl(t, ns, topic, sub)) + page.getByTestId("skip-subscription-messages-button").click() + page.getByTestId("skip-number-input").fill("3") // default target = skip-exact + ConfirmationDialog(page).confirm(guard = Some("CONFIRM")) + + assertThat(page.getByText("Messages were successfully skipped.")).isVisible() + assert(awaitBacklog(fqn, sub, _ == 0L) == 0L) + } + + test("SUB-4: skip all messages clears the backlog") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + produceCapturing(fqn, 5) + val sub = fixtures.unique("sub") + admin.topics().createSubscription(fqn, sub, MessageId.earliest) + assert(awaitBacklog(fqn, sub, _ == 5L) == 5L) + + page.navigate(overviewUrl(t, ns, topic, sub)) + page.getByTestId("skip-subscription-messages-button").click() + page.getByTestId("skip-target-select").selectOption(new SelectOption().setValue("skip-all-messages")) + ConfirmationDialog(page).confirm(guard = Some("CONFIRM")) + + assertThat(page.getByText("Messages were successfully skipped.")).isVisible() + assert(awaitBacklog(fqn, sub, _ == 0L) == 0L) + } + + // --- SUB-5: reset cursor ------------------------------------------------- + + test("SUB-5: reset cursor by message ID moves the backlog") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + val ids = produceCapturing(fqn, 3) + val sub = fixtures.unique("sub") + admin.topics().createSubscription(fqn, sub, MessageId.latest) // cursor at end => backlog 0 + assert(awaitBacklog(fqn, sub, _ == 0L) == 0L) + + page.navigate(overviewUrl(t, ns, topic, sub)) + page.getByTestId("reset-subscription-cursor-button").click() + // Default target = by-message-id; reset back to the first message. + page.getByTestId("reset-message-id-input").fill(hexOf(ids.head)) + ConfirmationDialog(page).confirm(guard = Some("CONFIRM")) + + assertThat(page.getByText("Cursor was successfully reset")).isVisible() + assert(awaitBacklog(fqn, sub, _ > 0L) > 0L) // cursor moved back => backlog grew + } + + test("SUB-5: reset cursor to a timestamp moves the backlog") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + val t0 = System.currentTimeMillis() // just before the messages exist + produceCapturing(fqn, 3) + val sub = fixtures.unique("sub") + admin.topics().createSubscription(fqn, sub, MessageId.latest) // backlog 0 + assert(awaitBacklog(fqn, sub, _ == 0L) == 0L) + + page.navigate(overviewUrl(t, ns, topic, sub)) + page.getByTestId("reset-subscription-cursor-button").click() + page.getByTestId("reset-target-select").selectOption(new SelectOption().setValue("reset-cursor-to-timestamp")) + page.getByTestId("reset-timestamp-input").fill(t0.toString) + ConfirmationDialog(page).confirm(guard = Some("CONFIRM")) + + assertThat(page.getByText("Cursor was successfully reset")).isVisible() + assert(awaitBacklog(fqn, sub, _ > 0L) > 0L) + } diff --git a/e2e/src/test/scala/routes/SubscriptionSpec.scala b/e2e/src/test/scala/routes/SubscriptionSpec.scala new file mode 100644 index 000000000..2310b98fe --- /dev/null +++ b/e2e/src/test/scala/routes/SubscriptionSpec.scala @@ -0,0 +1,22 @@ +package routes + +import harness.DekafSuite +import ui.ConfirmationDialog +import org.apache.pulsar.client.api.MessageId +import scala.jdk.CollectionConverters.* + +class SubscriptionSpec extends DekafSuite: + + test("SUB-6: delete a subscription (guard = topic FQN)") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + val sub = fixtures.unique("sub") + admin.topics().createSubscription(fqn, sub, MessageId.earliest) + + page.navigate(s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/subscriptions/$sub/overview") + page.getByTestId("subscription-page-delete-button").click() + ConfirmationDialog(page).confirm(guard = Some(fqn), force = true) + page.waitForTimeout(1500) + + assert(!admin.topics().getSubscriptions(fqn).asScala.contains(sub)) + } diff --git a/e2e/src/test/scala/routes/TableSpec.scala b/e2e/src/test/scala/routes/TableSpec.scala new file mode 100644 index 000000000..a6b5ba9fa --- /dev/null +++ b/e2e/src/test/scala/routes/TableSpec.scala @@ -0,0 +1,120 @@ +package routes + +import harness.DekafSuite +import harness.Eventually.eventually +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions +import scala.jdk.CollectionConverters.* + +/** NAV-6 - the shared Table primitive: counter, refresh, sort, filter chip, resize-persist. + * Exercised through the tenants list (`/tenants`, tableId `tenants-table`). */ +class TableSpec extends DekafSuite: + private def vis(ms: Int) = new LocatorAssertions.IsVisibleOptions().setTimeout(ms.toDouble) + private def cnt(ms: Int) = new LocatorAssertions.HasCountOptions().setTimeout(ms.toDouble) + + private def openTenantsTable(): Unit = + fixtures.createTenant(); fixtures.createTenant(); fixtures.createTenant() + page.navigate("/tenants") + assertThat(page.getByTestId("table-counter")).isVisible(vis(15000)) + + /** The rendered tenant names, in row order (the sticky first column of each row). */ + private def visibleTenantNames(): List[String] = + page.locator("tbody tr td:nth-child(1)").allInnerTexts().asScala.toList.map(_.trim).filter(_.nonEmpty) + + /** The persisted width of a tenants-table column, or -1 when unset. */ + private def storedWidth(col: String): Double = + page.evaluate( + s"""() => { const w = JSON.parse(localStorage.getItem('table:tenants-table:column-widths') || '{}'); return (w['$col'] ?? -1); }""" + ) match { case n: Number => n.doubleValue(); case _ => -1.0 } + + test("NAV-6: the table counter reflects the data and Refresh actually re-loads it") { + openTenantsTable() + assertThat(page.getByTestId("table-counter")).containsText("of") + + // Turn OFF auto-refresh first - otherwise SWR's periodic poll (enabled by default) would bring in + // the new tenant on its own and a dead Refresh button would pass anyway. + val autoRefresh = page.getByTestId("table-auto-refresh") + if autoRefresh.getAttribute("data-checked") == "true" then autoRefresh.click() + assertThat(autoRefresh).hasAttribute("data-checked", "false") + + // Change server state behind the table's back. It must stay absent past a refresh interval + // (proving the poll is really off), then appear ONLY after the explicit Refresh click. + val added = fixtures.createTenant() + page.waitForTimeout(6000) + assertThat(page.getByText(added)).hasCount(0, cnt(2000)) + page.getByTestId("table-refresh").click() + assertThat(page.getByText(added).first()).isVisible(vis(15000)) + } + + test("NAV-6: the active filter chip filters the rows and can be removed") { + openTenantsTable() + // The tenants table ships `defaultFiltersInUse` with tenantName ACTIVE, so the chip is present + // from the start (and that is also why the header's filter icon is hidden: `!isColumnFiltered`). + val chip = page.locator("[data-testid=table-filter-chip][data-column-key=tenantName]") + assertThat(chip).isVisible(vis(15000)) + + val all = visibleTenantNames() + assert(all.contains("public"), s"expected the seeded 'public' tenant among: $all") + + // Typing into the chip filters the rows (client-side, debounced). + chip.getByTestId("table-filter-chip-value").fill("public") + eventually() { + val shown = visibleTenantNames() + assert(shown.nonEmpty && shown.forall(_.contains("public")), s"filter not applied, rows: $shown") + } + + // Removing the chip restores the unfiltered rows. + chip.getByTestId("table-filter-chip-remove").click() + assertThat(chip).hasCount(0, cnt(5000)) + eventually() { + assert(visibleTenantNames().size == all.size, s"rows not restored after removing the filter") + } + } + + test("NAV-6: clicking a sortable header toggles the direction AND re-orders the rows") { + openTenantsTable() + val nameTh = page.locator("[data-testid=table-th][data-column-key=tenantName]") + assertThat(nameTh).hasAttribute("data-sort-direction", "asc") + + // Ascending: the visible names are actually in ascending order. + val asc = visibleTenantNames() + assert(asc == asc.sorted, s"rows not ascending: $asc") + + nameTh.click() + assertThat(nameTh).hasAttribute("data-sort-direction", "desc") + // Descending: the order really flipped (not just the attribute). + val desc = visibleTenantNames() + assert(desc == desc.sorted.reverse, s"rows not descending: $desc") + assert(desc.head != asc.head, s"first row unchanged after toggling sort: ${asc.head}") + } + + test("NAV-6: a resized column width persists across reload and is re-applied to the DOM") { + openTenantsTable() + val nameTh = page.locator("[data-testid=table-th][data-column-key=tenantName]") + val handle = nameTh.locator("[data-testid=table-resize-handle]") + val box = handle.boundingBox() + assert(box != null, "resize handle has no bounding box") + // Baseline the LIVE rendered width BEFORE the drag, so we can prove the drag actually applied to + // the DOM (an impl that only writes localStorage would leave both widths at the default default). + val domBaseline = nameTh.boundingBox().width + val cx = box.x + box.width / 2 + val cy = box.y + box.height / 2 + page.mouse().move(cx, cy) + page.mouse().down() + page.mouse().move(cx + 90, cy) + page.mouse().up() + + val stored = storedWidth("tenantName") // default is 300; the drag widens it + assert(stored > 300, s"width not persisted after resize: $stored") + val domAfterResize = nameTh.boundingBox().width + assert(domAfterResize > domBaseline + 20, + s"resize was not applied to the live DOM: ${domBaseline}px -> ${domAfterResize}px") + + page.reload() + assertThat(page.getByTestId("table-counter")).isVisible(vis(15000)) + // Storage kept it AND the restored column renders at the widened width, not the default. + assert(storedWidth("tenantName") == stored, s"stored width not stable across reload: $stored -> ${storedWidth("tenantName")}") + val domAfterReload = nameTh.boundingBox().width + assert(math.abs(domAfterReload - domAfterResize) <= 3, + s"restored column rendered at ${domAfterReload}px, expected ~${domAfterResize}px (persisted width not re-applied)") + } diff --git a/e2e/src/test/scala/routes/TenantOverviewSpec.scala b/e2e/src/test/scala/routes/TenantOverviewSpec.scala new file mode 100644 index 000000000..7ff60f62b --- /dev/null +++ b/e2e/src/test/scala/routes/TenantOverviewSpec.scala @@ -0,0 +1,50 @@ +package routes + +import harness.DekafSuite +import harness.Eventually.eventually +import com.microsoft.playwright.Locator +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions +import com.microsoft.playwright.options.AriaRole +import scala.jdk.CollectionConverters.* + +/** TEN-1/TEN-2 - the tenant overview's admin-roles / allowed-clusters editors (immediate update). */ +class TenantOverviewSpec extends DekafSuite: + private def vis(ms: Int) = new LocatorAssertions.IsVisibleOptions().setTimeout(ms.toDouble) + private def cnt(ms: Int) = new LocatorAssertions.HasCountOptions().setTimeout(ms.toDouble) + + test("TEN-1: adding then removing an admin role updates the tenant immediately") { + val tenant = fixtures.createTenant() + val role = fixtures.unique("role") + page.navigate(s"/tenants/$tenant/overview") + + val rolesInput = page.getByTestId("tenant-admin-roles") + assertThat(rolesInput).isVisible(vis(15000)) + + // Add - the editor is inline; type then click Add. + rolesInput.getByPlaceholder("Enter new role").fill(role) + rolesInput.getByRole(AriaRole.BUTTON, new Locator.GetByRoleOptions().setName("Add").setExact(true)).click() + eventually() { + assert(admin.tenants().getTenantInfo(tenant).getAdminRoles.asScala.contains(role), + s"roles were: ${admin.tenants().getTenantInfo(tenant).getAdminRoles}") + } + assertThat(rolesInput.locator(s"[data-testid=list-item][data-item-id='$role']")).isVisible(vis(10000)) + + // Remove (immediate). + rolesInput.locator(s"[data-testid=list-item][data-item-id='$role']").getByTestId("list-item-remove").click() + eventually() { + assert(!admin.tenants().getTenantInfo(tenant).getAdminRoles.asScala.contains(role), + s"roles still: ${admin.tenants().getTenantInfo(tenant).getAdminRoles}") + } + } + + test("TEN-2: the last allowed cluster cannot be removed") { + val tenant = fixtures.createTenant() + page.navigate(s"/tenants/$tenant/overview") + + val clustersInput = page.getByTestId("tenant-allowed-clusters") + assertThat(clustersInput).isVisible(vis(15000)) + assertThat(clustersInput.locator("[data-testid=list-item]")).hasCount(1, cnt(10000)) + // The single (last) cluster has no remove control. + assertThat(clustersInput.getByTestId("list-item-remove")).hasCount(0, cnt(5000)) + } diff --git a/e2e/src/test/scala/routes/TenantSpec.scala b/e2e/src/test/scala/routes/TenantSpec.scala new file mode 100644 index 000000000..ec30d1e09 --- /dev/null +++ b/e2e/src/test/scala/routes/TenantSpec.scala @@ -0,0 +1,29 @@ +package routes + +import harness.DekafSuite +import ui.ConfirmationDialog +import com.microsoft.playwright.options.AriaRole +import com.microsoft.playwright.Page.GetByRoleOptions +import scala.jdk.CollectionConverters.* + +class TenantSpec extends DekafSuite: + private def createButton = page.getByRole(AriaRole.BUTTON, new GetByRoleOptions().setName("Create").setExact(true)) + + test("TEN-4: create a namespace under a tenant") { + val tenant = fixtures.createTenant() + val ns = fixtures.unique("ns") + page.navigate(s"/tenants/$tenant/create-namespace") + page.getByTestId("create-namespace-name").fill(ns) + createButton.click() + page.waitForTimeout(1000) + assert(admin.namespaces().getNamespaces(tenant).asScala.contains(s"$tenant/$ns")) + } + + test("TEN-5: delete a tenant") { + val tenant = fixtures.createTenant() + page.navigate(s"/tenants/$tenant/overview") + page.getByTestId("tenant-page-delete-button").click() + ConfirmationDialog(page).confirm(guard = Some(tenant), force = true) + page.waitForTimeout(1500) + assert(!admin.tenants().getTenants.asScala.contains(tenant)) + } diff --git a/e2e/src/test/scala/routes/TopicActionsSpec.scala b/e2e/src/test/scala/routes/TopicActionsSpec.scala new file mode 100644 index 000000000..6fdd6a037 --- /dev/null +++ b/e2e/src/test/scala/routes/TopicActionsSpec.scala @@ -0,0 +1,122 @@ +package routes + +import harness.DekafSuite +import ui.ConfirmationDialog +import com.microsoft.playwright.Page.GetByRoleOptions +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions +import com.microsoft.playwright.options.AriaRole +import org.apache.pulsar.client.api.Schema +import java.util.regex.Pattern +import scala.jdk.CollectionConverters.* + +class TopicActionsSpec extends DekafSuite: + + private def visible(ms: Int) = new LocatorAssertions.IsVisibleOptions().setTimeout(ms) + + /** Poll an admin oracle instead of a fixed sleep. */ + private def eventually(timeoutMs: Long = 15000, stepMs: Long = 300)(p: => Boolean): Boolean = { + val deadline = System.currentTimeMillis() + timeoutMs + var ok = false + while (!ok && System.currentTimeMillis() < deadline) { + ok = try p catch { case _: Throwable => false } + if (!ok) Thread.sleep(stepMs) + } + ok + } + + private def partitionedTopic(count: Int): (String, String, String, String) = { + val t = fixtures.createTenant() + val ns = fixtures.createNamespace(t) + val topic = fixtures.unique("ptopic") + val fqn = s"persistent://$t/$ns/$topic" + admin.topics().createPartitionedTopic(fqn, count) + (t, ns, topic, fqn) + } + + test("TOP-2a: update a partitioned topic's partition count") { + val (t, ns, topic, fqn) = partitionedTopic(2) + page.navigate(s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/overview") + + val updateBtn = page.getByTestId("topic-update-partitions-button") + assertThat(updateBtn).isVisible(visible(20000)) + updateBtn.click() + + page.getByRole(AriaRole.SPINBUTTON).fill("4") + page.getByRole(AriaRole.BUTTON, new GetByRoleOptions().setName("Confirm").setExact(true)).click() + + assert(eventually() { admin.topics().getPartitionedTopicMetadata(fqn).partitions == 4 }) + } + + test("TOP-2b: view a partitioned topic's partitions") { + val (t, ns, topic, _) = partitionedTopic(2) + page.navigate(s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/overview") + + val viewBtn = page.getByTestId("topic-view-partitions-button") + assertThat(viewBtn).isVisible(visible(20000)) + viewBtn.click() + + // Opens the "Topic Partitions" modal (a partition-filtered Topics list). + assertThat(page.getByText("Topic Partitions").first()).isVisible(visible(15000)) + } + + test("TOP-2c: create missed partitions") { + val (t, ns, topic, fqn) = partitionedTopic(2) + // Force activePartitionsCount < partitionsCount (see topicInternalStats.scala:56-58): + // remove one materialized partition topic if present (no-op if partitions are lazy). + try admin.topics().delete(s"persistent://$t/$ns/$topic-partition-1", true) catch { case _: Throwable => () } + + page.navigate(s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/overview") + val createMissed = page.getByRole(AriaRole.BUTTON, + new GetByRoleOptions().setName("Create missed partitions").setExact(true)) + assertThat(createMissed).isVisible(visible(20000)) + createMissed.click() + + assert(eventually() { + admin.topics().getList(s"$t/$ns").asScala.exists(_.contains(s"$topic-partition-1")) + }) + } + + test("TOP-3: expire messages on all subscriptions") { + val (t, ns, topic) = fixtures.freshTopicParts() + page.navigate(s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/overview") + + page.getByTestId("expire-topic-messages-button").click() + + // Confirm stays disabled until duration > 0 (ExpireAllSubscriptions.tsx:93). + page.getByRole(AriaRole.SPINBUTTON).fill("1") + // No force checkbox on this dialog - force must stay false. + ConfirmationDialog(page).confirm(guard = Some("CONFIRM")) + + // Empty-backlog expire is a server no-op → assert the success toast + no error toast. + assertThat(page.getByText("Messages were successfully expired")).isVisible(visible(15000)) + } + + test("TOP-4: unload a topic") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + page.navigate(s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/overview") + + page.getByTestId("topic-page-unload-button").click() + ConfirmationDialog(page).confirm(guard = Some(fqn)) // guard = topic FQN, no force + + // Unload is transient: assert success toast + topic still present. + assertThat(page.getByText(Pattern.compile("has been successfully unloaded"))).isVisible(visible(15000)) + assert(admin.topics().getList(s"$t/$ns").asScala.exists(_.contains(topic))) + } + + test("TOP-5: producers list shows an attached producer") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + val pname = fixtures.unique("producer") + val producer = client.newProducer(Schema.STRING).topic(fqn).producerName(pname).create() + try { + page.navigate(s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/producers") + assertThat(page.getByText(pname).first()).isVisible(visible(20000)) + assert(eventually() { + admin.topics().getStats(fqn).getPublishers.asScala.exists(_.getProducerName == pname) + }) + } finally producer.close() + // NOTE: the topic page has no consumers tab; consumers render under the subscription + // consumers page (SubscriptionPage/**, SUB-2) - outside this subtree. See NOTES. + } diff --git a/e2e/src/test/scala/routes/TopicPolicyBreadthSpec.scala b/e2e/src/test/scala/routes/TopicPolicyBreadthSpec.scala new file mode 100644 index 000000000..354c48b9c --- /dev/null +++ b/e2e/src/test/scala/routes/TopicPolicyBreadthSpec.scala @@ -0,0 +1,57 @@ +package routes + +import harness.DekafSuite +import harness.Eventually.eventually +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions +import com.microsoft.playwright.options.{AriaRole, SelectOption} +import com.microsoft.playwright.Page.GetByRoleOptions +import org.apache.pulsar.client.admin.LongRunningProcessStatus +import scala.jdk.CollectionConverters.* + +/** Topic Details breadth beyond TOP-9 (which proves the delayed-delivery save): a second topic + * policy field, and the Topic Compaction tab's trigger action (unique - a broker-side operation, + * not a policy write). Both gated on `topicLevelPoliciesEnabled` (dev-stack default = on). */ +class TopicPolicyBreadthSpec extends DekafSuite: + private def vis(ms: Int) = new LocatorAssertions.IsVisibleOptions().setTimeout(ms.toDouble) + private def saveButton = + page.getByRole(AriaRole.BUTTON, new GetByRoleOptions().setName("Save").setExact(true)) + private def policiesEnabled: Boolean = + admin.brokers().getRuntimeConfigurations().asScala.get("topicLevelPoliciesEnabled").contains("true") + + test("TOP-13: Topic Limits tab persists a specified max-consumers") { + assume(policiesEnabled, "topic-level policies are DISABLED on this broker") + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + page.navigate(s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/details?category=limits") + + val select = page.getByTestId("topic-max-consumers-type-select") + assertThat(select).isVisible(vis(15000)) + select.selectOption(new SelectOption().setLabel("Specified for this topic")) + page.getByTestId("topic-max-consumers-value-input").fill("7") + saveButton.click() + + eventually() { + val v = admin.topicPolicies(false).getMaxConsumers(fqn) + assert(v != null && v.intValue == 7, s"topic maxConsumers was: $v") + } + } + + test("TOP-14: Topic Compaction tab triggers a broker-side compaction") { + assume(policiesEnabled, "topic-level policies are DISABLED on this broker") + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + fixtures.produceStrings(fqn, 3) + page.navigate(s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/details?category=topic-compaction") + + val trigger = page.getByTestId("trigger-compaction-button") + assertThat(trigger).isVisible(vis(15000)) + trigger.click() + + // The UI trigger reaches the broker: compaction leaves NOT_RUN for RUNNING/SUCCESS. + eventually(30000) { + val st = admin.topics().compactionStatus(fqn).status + assert(st == LongRunningProcessStatus.Status.RUNNING || st == LongRunningProcessStatus.Status.SUCCESS, + s"compaction status was: $st") + } + } diff --git a/e2e/src/test/scala/routes/TopicPolicySpec.scala b/e2e/src/test/scala/routes/TopicPolicySpec.scala new file mode 100644 index 000000000..a30108a6f --- /dev/null +++ b/e2e/src/test/scala/routes/TopicPolicySpec.scala @@ -0,0 +1,60 @@ +package routes + +import harness.DekafSuite +import com.microsoft.playwright.Locator +import com.microsoft.playwright.Page.GetByRoleOptions +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.assertions.LocatorAssertions +import com.microsoft.playwright.options.{AriaRole, SelectOption} +import java.util.regex.Pattern +import scala.jdk.CollectionConverters.* + +class TopicPolicySpec extends DekafSuite: + + private def visible(ms: Int) = new LocatorAssertions.IsVisibleOptions().setTimeout(ms) + + private def eventually(timeoutMs: Long = 15000, stepMs: Long = 300)(p: => Boolean): Boolean = { + val deadline = System.currentTimeMillis() + timeoutMs + var ok = false + while (!ok && System.currentTimeMillis() < deadline) { + ok = try p catch { case _: Throwable => false } + if (!ok) Thread.sleep(stepMs) + } + ok + } + + // Same key the UI reads (BrokersConfig.tsx -> getRuntimeConfigurations). + private def policiesEnabled: Boolean = + admin.brokers().getRuntimeConfigurations().asScala.get("topicLevelPoliciesEnabled").contains("true") + + test("TOP-8: Details shows a message when topic-level policies are disabled") { + assume(!policiesEnabled, + "topic-level policies are ENABLED on this broker (dev stack default) - TOP-8's disabled state is not reproducible here") + val (t, ns, topic) = fixtures.freshTopicParts() + page.navigate(s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/details") + assertThat(page.getByText(Pattern.compile("Topic level policies are not enabled"))).isVisible(visible(15000)) + } + + test("TOP-9: Details is-global toggle + field save when policies enabled") { + assume(policiesEnabled, + "topic-level policies are DISABLED on this broker - enable via broker.conf topicLevelPoliciesEnabled=true") + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + page.navigate(s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/details") + + val isGlobal = page.getByTestId("topic-details-is-global") + assertThat(isGlobal).isVisible(visible(15000)) + + // Field save on the default (Delayed Delivery) tab: set "Disabled", then Save. + page.getByTestId("topic-detail-delayed-delivery-select").selectOption(new SelectOption().setLabel("Disabled")) + page.getByRole(AriaRole.BUTTON, new GetByRoleOptions().setName("Save").setExact(true)).click() + + assert(eventually() { + Option(admin.topicPolicies(false).getDelayedDeliveryPolicy(fqn, false)).exists(!_.isActive) + }) + + // Exercise the is-global toggle (visually-hidden checkbox -> force). + val cb = isGlobal.locator("input[type='checkbox']") + cb.check(new Locator.CheckOptions().setForce(true)) + assertThat(cb).isChecked() + } diff --git a/e2e/src/test/scala/routes/TopicSpec.scala b/e2e/src/test/scala/routes/TopicSpec.scala new file mode 100644 index 000000000..30a0b7a27 --- /dev/null +++ b/e2e/src/test/scala/routes/TopicSpec.scala @@ -0,0 +1,57 @@ +package routes + +import harness.DekafSuite +import ui.ConfirmationDialog +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import com.microsoft.playwright.options.AriaRole +import com.microsoft.playwright.Page.GetByRoleOptions +import org.apache.pulsar.common.schema.{SchemaInfo, SchemaType} +import java.util.regex.Pattern +import scala.jdk.CollectionConverters.* + +class TopicSpec extends DekafSuite: + private def createButton = page.getByRole(AriaRole.BUTTON, new GetByRoleOptions().setName("Create").setExact(true)) + + test("TOP-7: create a subscription") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + val sub = fixtures.unique("sub") + page.navigate(s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/create-subscription") + page.getByTestId("create-subscription-name").fill(sub) + createButton.click() + page.waitForTimeout(1000) + assert(admin.topics().getSubscriptions(fqn).asScala.contains(sub)) + } + + test("TOP-10: delete a topic") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + page.navigate(s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/overview") + page.getByTestId("topic-page-delete-button").click() + ConfirmationDialog(page).confirm(guard = Some(fqn), force = true) + page.waitForTimeout(1500) + assert(!admin.topics().getList(s"$t/$ns").asScala.exists(_.contains(topic))) + } + + test("SCH-1: schema page redirects to /create when the topic has no schema") { + val (t, ns, topic) = fixtures.freshTopicParts() + page.navigate(s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/schema") + assertThat(page).hasURL(Pattern.compile(".*/schema/create.*")) + } + + test("SCH-7: delete a topic's schema") { + val (t, ns, topic) = fixtures.freshTopicParts() + val fqn = s"persistent://$t/$ns/$topic" + val schema = SchemaInfo.builder().name(topic).`type`(SchemaType.INT32).build() + admin.schemas().createSchema(fqn, schema) + + page.navigate(s"/tenants/$t/namespaces/$ns/topics/persistent/$topic/schema") + page.getByTestId("schema-delete-button").click() + ConfirmationDialog(page).confirm(guard = Some(topic), force = true) + page.waitForTimeout(1500) + + val deleted = + try { admin.schemas().getSchemaInfo(fqn); false } + catch { case _: Throwable => true } + assert(deleted) + } diff --git a/e2e/src/test/scala/routes/instance/InstanceSpec.scala b/e2e/src/test/scala/routes/instance/InstanceSpec.scala new file mode 100644 index 000000000..e2f2c9d29 --- /dev/null +++ b/e2e/src/test/scala/routes/instance/InstanceSpec.scala @@ -0,0 +1,77 @@ +package routes.instance + +import harness.DekafSuite +import routes.instance.create_tenant.CreateTenantPage +import routes.instance.resource_groups.create.CreateResourceGroupPage +import routes.instance.resource_groups.edit._resource_group_id.EditResourceGroupPage +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import org.apache.pulsar.common.policies.data.ResourceGroup +import scala.jdk.CollectionConverters.* + +class InstanceSpec extends DekafSuite: + + test("INS-3: create a tenant") { + page.navigate("/instance/create-tenant") + val p = CreateTenantPage(page.locator("body")) + val name = fixtures.unique("t") + p.setTenantName(name) + p.addAdminRole("role-1") + p.create() + fixtures.trackTenant(name) + + assert(admin.tenants().getTenants.asScala.contains(name)) + assert(admin.tenants().getTenantInfo(name).getAdminRoles.asScala.contains("role-1")) + } + + test("INS-4: Create is disabled until a tenant name is entered") { + page.navigate("/instance/create-tenant") + val p = CreateTenantPage(page.locator("body")) + assertThat(p.createButton).isDisabled() + p.setTenantName(fixtures.unique("t")) + assertThat(p.createButton).isEnabled() + } + + test("INS-6: create a resource group") { + val name = fixtures.unique("rg") + page.navigate("/instance/resource-groups/create") + val cp = CreateResourceGroupPage(page.locator("body")) + cp.setResourceGroupName(name) + cp.setDispatchRateInMsgs("10") + cp.create() + fixtures.onCleanup(() => admin.resourcegroups().deleteResourceGroup(name)) + + assert(admin.resourcegroups().getResourceGroups.asScala.contains(name)) + assert(admin.resourcegroups().getResourceGroup(name).getDispatchRateInMsgs == 10) + } + + test("INS-7: edit a resource group's rate (Save gated on dirty)") { + val name = fixtures.unique("rg") + admin.resourcegroups().createResourceGroup(name, new ResourceGroup()) + fixtures.onCleanup(() => admin.resourcegroups().deleteResourceGroup(name)) + + page.navigate(s"/instance/resource-groups/edit/$name") + val ep = EditResourceGroupPage(page.locator("body")) + assertThat(ep.resourceGroupName).isVisible() + assertThat(ep.saveButton).isDisabled() // not dirty + ep.setDispatchRateInMsgs("42") + assertThat(ep.saveButton).isEnabled() + ep.saveButton.click() + page.waitForTimeout(1000) + + assert(admin.resourcegroups().getResourceGroup(name).getDispatchRateInMsgs == 42) + } + + test("INS-8: delete a resource group") { + val name = fixtures.unique("rg") + admin.resourcegroups().createResourceGroup(name, new ResourceGroup()) + fixtures.onCleanup(() => admin.resourcegroups().deleteResourceGroup(name)) + + page.navigate(s"/instance/resource-groups/edit/$name") + val ep = EditResourceGroupPage(page.locator("body")) + ep.deleteButton.click() + ep.deleteGuardInput.fill("CONFIRM") + ep.deleteConfirmButton.click() + page.waitForTimeout(1000) + + assert(!admin.resourcegroups().getResourceGroups.asScala.contains(name)) + } diff --git a/e2e/src/test/scala/routes/instance/create_tenant/CreateTenantPageSpec.scala b/e2e/src/test/scala/routes/instance/create_tenant/CreateTenantPageSpec.scala deleted file mode 100644 index e785c7854..000000000 --- a/e2e/src/test/scala/routes/instance/create_tenant/CreateTenantPageSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package routes.instance.create_tenant - -import zio.* -import zio.test.* -import zio.test.Assertion.* -import zio.test.TestAspect.* - -import scala.jdk.CollectionConverters.* -import _root_.test_env.{TestEnv, createPulsarStandaloneEnv} -import com.microsoft.playwright.Page.WaitForURLOptions -import net.datafaker.Faker -import routes.InstancePage - -val faker = new Faker(); - -object CreateTenantPageSpec extends ZIOSpecDefault: - def spec: Spec[Any, Any] = suite("Create tenant page")( - test("User can create tenant") { - val testEnv: TestEnv = createPulsarStandaloneEnv - val page = testEnv.createNewPage() - page.navigate("/instance/create-tenant") - - val createTenantPage = CreateTenantPage(page.locator("body")) - val isCreateButtonDisabledIfNoTenantName = createTenantPage.createButton.isDisabled - - val tenantToCreate = s"${faker.name.lastName}-${new java.util.Date().getTime}" - createTenantPage.setTenantName(tenantToCreate) - - val adminRoles = List.range(0, 20).map(i => s"${faker.company.profession}-${i}") - adminRoles.foreach(role => createTenantPage.addAdminRole(role)) - - createTenantPage.create() - - val adminClient = testEnv.createPulsarAdminClient() - val tenants = adminClient.tenants.getTenants.asScala - val createdTenant = adminClient.tenants.getTenantInfo(tenantToCreate) - - page.waitForURL("/", new WaitForURLOptions().setTimeout(3000)) - - assertTrue(isCreateButtonDisabledIfNoTenantName) && - assertTrue(tenants.contains(tenantToCreate)) && - assertTrue(createdTenant.getAdminRoles.asScala.toSet == adminRoles.toSet) - // TODO - test allowed clusters input - } - ) diff --git a/e2e/src/test/scala/routes/instance/resource_groups/ResourceGroupsPageSpec.scala b/e2e/src/test/scala/routes/instance/resource_groups/ResourceGroupsPageSpec.scala deleted file mode 100644 index 20c37478f..000000000 --- a/e2e/src/test/scala/routes/instance/resource_groups/ResourceGroupsPageSpec.scala +++ /dev/null @@ -1,93 +0,0 @@ -package routes.instance.resource_groups - -import _root_.test_env.{TestEnv, createPulsarStandaloneEnv} -import com.microsoft.playwright.Page.WaitForURLOptions -import net.datafaker.Faker -import org.apache.pulsar.common.policies.data.ResourceGroup -import zio.* -import zio.test.* -import zio.test.Assertion.* -import zio.test.TestAspect.* - -import scala.jdk.CollectionConverters.* -import scala.util.control.Breaks.break - - -val faker = new Faker(); - -object ResourceGroupsPageSpec extends ZIOSpecDefault { - def spec: Spec[Any, Any] = suite("Resource groups page")( - test("User can see resource groups") { - val testEnv: TestEnv = createPulsarStandaloneEnv - val page = testEnv.createNewPage() - val adminClient = testEnv.createPulsarAdminClient() - val resourceGroupsPage = ResourceGroupsPage(page.locator("body")) - - val newResourceGroups: Map[String, ResourceGroup] = List.range(0, 20).map(i => { - val resourceGroup = new ResourceGroup - - val resourceGroupName = s"${faker.name.lastName}-${new java.util.Date().getTime}" - - resourceGroup.setDispatchRateInBytes(faker.number.numberBetween(0, Int.MaxValue)) - resourceGroup.setDispatchRateInMsgs(faker.number.numberBetween(0, Int.MaxValue)) - resourceGroup.setPublishRateInBytes(faker.number.numberBetween(0, Int.MaxValue)) - resourceGroup.setPublishRateInMsgs(faker.number.numberBetween(0, Int.MaxValue)) - - (resourceGroupName, resourceGroup) - }).toMap - - newResourceGroups.foreach({ case (rgName, rg) => - adminClient.resourcegroups.createResourceGroup(rgName, rg) - }) - - page.navigate("/instance/resource-groups") - - val existResourceGroups: Map[String, ResourceGroup] = newResourceGroups.map(resourceGroup => { - val existResourceGroup = new ResourceGroup - - val resourceGroupName = resourceGroupsPage.getResourceGroupName(resourceGroup._1) - - existResourceGroup.setDispatchRateInBytes(resourceGroupsPage.getDispatchRateInBytes(resourceGroup._1).replace(",", "").toLong) - existResourceGroup.setDispatchRateInMsgs(resourceGroupsPage.getDispatchRateInMsgs(resourceGroup._1).replace(",", "").toInt) - existResourceGroup.setPublishRateInBytes(resourceGroupsPage.getPublishRateInBytes(resourceGroup._1).replace(",", "").toLong) - existResourceGroup.setPublishRateInMsgs(resourceGroupsPage.getPublishRateInMsgs(resourceGroup._1).replace(",", "").toInt) - - (resourceGroupName, existResourceGroup) - }) - - assertTrue(newResourceGroups == existResourceGroups) - }, - - test("User can open resource-groups/:resource-group/edit") { - val testEnv: TestEnv = createPulsarStandaloneEnv - val page = testEnv.createNewPage() - val adminClient = testEnv.createPulsarAdminClient() - val resourceGroupsPage = ResourceGroupsPage(page.locator("body")) - - val testResourceGroupName = s"${faker.name.firstName()}-${java.util.Date().getTime}" - adminClient.resourcegroups.createResourceGroup(testResourceGroupName, new ResourceGroup()) - - page.navigate("/instance/resource-groups") - - resourceGroupsPage.edit(testResourceGroupName) - - page.waitForURL(s"/instance/resource-groups/edit/${testResourceGroupName}", new WaitForURLOptions().setTimeout(3000)) - - assertTrue(page.url == s"http://localhost:8090/instance/resource-groups/edit/${testResourceGroupName}") - }, - - test("User can open resource-groups/create") { - val testEnv: TestEnv = createPulsarStandaloneEnv - val page = testEnv.createNewPage() - val resourceGroupsPage = ResourceGroupsPage(page.locator("body")) - - page.navigate("/instance/resource-groups") - - resourceGroupsPage.createButton.click() - - page.waitForURL("/instance/resource-groups/create", new WaitForURLOptions().setTimeout(3000)) - - assertTrue(page.url == "http://localhost:8090/instance/resource-groups/create") - } - ) -} diff --git a/e2e/src/test/scala/routes/instance/resource_groups/create/CreateResourceGroupPageSpec.scala b/e2e/src/test/scala/routes/instance/resource_groups/create/CreateResourceGroupPageSpec.scala deleted file mode 100644 index 5317952fa..000000000 --- a/e2e/src/test/scala/routes/instance/resource_groups/create/CreateResourceGroupPageSpec.scala +++ /dev/null @@ -1,52 +0,0 @@ -package routes.instance.resource_groups.create - -import zio.* -import zio.test.* -import zio.test.Assertion.* -import zio.test.TestAspect.* - -import scala.jdk.CollectionConverters.* -import _root_.test_env.{TestEnv, createPulsarStandaloneEnv} -import com.microsoft.playwright.Page.WaitForURLOptions -import net.datafaker.Faker - -val faker = new Faker(); - -object CreateResourceGroupPageSpec extends ZIOSpecDefault { - def spec: Spec[Any, Any] = suite("Create resource group page")( - test("User can create resource group") { - val testEnv: TestEnv = createPulsarStandaloneEnv - val page = testEnv.createNewPage() - page.navigate("/instance/resource-groups/create") - - val createResourceGroupPage = CreateResourceGroupPage(page.locator("body")) - - val isCreateButtonDisabledIfNoResourceGroupName = createResourceGroupPage.createButton.isDisabled - - val resourceGroupName = s"${faker.name.lastName}-${new java.util.Date().getTime}" - createResourceGroupPage.setResourceGroupName(resourceGroupName) - - val dispatchRateInBytes = s"${faker.number.numberBetween(0, Long.MaxValue)}" - createResourceGroupPage.setDispatchRateInBytes(dispatchRateInBytes) - - val dispatchRateInMsgs = s"${faker.number.numberBetween(0, Int.MaxValue)}" - createResourceGroupPage.setDispatchRateInMsgs(dispatchRateInMsgs) - - val publishRateInBytes = s"${faker.number.numberBetween(0, Long.MaxValue)}" - createResourceGroupPage.setPublishRateInBytes(publishRateInBytes) - - val publishRateInMsgs = s"${faker.number.numberBetween(0, Int.MaxValue)}" - createResourceGroupPage.setPublishRateInMsgs(publishRateInMsgs) - - createResourceGroupPage.create() - - val adminClient = testEnv.createPulsarAdminClient() - val resourceGroups = adminClient.resourcegroups.getResourceGroups.asScala - - page.waitForURL("/instance/resource-groups", new WaitForURLOptions().setTimeout(3000)) - - assertTrue(isCreateButtonDisabledIfNoResourceGroupName) && - assertTrue(resourceGroups.contains(resourceGroupName)) - } - ) -} diff --git a/e2e/src/test/scala/routes/instance/resource_groups/edit/_resource_group_id/EditResourceGroupPageSpec.scala b/e2e/src/test/scala/routes/instance/resource_groups/edit/_resource_group_id/EditResourceGroupPageSpec.scala deleted file mode 100644 index 56ba66c23..000000000 --- a/e2e/src/test/scala/routes/instance/resource_groups/edit/_resource_group_id/EditResourceGroupPageSpec.scala +++ /dev/null @@ -1,79 +0,0 @@ -package routes.instance.resource_groups.edit._resource_group_id - -import zio.* -import zio.test.* -import zio.test.Assertion.* -import zio.test.TestAspect.* - -import scala.jdk.CollectionConverters.* -import _root_.test_env.{TestEnv, createPulsarStandaloneEnv} -import com.microsoft.playwright.Page.WaitForURLOptions -import org.apache.pulsar.common.policies.data.ResourceGroup -import net.datafaker.Faker - -val faker = new Faker(); - -object EditResourceGroupPageSpec extends ZIOSpecDefault { - def spec: Spec[Any, Any] = suite("Edit Resource Group page")( - test("User can delete resource group") { - val testEnv: TestEnv = createPulsarStandaloneEnv - val page = testEnv.createNewPage() - val adminClient = testEnv.createPulsarAdminClient() - - val testResourceGroupName = s"${faker.name.firstName()}-${java.util.Date().getTime}" - adminClient.resourcegroups.createResourceGroup(testResourceGroupName, new ResourceGroup()) - val isTestResourceGroupCreated = adminClient.resourcegroups.getResourceGroups.asScala.contains(testResourceGroupName) - - page.navigate(s"/instance/resource-groups/edit/${testResourceGroupName}") - val editResourceGroupPage = EditResourceGroupPage(page.locator("body")) - - editResourceGroupPage.deleteButton.click() - editResourceGroupPage.deleteGuardInput.fill(testResourceGroupName) - editResourceGroupPage.deleteConfirmButton.click() - - page.waitForTimeout(1000) - val isTestResourceGroupDeleted = !adminClient.resourcegroups.getResourceGroups.asScala.contains(testResourceGroupName) - - page.waitForURL("/instance/resource-groups", new WaitForURLOptions().setTimeout(3000)) - - assertTrue(isTestResourceGroupCreated) && assertTrue(isTestResourceGroupDeleted) - }, - - test("User can update resource group") { - val testEnv: TestEnv = createPulsarStandaloneEnv - val page = testEnv.createNewPage() - val adminClient = testEnv.createPulsarAdminClient() - - val testResourceGroupName = s"${faker.name.firstName()}-${java.util.Date().getTime}" - adminClient.resourcegroups.createResourceGroup(testResourceGroupName, new ResourceGroup()) - val isTestResourceGroupCreated = adminClient.resourcegroups.getResourceGroups.asScala.contains(testResourceGroupName) - - page.navigate(s"/instance/resource-groups/edit/${testResourceGroupName}") - val editResourceGroupPage = EditResourceGroupPage(page.locator("body")) - - val dispatchRateInBytes = s"${faker.number.numberBetween(0, Int.MaxValue)}" - editResourceGroupPage.setDispatchRateInBytes(dispatchRateInBytes) - - val dispatchRateInMsgs = s"${faker.number.numberBetween(0, Int.MaxValue)}" - editResourceGroupPage.setDispatchRateInMsgs(dispatchRateInMsgs) - - val publishRateInBytes = s"${faker.number.numberBetween(0, Int.MaxValue)}" - editResourceGroupPage.setPublishRateInBytes(publishRateInBytes) - - val publishRateInMsgs = s"${faker.number.numberBetween(0, Int.MaxValue)}" - editResourceGroupPage.setPublishRateInMsgs(publishRateInMsgs) - - editResourceGroupPage.saveButton.click() - page.waitForTimeout(1000) - val UpdatedResourceGroup = adminClient.resourcegroups.getResourceGroup(testResourceGroupName) - - page.waitForURL("/instance/resource-groups", new WaitForURLOptions().setTimeout(3000)) - - assertTrue(isTestResourceGroupCreated) && - assertTrue(UpdatedResourceGroup.getDispatchRateInBytes.toString == dispatchRateInBytes) && - assertTrue(UpdatedResourceGroup.getDispatchRateInMsgs.toString == dispatchRateInMsgs) && - assertTrue(UpdatedResourceGroup.getPublishRateInBytes.toString == publishRateInBytes) && - assertTrue(UpdatedResourceGroup.getPublishRateInMsgs.toString == publishRateInMsgs) - } - ) -} diff --git a/e2e/src/test/scala/routes/tenants/_tenant_id/namespaces/NamespacesPageSpec.scala b/e2e/src/test/scala/routes/tenants/_tenant_id/namespaces/NamespacesPageSpec.scala deleted file mode 100644 index 43eb41ce9..000000000 --- a/e2e/src/test/scala/routes/tenants/_tenant_id/namespaces/NamespacesPageSpec.scala +++ /dev/null @@ -1,64 +0,0 @@ -package routes.tenants._tenant_id.namespaces - -import zio.* -import zio.test.* -import zio.test.Assertion.* -import zio.test.TestAspect.* - -import scala.jdk.CollectionConverters.* -import _root_.test_env.{TestEnv, createPulsarStandaloneEnv} -import com.microsoft.playwright.Page.WaitForURLOptions -import org.apache.pulsar.common.policies.data.{ResourceGroup, TenantInfo} -import net.datafaker.Faker - -val faker = new Faker(); - -object NamespacesPageSpec extends ZIOSpecDefault { - def spec: Spec[Any, Any] = suite("Use namespaces page")( - - test("User can delete tenant") { - val testEnv: TestEnv = createPulsarStandaloneEnv - val page = testEnv.createNewPage() - val adminClient = testEnv.createPulsarAdminClient() - val namespacesPage = NamespacesPage(page.locator("body")) - - val tenant = s"${faker.name.firstName()}-${java.util.Date().getTime}" - - val config = TenantInfo.builder - val cluster = adminClient.clusters().getClusters.get(0) - - config.adminRoles(Set("Admin").asJava) - config.allowedClusters(Set(cluster).asJava) - adminClient.tenants.createTenant(tenant, config.build) - - page.navigate(s"/tenants/${tenant}/namespaces/") - - namespacesPage.deleteButton.click() - - val isDisabled = namespacesPage.deleteConfirmButton.isDisabled - namespacesPage.deleteGuardInput.fill(tenant) - - val unDisabled = !namespacesPage.deleteConfirmButton.isDisabled - namespacesPage.deleteConfirmButton.click() - - page.waitForTimeout(1000) - - val isDeleted = - try { - adminClient.tenants.getTenantInfo(tenant) - false - } catch { - case _ => true - } - - page.waitForURL("/", new WaitForURLOptions().setTimeout(3000)) - -// TODO ADD TEST FOR FORCE DELETE WHEN WILL BE POSSIBLE CHANGE CLUSTER - - assertTrue(isDeleted) && - assertTrue(isDisabled) && - assertTrue(unDisabled) - }, - - ) -} diff --git a/e2e/src/test/scala/routes/tenants/_tenant_id/namespaces/_namespace_id/policies/Properties.scala b/e2e/src/test/scala/routes/tenants/_tenant_id/namespaces/_namespace_id/policies/Properties.scala deleted file mode 100644 index 92fa39554..000000000 --- a/e2e/src/test/scala/routes/tenants/_tenant_id/namespaces/_namespace_id/policies/Properties.scala +++ /dev/null @@ -1,130 +0,0 @@ -package routes.tenants._tenant_id.namespaces._namespace_id.policies - -import zio.* -import zio.test.* -import zio.test.Assertion.* -import zio.test.TestAspect.* - -import scala.jdk.CollectionConverters.* -import _root_.test_env.{TestEnv, createPulsarStandaloneEnv} -import com.microsoft.playwright.Page.WaitForURLOptions -import net.datafaker.Faker -import org.apache.pulsar.common.policies.data.TenantInfo - -val faker = new Faker(); - -object PropertiesTest extends ZIOSpecDefault { - def spec: Spec[Any, Any] = suite("Edit namespace properties")( - test("User can create properties") { - val testEnv: TestEnv = createPulsarStandaloneEnv - val page = testEnv.createNewPage() - val adminClient = testEnv.createPulsarAdminClient() - val createProperty = Properties(page.locator("body")) - - val tenant = s"${faker.name.firstName()}-${java.util.Date().getTime}" - val namespace = s"${faker.name.firstName()}-${java.util.Date().getTime}" - - val config = TenantInfo.builder - config.adminRoles(Set("Admin").asJava) - - val cluster = adminClient.clusters().getClusters.get(0) - config.allowedClusters(Set(cluster).asJava) - - adminClient.tenants.createTenant(tenant, config.build) - adminClient.namespaces.createNamespace(s"${tenant}/${namespace}") - - page.navigate(s"/tenants/${tenant}/namespaces/${namespace}/policies") - - val key = s"${faker.name.firstName()}-${java.util.Date().getTime}" - val value = s"${faker.name.firstName()}-${java.util.Date().getTime}" - - createProperty.newKey.fill(key) - createProperty.newValue.fill(value) - - createProperty.addButton.click() - createProperty.saveButton.click() - - val createdKey = createProperty.existingKey(key).innerText() - val createdValue = createProperty.existingValue(value).innerText() - - page.waitForTimeout(1000) - val savedPropertyValue = adminClient.namespaces.getProperty(s"${tenant}/${namespace}", key) - - assertTrue(createdKey == key) && - assertTrue(createdValue == value) - assertTrue(savedPropertyValue == value) - }, - - test("User can delete properties") { - val testEnv: TestEnv = createPulsarStandaloneEnv - val page = testEnv.createNewPage() - val adminClient = testEnv.createPulsarAdminClient() - val deleteProperty = Properties(page.locator("body")) - - val tenant = s"${faker.name.firstName()}-${java.util.Date().getTime}" - val namespace = s"${faker.name.firstName()}-${java.util.Date().getTime}" - - val key = s"${faker.name.firstName()}-${java.util.Date().getTime}" - val value = s"${faker.name.firstName()}-${java.util.Date().getTime}" - val config = TenantInfo.builder - config.adminRoles(Set("Admin").asJava) - - val cluster = adminClient.clusters().getClusters.get(0) - config.allowedClusters(Set(cluster).asJava) - - adminClient.tenants.createTenant(tenant, config.build) - adminClient.namespaces.createNamespace(s"${tenant}/${namespace}") - adminClient.namespaces.setProperty(s"${tenant}/${namespace}", key, value) - - page.navigate(s"/tenants/${tenant}/namespaces/${namespace}/policies") - val createdKey = deleteProperty.existingKey(key).innerText() - val createdValue = deleteProperty.existingValue(value).innerText() - - deleteProperty.deleteButton(key).click() - deleteProperty.saveButton.click() - - page.waitForTimeout(1000) - val isDeleted = adminClient.namespaces.getProperty(s"${tenant}/${namespace}", key) - - assertTrue(createdKey == "") && - assertTrue(createdValue == "") && - assertTrue(isDeleted == null) - }, - test ("User can edit properties") { - val testEnv: TestEnv = createPulsarStandaloneEnv - val page = testEnv.createNewPage() - val adminClient = testEnv.createPulsarAdminClient() - val editProperty = Properties(page.locator("body")) - - val tenant = s"${faker.name.firstName()}-${java.util.Date().getTime}" - val namespace = s"${faker.name.firstName()}-${java.util.Date().getTime}" - - val key = s"${faker.name.firstName()}-${java.util.Date().getTime}" - val value = s"${faker.name.firstName()}-${java.util.Date().getTime}" - val config = TenantInfo.builder - config.adminRoles(Set("Admin").asJava) - - val cluster = adminClient.clusters().getClusters.get(0) - config.allowedClusters(Set(cluster).asJava) - - adminClient.tenants.createTenant(tenant, config.build) - adminClient.namespaces.createNamespace(s"${tenant}/${namespace}") - adminClient.namespaces.setProperty(s"${tenant}/${namespace}", key, value) - - page.navigate(s"/tenants/${tenant}/namespaces/${namespace}/policies") - - val newKey = s"${faker.name.firstName()}-${java.util.Date().getTime}" - val newValue = s"${faker.name.firstName()}-${java.util.Date().getTime}" - - editProperty.existingKey(key).fill(newKey) - editProperty.existingValue(value).fill(newValue) - - editProperty.saveButton.click() - - page.waitForTimeout(1000) - val isEditedValue = adminClient.namespaces.getProperty(s"${tenant}/${namespace}", newKey) - - assertTrue(isEditedValue == newValue) - } - ) -} diff --git a/e2e/src/test/scala/routes/tenants/_tenant_id/namespaces/_namespace_id/topics/TopicsPageSpec.scala b/e2e/src/test/scala/routes/tenants/_tenant_id/namespaces/_namespace_id/topics/TopicsPageSpec.scala deleted file mode 100644 index d6d2ed3c9..000000000 --- a/e2e/src/test/scala/routes/tenants/_tenant_id/namespaces/_namespace_id/topics/TopicsPageSpec.scala +++ /dev/null @@ -1,66 +0,0 @@ -package routes.tenants._tenant_id.namespaces._namespace_id.topics - -import zio.* -import zio.test.* -import zio.test.Assertion.* -import zio.test.TestAspect.* - -import scala.jdk.CollectionConverters.* -import _root_.test_env.{TestEnv, createPulsarStandaloneEnv} -import com.microsoft.playwright.Page.WaitForURLOptions -import org.apache.pulsar.common.policies.data.{ResourceGroup, TenantInfo} -import net.datafaker.Faker - -val faker = new Faker(); - -object TopicsPageSpec extends ZIOSpecDefault { - def spec: Spec[Any, Any] = suite("Use namespaces page")( - - test("User can delete namespace") { - val testEnv: TestEnv = createPulsarStandaloneEnv - val page = testEnv.createNewPage() - val adminClient = testEnv.createPulsarAdminClient() - val topicsPage = TopicsPage(page.locator("body")) - - val tenant = s"${faker.name.firstName()}-${java.util.Date().getTime}" - val namespace = s"${faker.name.firstName()}-${java.util.Date().getTime}" - - val config = TenantInfo.builder - val cluster = adminClient.clusters().getClusters.get(0) - - config.adminRoles(Set("Admin").asJava) - config.allowedClusters(Set(cluster).asJava) - adminClient.tenants.createTenant(tenant, config.build) - adminClient.namespaces.createNamespace(s"${tenant}/${namespace}") - - page.navigate(s"/tenants/${tenant}/namespaces/${namespace}/topics") - - topicsPage.deleteButton.click() - - val isDisabled = topicsPage.deleteConfirmButton.isDisabled - topicsPage.deleteGuardInput.fill(namespace) - - val unDisabled = !topicsPage.deleteConfirmButton.isDisabled - topicsPage.deleteConfirmButton.click() - - page.waitForTimeout(1000) - - val isDeleted = - try { - adminClient.namespaces.getNamespaceResourceGroup(s"${tenant}/${namespace}") - false - } catch { - case _ => true - } - - page.waitForURL(s"tenants/${tenant}/namespaces", new WaitForURLOptions().setTimeout(3000)) - -// TODO ADD TEST FOR FORCE DELETE WHEN WILL BE POSSIBLE CHANGE CLUSTER - - assertTrue(isDeleted) && - assertTrue(isDisabled) && - assertTrue(unDisabled) - }, - - ) -} diff --git a/e2e/src/test/scala/routes/tenants/_tenant_id/namespaces/_namespace_id/topics/_topic_id/TopicPageSpec.scala b/e2e/src/test/scala/routes/tenants/_tenant_id/namespaces/_namespace_id/topics/_topic_id/TopicPageSpec.scala deleted file mode 100644 index a9a3c9a38..000000000 --- a/e2e/src/test/scala/routes/tenants/_tenant_id/namespaces/_namespace_id/topics/_topic_id/TopicPageSpec.scala +++ /dev/null @@ -1,136 +0,0 @@ -package routes.tenants._tenant_id.namespaces._namespace_id.topics._topic_id - -import zio.* -import zio.test.* -import zio.test.Assertion.* -import zio.test.TestAspect.* - -import scala.jdk.CollectionConverters.* -import _root_.test_env.{TestEnv, createPulsarStandaloneEnv} -import com.microsoft.playwright.Page.WaitForURLOptions -import org.apache.pulsar.common.policies.data.{ResourceGroup, TenantInfo} -import net.datafaker.Faker - -val faker = new Faker(); - -object TopicPageSpec extends ZIOSpecDefault { - def spec: Spec[Any, Any] = suite("Use topic page")( - - test("User can delete topic") { - val testEnv: TestEnv = createPulsarStandaloneEnv - val page = testEnv.createNewPage() - val adminClient = testEnv.createPulsarAdminClient() - val topicPage = TopicPage(page.locator("body")) - - val tenant = s"${faker.name.firstName()}-${java.util.Date().getTime}" - val namespace = s"${faker.name.firstName()}-${java.util.Date().getTime}" - val topic = s"${faker.name.firstName()}-${java.util.Date().getTime}" - - val config = TenantInfo.builder - val cluster = adminClient.clusters().getClusters.get(0) - - config.adminRoles(Set("Admin").asJava) - config.allowedClusters(Set(cluster).asJava) - adminClient.tenants.createTenant(tenant, config.build) - adminClient.namespaces.createNamespace(s"${tenant}/${namespace}") - adminClient.topics.createNonPartitionedTopic(s"${tenant}/${namespace}/${topic}") - - page.navigate(s"/tenants/${tenant}/namespaces/${namespace}/topics/non-persistent/${topic}/messages") - - topicPage.deleteButton.click() - - val isDisabled = topicPage.deleteConfirmButton.isDisabled - topicPage.deleteGuardInput.fill(topic) - - topicPage.forceDeleteCheckbox.click() - - val unDisabled = !topicPage.deleteConfirmButton.isDisabled - topicPage.deleteConfirmButton.click() - - page.waitForTimeout(1000) - - val isDeleted = - try { - adminClient.namespaces.getTopics(s"${tenant}/${namespace}/${topic}") - false - } catch { - case _ => true - } - - page.waitForURL(s"tenants/${tenant}/namespaces/${namespace}/topics", new WaitForURLOptions().setTimeout(3000)) - - // TODO ADD TEST FOR FORCE DELETE WHEN WILL BE POSSIBLE CHANGE CLUSTER - - assertTrue(isDeleted) && - assertTrue(isDisabled) && - assertTrue(unDisabled) - }, - - test("User can't open the topic policies page for non-persistent topic") { - val testEnv: TestEnv = createPulsarStandaloneEnv - val page = testEnv.createNewPage() - val adminClient = testEnv.createPulsarAdminClient() - val topicPage = TopicPage(page.locator("body")) - - val tenant = s"${faker.name.firstName()}-${java.util.Date().getTime}" - val namespace = s"${faker.name.firstName()}-${java.util.Date().getTime}" - val topic = s"${faker.name.firstName()}-${java.util.Date().getTime}" - - val config = TenantInfo.builder - val cluster = adminClient.clusters().getClusters.get(0) - - config.adminRoles(Set("Admin").asJava) - config.allowedClusters(Set(cluster).asJava) - adminClient.tenants.createTenant(tenant, config.build) - adminClient.namespaces.createNamespace(s"${tenant}/${namespace}") - adminClient.topics.createNonPartitionedTopic(s"${tenant}/${namespace}/${topic}") - - page.navigate(s"/tenants/${tenant}/namespaces/${namespace}/topics/non-persistent/${topic}/messages") - - val buttonHiden = - try { - val buttonExist = topicPage.policiesButton.innerText() - false - } catch { - _ => - true - } - - assertTrue(buttonHiden) - }, - - test("User can open the topic policies page for persistent topic") { - val testEnv: TestEnv = createPulsarStandaloneEnv - val page = testEnv.createNewPage() - val adminClient = testEnv.createPulsarAdminClient() - val topicPage = TopicPage(page.locator("body")) - - val tenant = s"${faker.name.firstName()}-${java.util.Date().getTime}" - val namespace = s"${faker.name.firstName()}-${java.util.Date().getTime}" - val topic = s"${faker.name.firstName()}-${java.util.Date().getTime}" - - val config = TenantInfo.builder - val cluster = adminClient.clusters().getClusters.get(0) - - config.adminRoles(Set("Admin").asJava) - config.allowedClusters(Set(cluster).asJava) - adminClient.tenants.createTenant(tenant, config.build) - adminClient.namespaces.createNamespace(s"${tenant}/${namespace}") - adminClient.topics.createPartitionedTopic(s"${tenant}/${namespace}/${topic}", 1) - - page.navigate(s"/tenants/${tenant}/namespaces/${namespace}/topics/persistent/${topic}/messages") - - val buttonHiden = - try { - val buttonExist = topicPage.policiesButton.innerText() - true - } catch { - _ => - false - } - - assertTrue(buttonHiden) - }, - - ) -} diff --git a/e2e/src/test/scala/routes/tenants/_tenant_id/namespaces/_namespace_id/topics/_topic_id/schema/SchemaPageSpec.scala b/e2e/src/test/scala/routes/tenants/_tenant_id/namespaces/_namespace_id/topics/_topic_id/schema/SchemaPageSpec.scala deleted file mode 100644 index dcbb2e666..000000000 --- a/e2e/src/test/scala/routes/tenants/_tenant_id/namespaces/_namespace_id/topics/_topic_id/schema/SchemaPageSpec.scala +++ /dev/null @@ -1,129 +0,0 @@ -package routes.tenants._tenant_id.namespaces._namespace_id.topics._topic_id.schema - -import zio.* -import zio.test.* -import zio.test.Assertion.* -import zio.test.TestAspect.* - -import scala.jdk.CollectionConverters.* -import _root_.test_env.{TestEnv, createPulsarStandaloneEnv} -import com.microsoft.playwright.Page.WaitForURLOptions -import org.apache.pulsar.common.policies.data.{ResourceGroup, TenantInfo} -import org.apache.pulsar.common.schema.{SchemaInfo, SchemaType} -import net.datafaker.Faker - -val faker = new Faker(); - -object SchemaPageSpec extends ZIOSpecDefault { - def spec: Spec[Any, Any] = suite("Use schema topic")( - - test("User can delete json schema") { - val testEnv: TestEnv = createPulsarStandaloneEnv - val page = testEnv.createNewPage() - val adminClient = testEnv.createPulsarAdminClient() - val schemaPage = SchemaPage(page.locator("body")) - - val tenant = s"${faker.name.firstName()}-${java.util.Date().getTime}" - val namespace = s"${faker.name.firstName()}-${java.util.Date().getTime}" - val topic = s"${faker.name.firstName()}-${java.util.Date().getTime}" - - val config = TenantInfo.builder - val cluster = adminClient.clusters().getClusters.get(0) - - config.adminRoles(Set("Admin").asJava) - config.allowedClusters(Set(cluster).asJava) - - adminClient.tenants.createTenant(tenant, config.build) - adminClient.namespaces.createNamespace(s"${tenant}/${namespace}") - adminClient.topics.createNonPartitionedTopic(s"${tenant}/${namespace}/${topic}") - - val schemaName = s"${faker.name.firstName()}-${java.util.Date().getTime}" - val schema1 = "{\"type\":\"record\",\"name\":\"User1\",\"namespace\":\"com.foo\",\"fields\":[{\"name\":\"file1\",\"type\":\"string\"}]}" - val schema2 = "{\"type\":\"record\",\"name\":\"User2\",\"namespace\":\"com.foo\",\"fields\":[{\"name\":\"file1\",\"type\":\"string\"}]}" - val schema3 = "{\"type\":\"record\",\"name\":\"User3\",\"namespace\":\"com.foo\",\"fields\":[{\"name\":\"file1\",\"type\":\"string\"}]}" - val schema4 = "{\"type\":\"record\",\"name\":\"User4\",\"namespace\":\"com.foo\",\"fields\":[{\"name\":\"file1\",\"type\":\"string\"}]}" - val schema5 = "{\"type\":\"record\",\"name\":\"User5\",\"namespace\":\"com.foo\",\"fields\":[{\"name\":\"file1\",\"type\":\"string\"}]}" - - val convertedSchema = (schema: String) => - schema.split("").map(symbol => symbol.getBytes).flatten - - val createSchemaInfo = (schema: String) => - SchemaInfo.builder - .name(schemaName) - .`type`(SchemaType.AVRO) - .properties(Map().asJava) - .schema(convertedSchema(schema)) - .build() - - adminClient.schemas.createSchema(s"${tenant}/${namespace}/${topic}", createSchemaInfo(schema1)) - adminClient.schemas.createSchema(s"${tenant}/${namespace}/${topic}", createSchemaInfo(schema2)) - adminClient.schemas.createSchema(s"${tenant}/${namespace}/${topic}", createSchemaInfo(schema3)) - adminClient.schemas.createSchema(s"${tenant}/${namespace}/${topic}", createSchemaInfo(schema4)) - adminClient.schemas.createSchema(s"${tenant}/${namespace}/${topic}", createSchemaInfo(schema5)) - - page.navigate(s"/tenants/${tenant}/namespaces/${namespace}/topics/persistent/${topic}/schema") - - schemaPage.deleteButton.click() - schemaPage.deleteConfirmButton.click() - - page.waitForTimeout(1000) - - val isDeleted = - try { - adminClient.schemas.getSchemaInfo(schemaName) - false - } catch { - case _ => true - } - - assertTrue(isDeleted) - }, - - test("User can delete common schema") { - val testEnv: TestEnv = createPulsarStandaloneEnv - val page = testEnv.createNewPage() - val adminClient = testEnv.createPulsarAdminClient() - val schemaPage = SchemaPage(page.locator("body")) - - val tenant = s"${faker.name.firstName()}-${java.util.Date().getTime}" - val namespace = s"${faker.name.firstName()}-${java.util.Date().getTime}" - val topic = s"${faker.name.firstName()}-${java.util.Date().getTime}" - - val config = TenantInfo.builder - val cluster = adminClient.clusters().getClusters.get(0) - - config.adminRoles(Set("Admin").asJava) - config.allowedClusters(Set(cluster).asJava) - - adminClient.tenants.createTenant(tenant, config.build) - adminClient.namespaces.createNamespace(s"${tenant}/${namespace}") - adminClient.topics.createNonPartitionedTopic(s"${tenant}/${namespace}/${topic}") - - val schemaName = s"${faker.name.firstName()}-${java.util.Date().getTime}" - - val schemaInfo = SchemaInfo.builder - .name(schemaName) - .`type`(SchemaType.INT32) - .build() - - adminClient.schemas.createSchema(s"${tenant}/${namespace}/${topic}", schemaInfo) - - page.navigate(s"/tenants/${tenant}/namespaces/${namespace}/topics/persistent/${topic}/schema") - - schemaPage.deleteButton.click() - schemaPage.deleteConfirmButton.click() - - page.waitForTimeout(1000) - - val isDeleted = - try { - adminClient.schemas.getSchemaInfo(schemaName) - false - } catch { - case _ => true - } - - assertTrue(isDeleted) - }, - ) -} diff --git a/e2e/src/test/scala/routes/tenants/_tenant_id/namespaces/_namespace_id/topics/permissions/PermissionsPageSpec.scala b/e2e/src/test/scala/routes/tenants/_tenant_id/namespaces/_namespace_id/topics/permissions/PermissionsPageSpec.scala deleted file mode 100644 index cd2ee7e90..000000000 --- a/e2e/src/test/scala/routes/tenants/_tenant_id/namespaces/_namespace_id/topics/permissions/PermissionsPageSpec.scala +++ /dev/null @@ -1,52 +0,0 @@ -package routes.tenants._tenant_id.namespaces._namespace_id.topics.permissions - -import zio.* -import zio.test.* -import zio.test.Assertion.* -import zio.test.TestAspect.* - -import scala.jdk.CollectionConverters.* -import _root_.test_env.{TestEnv, createPulsarStandaloneEnv} -import com.microsoft.playwright.Page.WaitForURLOptions -import org.apache.pulsar.common.policies.data.{ResourceGroup, TenantInfo} -import net.datafaker.Faker - -val faker = new Faker(); - -object PermissionsPageSpec extends ZIOSpecDefault { - def spec: Spec[Any, Any] = suite("Use can change permissions")( - - test("User can revoke permissions") { - val testEnv: TestEnv = createPulsarStandaloneEnv - val page = testEnv.createNewPage() - val adminClient = testEnv.createPulsarAdminClient() - val permissionsPage = PermissionsPage(page.locator("body")) - - val permission = s"${faker.name.firstName()}-${java.util.Date().getTime}" - - val tenant = s"${faker.name.firstName()}-${java.util.Date().getTime}" - val namespace = s"${faker.name.firstName()}-${java.util.Date().getTime}" - - val config = TenantInfo.builder - val cluster = adminClient.clusters().getClusters.get(0) - - config.adminRoles(Set("Admin").asJava) - config.allowedClusters(Set(cluster).asJava) - adminClient.tenants.createTenant(tenant, config.build) - adminClient.namespaces.createNamespace(s"${tenant}/${namespace}") - adminClient.namespaces.grantPermissionOnNamespace(s"${tenant}/${namespace}", permission, Set().asJava) - - page.navigate(s"/tenants/${tenant}/namespaces/${namespace}/permissions/") - - permissionsPage.revokeButton.click() - permissionsPage.revokeConfirmButton.click() - - page.waitForTimeout(1000) - - val isRevoked = adminClient.namespaces.getPermissions(s"${tenant}/${namespace}").get(permission) - - assertTrue(isRevoked == null) - }, - - ) -} diff --git a/e2e/src/test/scala/smoke/OverviewSmokeSpec.scala b/e2e/src/test/scala/smoke/OverviewSmokeSpec.scala new file mode 100644 index 000000000..124eb8074 --- /dev/null +++ b/e2e/src/test/scala/smoke/OverviewSmokeSpec.scala @@ -0,0 +1,19 @@ +package smoke + +import harness.DekafSuite +import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat + +/** Phase-0 smoke: proves the harness wiring (browser + baseURL + navigation) end-to-end. + * Requires a running stack (see e2e/README.md §2). */ +class OverviewSmokeSpec extends DekafSuite: + + test("app boots to the instance overview without a login gate") { + page.navigate("/overview") + assertThat(page.getByText("Pulsar Instance")).isVisible() + } + + test("PulsarAdmin oracle is reachable and a fresh topic can be arranged") { + val topicFqn = fixtures.freshTopic() + fixtures.produceStrings(topicFqn, 3) + assert(topicFqn.startsWith("persistent://")) + } diff --git a/server/src/main/scala/consumer/session_runner/ConsumerSessionRunner.scala b/server/src/main/scala/consumer/session_runner/ConsumerSessionRunner.scala index 9901e20be..9cbae16ff 100644 --- a/server/src/main/scala/consumer/session_runner/ConsumerSessionRunner.scala +++ b/server/src/main/scala/consumer/session_runner/ConsumerSessionRunner.scala @@ -67,7 +67,20 @@ case class ConsumerSessionRunner( ) if !messageFilterChainResult.isOk then - createAndSendResponse(Seq.empty) + // Session-level filter miss: emit the SAME count-only placeholder the drained / + // target-filter-miss path (`case None` above) sends - NOT an empty (zero-message) + // response. On a zero-message response the client throws reading the trailing + // counters (ConsumerSession.tsx: newMessages[newMessages.length - 1] + // .getNumMessageProcessed()), which kills the stream handler - so the session + // halted at the first non-matching message. A count-only placeholder advances + // the processed counter, renders no row, and keeps the stream flowing (matching + // the per-target filter, which never halts). + val filteredMsgPb = consumerPb.Message( + numMessageProcessed = numMessageProcessed, + numMessageSent = numMessageSent + ) + val filterErrors = if isDebug then messageFilterChainResult.results.flatMap(r => r.error) else Vector.empty + createAndSendResponse(Seq(filteredMsgPb), filterErrors) val coloringRuleChainResult: Vector[ChainTestResult] = if sessionConfig.coloringRuleChain.isEnabled then sessionConfig.coloringRuleChain.coloringRules diff --git a/server/src/main/scala/library/Library.scala b/server/src/main/scala/library/Library.scala index fa5d86530..cc3881e41 100644 --- a/server/src/main/scala/library/Library.scala +++ b/server/src/main/scala/library/Library.scala @@ -21,22 +21,39 @@ case class LibraryDb( object Library: def createAndRefreshDb(rootDir: String): Library = + os.makeDir.all(os.Path(rootDir, os.pwd)) // scoped per-connection subdirs may not exist yet val library = Library() library.rootDir = rootDir library.refreshDb() library + // Item ids become file names - restrict them to a separator-free charset so a crafted id + // (e.g. `../../…`) can never escape the library directory. Real ids are UUIDs. + private val SafeItemId = "^[A-Za-z0-9_-]{1,200}$".r + class Library: private var rootDir = "./data" private var db = LibraryDb(itemsById = Map.empty) private val logger: Logger = Logger(getClass.getName) - + def size: Int = db.itemsById.size + private def requireSafeItemId(itemId: LibraryItemId): Unit = + if Library.SafeItemId.findFirstIn(itemId).isEmpty then + throw new IllegalArgumentException( + s"Invalid library item id - only alphanumerics, '_' and '-' are allowed." + ) + def writeItem(item: LibraryItem): Unit = val itemId = item.spec.metadata.id + requireSafeItemId(itemId) if item.spec.metadata.name.isEmpty then throw new Exception(s"Library item $itemId should have a name.") + // An item with no contexts can never be returned by any search - reject instead of orphaning. + if item.metadata.availableForContexts.isEmpty then + throw new IllegalArgumentException( + s"Library item $itemId must be available in at least one context; an item without contexts would be unreachable." + ) val fileName = s"$itemId.binpb" val filePath = os.Path(fileName, os.Path(rootDir, os.pwd)) @@ -50,6 +67,7 @@ class Library: refreshDb() def deleteItem(itemId: LibraryItemId): Unit = + requireSafeItemId(itemId) val fileName = s"$itemId.binpb" val filePath = os.Path(fileName, os.Path(rootDir, os.pwd)) @@ -68,24 +86,21 @@ class Library: ) ) - filter.contexts match - case List() => List.empty - case _ => - val dbItems = db.itemsById.values.toList - val byTypes = - if filter.types.isEmpty - then dbItems - else - dbItems.filter(item => - val metadata = item.spec.metadata - filter.types.contains(metadata.`type`) - ) - val byContexts = - if filter.contexts.isEmpty - then byTypes - else getItemsByContexts(byTypes, filter.contexts) - - byContexts + // An empty contexts filter used to return an empty list - indistinguishable from a genuine + // "no matches". It is a caller error and must fail loudly (mapped to INVALID_ARGUMENT). + if filter.contexts.isEmpty then + throw new IllegalArgumentException("Search contexts must not be empty.") + + val dbItems = db.itemsById.values.toList + val byTypes = + if filter.types.isEmpty + then dbItems + else + dbItems.filter(item => + val metadata = item.spec.metadata + filter.types.contains(metadata.`type`) + ) + getItemsByContexts(byTypes, filter.contexts) private def scan(): LibraryScanResults = os.list(os.Path(rootDir, os.pwd)) diff --git a/server/src/main/scala/library/LibraryServiceImpl.scala b/server/src/main/scala/library/LibraryServiceImpl.scala index 5ca318f11..36ae23d6c 100644 --- a/server/src/main/scala/library/LibraryServiceImpl.scala +++ b/server/src/main/scala/library/LibraryServiceImpl.scala @@ -30,6 +30,9 @@ import java.util.concurrent.{CompletableFuture, TimeUnit} import scala.concurrent.duration.{Duration, SECONDS} val config = Await.result(readConfigAsync, Duration(10, SECONDS)) +// One FLAT library dir per Dekaf instance - by design. A Dekaf instance always serves exactly one +// Pulsar connection (docker: one dekaf per pulsar; desktop: each saved connection gets its own +// DEKAF_DATA_DIR), so per-connection isolation lives at the deployment layer, not in here. val libraryRoot = s"${config.dataDir.get}/library" class LibraryServiceImpl extends pb.LibraryServiceGrpc.LibraryService: @@ -48,6 +51,10 @@ class LibraryServiceImpl extends pb.LibraryServiceGrpc.LibraryService: val status: Status = Status(code = Code.OK.index) Future.successful(pb.SaveLibraryItemResponse(status = Some(status))) } catch { + case err: IllegalArgumentException => + logger.warn(s"Rejected library item save: ${err.getMessage}") + val status: Status = Status(code = Code.INVALID_ARGUMENT.index, message = s"Unable to save library item. ${err.getMessage}") + Future.successful(pb.SaveLibraryItemResponse(status = Some(status))) case err: Exception => logger.warn(s"Failed to save library item: ${err.getMessage}") val status: Status = Status(code = Code.INTERNAL.index, message = s"Unable to save library item. ${err.getMessage}}") @@ -65,6 +72,10 @@ class LibraryServiceImpl extends pb.LibraryServiceGrpc.LibraryService: val status: Status = Status(code = Code.OK.index) Future.successful(pb.DeleteLibraryItemResponse(status = Some(status))) } catch { + case e: IllegalArgumentException => + logger.warn(s"Rejected library item delete: ${e.getMessage}") + val status: Status = Status(code = Code.INVALID_ARGUMENT.index, message = s"Unable to delete library item. ${e.getMessage}") + Future.successful(pb.DeleteLibraryItemResponse(status = Some(status))) case e: Exception => logger.warn(s"Failed to delete library item: ${e.getMessage}") val status: Status = Status(code = Code.INTERNAL.index, message = "Unable to delete library item") @@ -109,6 +120,10 @@ class LibraryServiceImpl extends pb.LibraryServiceGrpc.LibraryService: val status: Status = Status(code = Code.OK.index) Future.successful(pb.ListLibraryItemsResponse(status = Some(status), items = libraryItemsPb)) } catch { + case e: IllegalArgumentException => + logger.warn(s"Rejected library items list: ${e.getMessage}") + val status: Status = Status(code = Code.INVALID_ARGUMENT.index, message = s"Unable to list library items. ${e.getMessage}") + Future.successful(pb.ListLibraryItemsResponse(status = Some(status))) case e: Exception => logger.warn(s"Failed to list library items: ${e.getMessage}") val status: Status = Status(code = Code.INTERNAL.index, message = s"Unable to list library items. ${e.getMessage}") @@ -141,6 +156,10 @@ class LibraryServiceImpl extends pb.LibraryServiceGrpc.LibraryService: itemCountPerType = itemCountPerType )) } catch { + case e: IllegalArgumentException => + logger.warn(s"Rejected library items count: ${e.getMessage}") + val status: Status = Status(code = Code.INVALID_ARGUMENT.index, message = s"Unable to get library items count. ${e.getMessage}") + Future.successful(pb.GetLibraryItemsCountResponse(status = Some(status))) case e: Exception => logger.warn(s"Failed to get library items count: ${e.getMessage}") val status: Status = Status(code = Code.INTERNAL.index, message = s"Unable to get library items count. ${e.getMessage}") diff --git a/server/src/main/scala/pulsar_auth/Client.scala b/server/src/main/scala/pulsar_auth/Client.scala index c25966b4c..772a246c5 100644 --- a/server/src/main/scala/pulsar_auth/Client.scala +++ b/server/src/main/scala/pulsar_auth/Client.scala @@ -26,37 +26,44 @@ def getThreadFactory(poolName: String) = val sharedTimer = new HashedWheelTimer(getThreadFactory("shared-pulsar-timer"), 1, TimeUnit.MILLISECONDS) def makePulsarAdmin(pulsarAuth: PulsarAuth): Either[Throwable, PulsarAdmin] = - var builder = PulsarAdmin.builder.serviceHttpUrl(config.pulsarWebUrl.get) - - builder = tls.configureAdminClient(builder, config) - - val pulsarCredentials = pulsarAuth.current match - case None => defaultPulsarAuth.credentials.get("Default") - case Some(c) => pulsarAuth.credentials.get(c) - pulsarCredentials match - case None => Left(new Exception("No credentials found for Pulsar Admin")) - case Some(c) => - c match - case cr: JwtCredentials => builder.authentication(AuthenticationFactory.token(cr.token)) - case cr: OAuth2Credentials => - builder.authentication( - AuthenticationFactoryOAuth2.clientCredentials( - URL.createURL(cr.issuerUrl), - URL.createURL(cr.privateKey), - cr.audience.orNull, - cr.scope.orNull + // The WHOLE construction is Try-wrapped: `builder.authentication(pluginClass, params)` throws on + // an unloadable plugin or bad params, and an escaped exception here used to kill every gRPC call + // carrying that cookie instead of degrading to an auth error. + Try { + var builder = PulsarAdmin.builder.serviceHttpUrl(config.pulsarWebUrl.get) + + builder = tls.configureAdminClient(builder, config) + + val pulsarCredentials = pulsarAuth.current match + case None => defaultPulsarAuth.credentials.get("Default") + case Some(c) => pulsarAuth.credentials.get(c) + pulsarCredentials match + case None => throw new Exception("No credentials found for Pulsar Admin") + case Some(c) => + c match + case cr: JwtCredentials => builder.authentication(AuthenticationFactory.token(cr.token)) + case cr: OAuth2Credentials => + builder.authentication( + AuthenticationFactoryOAuth2.clientCredentials( + URL.createURL(cr.issuerUrl), + URL.createURL(cr.privateKey), + cr.audience.orNull, + cr.scope.orNull + ) ) - ) - case cr: AuthParamsStringCredentials => - builder.authentication(cr.authPluginClassName, cr.authParams) - case _ => Left(new Exception("Unsupported credentials type")) - - Try(builder.build) match { - case Success(value) => Right(value) - case Failure(_) => Left(new Exception("Wrong credentials for Pulsar Admin")) + case cr: AuthParamsStringCredentials => + builder.authentication(cr.authPluginClassName, cr.authParams) + case _: EmptyCredentials => () // anonymous/default access - no authentication + case _ => throw new Exception("Unsupported credentials type") + + builder.build + } match { + case Success(value) => Right(value) + case Failure(err) => Left(new Exception(s"Wrong credentials for Pulsar Admin: ${err.getMessage}")) } def makePulsarClient(pulsarAuth: PulsarAuth): Either[Throwable, PulsarClient] = + Try { val pulsarClientConfig = ClientConfigurationData() /* By default, for partitioned topics Pulsar client may use several threads. @@ -91,17 +98,22 @@ def makePulsarClient(pulsarAuth: PulsarAuth): Either[Throwable, PulsarClient] = .timer(sharedTimer) .eventLoopGroup(sharedEventLoopGroup) - Try(builder.build) match { - case Success(value) => Right(value) - case Failure(_) => Left(new Exception("Wrong credentials for Pulsar Client")) - } - -def configureAuth(pulsarAuth: PulsarAuth, pulsarClientConfig: ClientConfigurationData) = + builder.build + } match { + case Success(value) => Right(value) + case Failure(err) => Left(new Exception(s"Wrong credentials for Pulsar Client: ${err.getMessage}")) + } + +/** Configures authentication on the client config, or THROWS for missing/unsupported credentials. + * Called inside `makePulsarClient`'s Try, so a throw becomes that function's `Left`. (The previous + * version returned `Left` values that every caller discarded, so missing/unsupported credentials + * silently produced an anonymous client.) */ +def configureAuth(pulsarAuth: PulsarAuth, pulsarClientConfig: ClientConfigurationData): Unit = val pulsarCredentials = pulsarAuth.current match case None => defaultPulsarAuth.credentials.get("Default") case Some(c) => pulsarAuth.credentials.get(c) pulsarCredentials match - case None => Left(new Exception("No credentials found for Pulsar Admin")) + case None => throw new Exception("No credentials found for Pulsar Client") case Some(c) => c match case cr: JwtCredentials => pulsarClientConfig.setAuthentication(AuthenticationFactory.token(cr.token)) @@ -117,4 +129,5 @@ def configureAuth(pulsarAuth: PulsarAuth, pulsarClientConfig: ClientConfiguratio case cr: AuthParamsStringCredentials => pulsarClientConfig.setAuthPluginClassName(cr.authPluginClassName) pulsarClientConfig.setAuthParams(cr.authParams) - case _ => Left(new Exception("Unsupported credentials type")) + case _: EmptyCredentials => () // anonymous/default access - no authentication configured + case _ => throw new Exception("Unsupported credentials type") diff --git a/server/src/main/scala/pulsar_auth/PulsarAuthInterceptor.scala b/server/src/main/scala/pulsar_auth/PulsarAuthInterceptor.scala index 278b3e48b..bab50a9d1 100644 --- a/server/src/main/scala/pulsar_auth/PulsarAuthInterceptor.scala +++ b/server/src/main/scala/pulsar_auth/PulsarAuthInterceptor.scala @@ -99,24 +99,36 @@ class PulsarAuthInterceptor extends ServerInterceptor: var ctx = Context.current().withValue(RequestContext.pulsarAuth, pulsarAuth) - val pulsarAdmin = ClientsCache.getPulsarAdmin(pulsarAuth) - pulsarAdmin match - case Left(_) => cancelWithCredentials(ctx, pulsarAuth) - case Right(pa) => ctx = ctx.withValue(RequestContext.pulsarAdmin, pa) - - val pulsarClient = ClientsCache.getPulsarClient(pulsarAuth) - pulsarClient match - case Left(_) => cancelWithCredentials(ctx, pulsarAuth) - case Right(pc) => ctx = ctx.withValue(RequestContext.pulsarClient, pc) + // Credential MANAGEMENT must keep working even when the current credentials cannot build a + // Pulsar client - that is exactly when the user needs the manager to inspect the list and + // switch back to Default. Those endpoints only read the parsed cookie, so skip client + // construction for them; every other service gets clients attached (or is cancelled). + val serviceName = call.getMethodDescriptor.getServiceName + val isAuthManagement = serviceName != null && serviceName.endsWith("PulsarAuthService") + + if !isAuthManagement then + ClientsCache.getPulsarAdmin(pulsarAuth) match + case Left(err) => return rejectUnauthenticated(call, pulsarAuth, err) + case Right(pa) => ctx = ctx.withValue(RequestContext.pulsarAdmin, pa) + + ClientsCache.getPulsarClient(pulsarAuth) match + case Left(err) => return rejectUnauthenticated(call, pulsarAuth, err) + case Right(pc) => ctx = ctx.withValue(RequestContext.pulsarClient, pc) Contexts.interceptCall(ctx, call, metadata, next) - private def cancelWithCredentials(context: Context, pulsarAuth: PulsarAuth) = - context - .withCancellation() - .cancel( - Status.UNAUTHENTICATED - .withDescription(s"Invalid credentials: ${pulsarAuth.current.getOrElse("")}") - .asRuntimeException() - ) + /** A request whose credentials cannot build a client must STOP here with UNAUTHENTICATED. + * (The previous implementation canceled a derived Context and then discarded it, so the call + * proceeded anyway and handlers crashed on the missing client values.) */ + private def rejectUnauthenticated[ReqT, RespT]( + call: ServerCall[ReqT, RespT], + pulsarAuth: PulsarAuth, + err: Throwable + ): ServerCall.Listener[ReqT] = + call.close( + Status.UNAUTHENTICATED + .withDescription(s"Invalid credentials: ${pulsarAuth.current.getOrElse("")}. ${err.getMessage}"), + new Metadata() + ) + new ServerCall.Listener[ReqT] {} diff --git a/server/src/test/scala/library/LibraryBugRegressionsTest.scala b/server/src/test/scala/library/LibraryBugRegressionsTest.scala new file mode 100644 index 000000000..26795dbe0 --- /dev/null +++ b/server/src/test/scala/library/LibraryBugRegressionsTest.scala @@ -0,0 +1,90 @@ +package library + +import zio.* +import zio.test.* +import zio.test.Assertion.* +import _root_.library.managed_items.{ManagedMarkdownDocument, ManagedMarkdownDocumentSpec} + +/** Executable regressions for the formerly-known Library bugs (see e2e/README.md §6), driven at the unit + * level against a temporary directory - the e2e module cannot reach LibraryService directly. + * BUG-17: item ids become file names → charset-guarded against path traversal. + * BUG-8: an item with no contexts would be unreachable → rejected on write. + * BUG-9: an empty search-contexts filter is a caller error → IllegalArgumentException + * (mapped to INVALID_ARGUMENT by the service), distinguishable from a no-match. + * (BUG-16 "per-connection storage scoping" was reclassified as by-design and reverted: a Dekaf + * instance always serves ONE Pulsar - isolation lives at the deployment layer, e.g. the desktop + * app gives each saved connection its own DEKAF_DATA_DIR. The library dir stays flat.) + */ +object LibraryBugRegressionsTest extends ZIOSpecDefault { + + private def tempDir(): os.Path = + os.temp.dir(prefix = "library-bug-regressions") + + private def markdownItem(id: String, contexts: Vector[ResourceMatcher]): LibraryItem = + LibraryItem( + metadata = LibraryItemMetadata(updatedAt = "2026-07-19T00:00:00Z", availableForContexts = contexts), + spec = ManagedMarkdownDocument( + metadata = ManagedItemMetadata( + `type` = ManagedItemType.MarkdownDocument, + id = id, + name = s"item-$id", + descriptionMarkdown = "" + ), + spec = ManagedMarkdownDocumentSpec(markdown = "hello") + ) + ) + + private def tenantContext(tenant: String): ResourceMatcher = + ResourceMatcher(matcher = TenantMatcher(matcher = ExactTenantMatcher(tenant = tenant))) + + def spec = suite(this.getClass.toString)( + test("BUG-17: a path-traversal item id is rejected on write and delete") { + val root = tempDir() + val library = Library.createAndRefreshDb(root.toString) + val evil = markdownItem("../../evil", Vector(tenantContext("t1"))) + val write = scala.util.Try(library.writeItem(evil)) + val delete = scala.util.Try(library.deleteItem("../../evil")) + assertTrue( + write.isFailure, + write.failed.get.isInstanceOf[IllegalArgumentException], + delete.isFailure, + // nothing escaped the library root - the id has TWO ups, so the true escape + // target is two levels above the root (also check one level, cheap) + !os.exists(root / os.up / "evil.binpb"), + !os.exists(root / os.up / os.up / "evil.binpb") + ) + }, + test("BUG-8: an item with zero contexts is rejected, not orphaned") { + val root = tempDir() + val library = Library.createAndRefreshDb(root.toString) + val orphan = markdownItem("a1b2c3", Vector.empty) + val write = scala.util.Try(library.writeItem(orphan)) + assertTrue( + write.isFailure, + write.failed.get.isInstanceOf[IllegalArgumentException], + library.size == 0 + ) + }, + test("BUG-9: an empty search-contexts filter throws, distinguishable from a no-match") { + val root = tempDir() + val library = Library.createAndRefreshDb(root.toString) + library.writeItem(markdownItem("abc123", Vector(tenantContext("t1")))) + + val emptyFilter = scala.util.Try( + library.listItems(ListItemsFilter(types = Vector.empty, contexts = List.empty)) + ) + val noMatch = library.listItems( + ListItemsFilter(types = Vector.empty, contexts = List(tenantContext("other-tenant"))) + ) + val hit = library.listItems( + ListItemsFilter(types = Vector.empty, contexts = List(tenantContext("t1"))) + ) + assertTrue( + emptyFilter.isFailure, + emptyFilter.failed.get.isInstanceOf[IllegalArgumentException], + noMatch.isEmpty, // a genuine no-match is an ordinary empty result + hit.size == 1 + ) + } + ) +} diff --git a/server/src/test/scala/pulsar_auth/ClientConstructionTest.scala b/server/src/test/scala/pulsar_auth/ClientConstructionTest.scala new file mode 100644 index 000000000..6c4ec45dc --- /dev/null +++ b/server/src/test/scala/pulsar_auth/ClientConstructionTest.scala @@ -0,0 +1,56 @@ +package pulsar_auth + +import zio.* +import zio.test.* +import zio.test.Assertion.* + +/** Client-construction contracts behind the auth interceptor (BUG-19's server layer): + * - Empty (anonymous/default) credentials MUST build - turning the historically-discarded + * "unsupported" branch into a throw once broke every anonymous request. + * - Credentials whose Authentication cannot be constructed (e.g. a malformed OAuth2 URL) MUST + * come back as Left - never an escaped exception (which used to kill the whole call chain). + * - A well-formed AuthParamsString builds even for an unknown plugin class: Pulsar loads the + * plugin lazily at connect time, so construction is Right (the interceptor's UNAUTHENTICATED + * path is instead exercised end-to-end by the NAV-14 browser test). + * Construction is offline (no broker connection is made), so these run without a stack. + */ +object ClientConstructionTest extends ZIOSpecDefault { + + private def auth(name: String, credentials: Credentials): PulsarAuth = + PulsarAuth(credentials = Map(name -> credentials), current = Some(name)) + + def spec = suite(this.getClass.toString)( + test("empty credentials build an anonymous admin client (Right)") { + val result = makePulsarAdmin(auth("anon", EmptyCredentials(`type` = "empty"))) + assertTrue(result.isRight) + }, + test("a malformed OAuth2 issuer URL yields Left, not an escaped exception") { + val bad = OAuth2Credentials( + `type` = "oauth2", + issuerUrl = "::: not a url :::", + privateKey = "data:application/json;base64,e30=", + audience = None, + scope = None + ) + val admin = makePulsarAdmin(auth("bad", bad)) + val client = makePulsarClient(auth("bad", bad)) + assertTrue(admin.isLeft, client.isLeft) + }, + test("a well-formed AuthParamsString builds (plugin is loaded lazily at connect)") { + val aps = AuthParamsStringCredentials( + `type` = "authParamsString", + authPluginClassName = "org.apache.pulsar.client.impl.auth.AuthenticationToken", + authParams = "token:abc" + ) + assertTrue(makePulsarAdmin(auth("aps", aps)).isRight) + }, + test("a JWT credential constructs offline (Right)") { + val jwt = JwtCredentials(`type` = "jwt", token = "header.payload.signature") + assertTrue(makePulsarAdmin(auth("jwt", jwt)).isRight) + }, + test("a current name with no matching credentials yields Left") { + val dangling = PulsarAuth(credentials = Map.empty, current = Some("ghost")) + assertTrue(makePulsarAdmin(dangling).isLeft, makePulsarClient(dangling).isLeft) + } + ) +} diff --git a/ui/__mocks__/fileMock.js b/ui/__mocks__/fileMock.js new file mode 100644 index 000000000..86059f362 --- /dev/null +++ b/ui/__mocks__/fileMock.js @@ -0,0 +1 @@ +module.exports = 'test-file-stub'; diff --git a/ui/__mocks__/styleMock.js b/ui/__mocks__/styleMock.js new file mode 100644 index 000000000..f053ebf79 --- /dev/null +++ b/ui/__mocks__/styleMock.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/ui/components/InstancePage/Configuration/Configuration.tsx b/ui/components/InstancePage/Configuration/Configuration.tsx index a6f3ca421..a2bdced3b 100644 --- a/ui/components/InstancePage/Configuration/Configuration.tsx +++ b/ui/components/InstancePage/Configuration/Configuration.tsx @@ -62,10 +62,11 @@ const Configuration = () => {
    - setPropertyFilter(v)} placeholder="Search by property name" focusOnMount={true} clearable={true} /> + setPropertyFilter(v)} placeholder="Search by property name" focusOnMount={true} clearable={true} />
    setIsShowDynamicOnly(!isShowDynamicOnly)} type="primary" @@ -106,7 +107,7 @@ const Configuration = () => {
    {allKeys.map((key) => { return ( - + {actionsList.map(action => ( {actionsList.map(action => ( {(maskedCredentials || []).sort((a, b) => a.name.localeCompare(b.name, 'en', { numeric: true })).map((item) => ( - +
    = (props) => {props.configKey}
    - setInputValue(v)} placeholder="Enter new value" value={inputValue} focusOnMount={true} /> + setInputValue(v)} placeholder="Enter new value" value={inputValue} focusOnMount={true} />
    -
    -
    @@ -245,6 +246,7 @@ export const DynamicConfigValue: React.FC = (props) => {props.configValue} setIsShowEditor(!isShowEditor)} action={isShowEditor ? { type: 'predefined', action: 'close' } : { type: 'predefined', action: 'edit' }} /> diff --git a/ui/components/InstancePage/CreateTenantPage/CreateTenantPage.tsx b/ui/components/InstancePage/CreateTenantPage/CreateTenantPage.tsx index f4e137edf..a7c94af39 100644 --- a/ui/components/InstancePage/CreateTenantPage/CreateTenantPage.tsx +++ b/ui/components/InstancePage/CreateTenantPage/CreateTenantPage.tsx @@ -58,8 +58,14 @@ const CreateTenantPage: React.FC = () => { req.setTenantName(tenantName); req.setTenantInfo(tenantInfo); - const res = await tenantServiceClient.createTenant(req, null).catch(err => { `Unable to create tenant: ${err}` }); - if (res !== undefined && res.getStatus()?.getCode() !== Code.OK) { + const res = await tenantServiceClient.createTenant(req, null).catch(err => { + notifyError(`Unable to create tenant: ${err}`); + return undefined; + }); + if (res === undefined) { + return; // transport failure - stay on the form, never a silent "success" + } + if (res.getStatus()?.getCode() !== Code.OK) { notifyError(`Unable to create tenant: ${res.getStatus()?.getMessage()}`); return; } diff --git a/ui/components/InstancePage/ResourceGroups/ResourceGroupForm/DeleteDialog/DeleteDialog.tsx b/ui/components/InstancePage/ResourceGroups/ResourceGroupForm/DeleteDialog/DeleteDialog.tsx index f7d866e8e..d0ad1c5aa 100644 --- a/ui/components/InstancePage/ResourceGroups/ResourceGroupForm/DeleteDialog/DeleteDialog.tsx +++ b/ui/components/InstancePage/ResourceGroups/ResourceGroupForm/DeleteDialog/DeleteDialog.tsx @@ -26,8 +26,14 @@ const DeleteDialog = (props: Props) => { const req = new pb.DeleteResourceGroupRequest(); req.setName(props.resourceGroup); - const res = await brokersServiceClient.deleteResourceGroup(req, null).catch(err => { `Unable to delete resource group: ${err}` }); - if (res !== undefined && res.getStatus()?.getCode() !== Code.OK) { + const res = await brokersServiceClient.deleteResourceGroup(req, null).catch(err => { + notifyError(`Unable to delete resource group: ${err}`); + return undefined; + }); + if (res === undefined) { + return; // transport failure - keep the dialog open, never a silent "success" + } + if (res.getStatus()?.getCode() !== Code.OK) { notifyError(`Unable to delete resource group: ${res.getStatus()?.getMessage()}`); return; } diff --git a/ui/components/InstancePage/ResourceGroups/ResourceGroupForm/ResourceGroupForm.tsx b/ui/components/InstancePage/ResourceGroups/ResourceGroupForm/ResourceGroupForm.tsx index 69a3ce63b..f0d02d548 100644 --- a/ui/components/InstancePage/ResourceGroups/ResourceGroupForm/ResourceGroupForm.tsx +++ b/ui/components/InstancePage/ResourceGroups/ResourceGroupForm/ResourceGroupForm.tsx @@ -119,8 +119,14 @@ const ResourceGroupForm = (props: Props) => { const resourceGroupPb = resourceGroupPbFromFormValue(formValue); req.setResourceGroup(resourceGroupPb); - const res = await brokersServiceClient.createResourceGroup(req, null).catch(err => { `Unable to create resource group: ${err}` }); - if (res !== undefined && res.getStatus()?.getCode() !== Code.OK) { + const res = await brokersServiceClient.createResourceGroup(req, null).catch(err => { + notifyError(`Unable to create resource group: ${err}`); + return undefined; + }); + if (res === undefined) { + return; // transport failure - stay on the form, never a silent "success" + } + if (res.getStatus()?.getCode() !== Code.OK) { notifyError(`Unable to create resource group: ${res.getStatus()?.getMessage()}`); return; } @@ -134,8 +140,14 @@ const ResourceGroupForm = (props: Props) => { const resourceGroupPb = resourceGroupPbFromFormValue(formValue); req.setResourceGroup(resourceGroupPb); - const res = await brokersServiceClient.updateResourceGroup(req, null).catch(err => { `Unable to update resource group: ${err}` }); - if (res !== undefined && res.getStatus()?.getCode() !== Code.OK) { + const res = await brokersServiceClient.updateResourceGroup(req, null).catch(err => { + notifyError(`Unable to update resource group: ${err}`); + return undefined; + }); + if (res === undefined) { + return; // transport failure - stay on the form, never a silent "success" + } + if (res.getStatus()?.getCode() !== Code.OK) { notifyError(`Unable to update resource group: ${res.getStatus()?.getMessage()}`); return; } diff --git a/ui/components/NamespacePage/CreateTopic/CreateTopic.tsx b/ui/components/NamespacePage/CreateTopic/CreateTopic.tsx index 28004bb18..c5a0749ae 100644 --- a/ui/components/NamespacePage/CreateTopic/CreateTopic.tsx +++ b/ui/components/NamespacePage/CreateTopic/CreateTopic.tsx @@ -48,7 +48,7 @@ const CreateTopic: React.FC = (props) => { } }, [brokersConfig]); - const topicNameInput = + const topicNameInput = const topicPersistencyInput = ( onChange={setTopicPersistency} @@ -62,6 +62,7 @@ const CreateTopic: React.FC = (props) => { const topicPartitioningInput = ( + testId="create-topic-partitioning" onChange={setTopicPartitioning} value={topicPartitioning} list={[ diff --git a/ui/components/NamespacePage/NamespaceDetails/NamespaceBundlesEditor/NamespaceBundlesEditor.tsx b/ui/components/NamespacePage/NamespaceDetails/NamespaceBundlesEditor/NamespaceBundlesEditor.tsx index 402fc5b1f..b47f91d8a 100644 --- a/ui/components/NamespacePage/NamespaceDetails/NamespaceBundlesEditor/NamespaceBundlesEditor.tsx +++ b/ui/components/NamespacePage/NamespaceDetails/NamespaceBundlesEditor/NamespaceBundlesEditor.tsx @@ -100,6 +100,7 @@ const NamespaceBundlesEditor: React.FC = (props) => /> { modals.push({ id: "clear-backlog", diff --git a/ui/components/NamespacePage/NamespaceDetails/Permissions/Permissions.tsx b/ui/components/NamespacePage/NamespaceDetails/Permissions/Permissions.tsx index a558bf0fc..84a7089bd 100644 --- a/ui/components/NamespacePage/NamespaceDetails/Permissions/Permissions.tsx +++ b/ui/components/NamespacePage/NamespaceDetails/Permissions/Permissions.tsx @@ -219,7 +219,7 @@ const Permissions: React.FC = (props) => {
    -
    +
    { @@ -246,6 +246,7 @@ const Permissions: React.FC = (props) => { onClick={() => grant(permission)} type='primary' text='Update' + testId={`permission-update-button-${permission.role}`} disabled={_.isEqual(permissions[index].actions, permission.actions)} className={s.Button} /> @@ -253,7 +254,7 @@ const Permissions: React.FC = (props) => { type='danger' text='Revoke' className={s.Button} - testId="permission-revoke-button" + testId={`permission-revoke-button-${permission.role}`} onClick={() => modals.push({ id: 'revoke-permission', title: `Revoke permission`, @@ -277,6 +278,7 @@ const Permissions: React.FC = (props) => {
    setFormValue({ ...formValue, role: v })} placeholder='user-role' @@ -292,7 +294,7 @@ const Permissions: React.FC = (props) => { -
    +
    { @@ -311,6 +313,7 @@ const Permissions: React.FC = (props) => { onClick={() => grant(formValue, true)} type='primary' text='Grant' + testId="permission-grant-button" disabled={formValue.role.length < 1} className={s.Button} /> diff --git a/ui/components/NamespacePage/NamespaceDetails/fields/compaction-threshold.tsx b/ui/components/NamespacePage/NamespaceDetails/fields/compaction-threshold.tsx index 5cfba91f8..a17f1bfa8 100644 --- a/ui/components/NamespacePage/NamespaceDetails/fields/compaction-threshold.tsx +++ b/ui/components/NamespacePage/NamespaceDetails/fields/compaction-threshold.tsx @@ -139,6 +139,7 @@ export const FieldInput: React.FC = (props) => { <>
    + testId="compaction-threshold-type-select" list={[ { type: 'item', title: 'Inherited from broker config', value: 'inherited-from-broker-config' }, { type: 'item', title: 'Automatic compaction disabled', value: 'automatic-compaction-disabled' }, @@ -159,6 +160,7 @@ export const FieldInput: React.FC = (props) => {
    {value.type === 'specified-for-this-namespace' && ( { if (size > 0) { diff --git a/ui/components/NamespacePage/NamespaceDetails/fields/deduplication.tsx b/ui/components/NamespacePage/NamespaceDetails/fields/deduplication.tsx index 5beaa3788..8d7b9fca6 100644 --- a/ui/components/NamespacePage/NamespaceDetails/fields/deduplication.tsx +++ b/ui/components/NamespacePage/NamespaceDetails/fields/deduplication.tsx @@ -94,6 +94,7 @@ export const FieldInput: React.FC = (props) => { {({ value, onChange }) => { return ( + testId='deduplication-type-select' list={[ { type: 'item', value: 'inherited-from-broker-config', title: 'Inherited from broker config' }, { type: 'item', value: 'enabled', title: 'Enabled' }, diff --git a/ui/components/NamespacePage/NamespaceDetails/fields/delayed-delivery.tsx b/ui/components/NamespacePage/NamespaceDetails/fields/delayed-delivery.tsx index 3f031bf9f..b804ce8ab 100644 --- a/ui/components/NamespacePage/NamespaceDetails/fields/delayed-delivery.tsx +++ b/ui/components/NamespacePage/NamespaceDetails/fields/delayed-delivery.tsx @@ -118,6 +118,7 @@ export const FieldInput: React.FC = (props) => { <>
    + testId='delayed-delivery-type-select' list={[ { type: 'item', value: 'inherited-from-broker-config', title: 'Inherited from broker config' }, { type: 'item', value: 'enabled', title: 'Enabled' }, @@ -148,6 +149,7 @@ export const FieldInput: React.FC = (props) => { )} /> onChange({ ...value, tickTimeMs: seconds * 1000 })} /> diff --git a/ui/components/NamespacePage/NamespaceDetails/fields/dispatch-rate.tsx b/ui/components/NamespacePage/NamespaceDetails/fields/dispatch-rate.tsx index 9bfaa7a7c..f3f30c9dd 100644 --- a/ui/components/NamespacePage/NamespaceDetails/fields/dispatch-rate.tsx +++ b/ui/components/NamespacePage/NamespaceDetails/fields/dispatch-rate.tsx @@ -117,6 +117,7 @@ export const FieldInput: React.FC = (props) => { <>
    + testId='dispatch-rate-type-select' list={[ { type: 'item', value: 'inherited-from-broker-config', title: 'Inherited from broker config' }, { type: 'item', value: 'specified', title: 'Specified' }, @@ -146,7 +147,7 @@ export const FieldInput: React.FC = (props) => {
    Msg. dispatch rate
    - onChange({ ...value, rateInMsg: Math.floor(Number(v)) })} type='number' /> + onChange({ ...value, rateInMsg: Math.floor(Number(v)) })} type='number' />
    Period in seconds
    diff --git a/ui/components/NamespacePage/NamespaceDetails/fields/encryption-required.tsx b/ui/components/NamespacePage/NamespaceDetails/fields/encryption-required.tsx index 82b041183..deb5540ba 100644 --- a/ui/components/NamespacePage/NamespaceDetails/fields/encryption-required.tsx +++ b/ui/components/NamespacePage/NamespaceDetails/fields/encryption-required.tsx @@ -76,6 +76,7 @@ export const FieldInput: React.FC = (props) => { return (
    + testId='encryption-required-type-select' list={[ { type: 'item', value: 'required', title: 'Required' }, { type: 'item', value: 'not-required', title: 'Not required' }, diff --git a/ui/components/NamespacePage/NamespaceDetails/fields/max-consumers-per-topic.tsx b/ui/components/NamespacePage/NamespaceDetails/fields/max-consumers-per-topic.tsx index fd1750541..a6b6d6e30 100644 --- a/ui/components/NamespacePage/NamespaceDetails/fields/max-consumers-per-topic.tsx +++ b/ui/components/NamespacePage/NamespaceDetails/fields/max-consumers-per-topic.tsx @@ -116,6 +116,7 @@ export const FieldInput: React.FC = (props) => { <>
    + testId="max-consumers-per-topic-type-select" list={[ { type: 'item', value: 'inherited-from-broker-config', title: 'Inherited from broker config' }, { type: 'item', value: 'unlimited', title: 'Unlimited' }, @@ -134,6 +135,7 @@ export const FieldInput: React.FC = (props) => { {value.type === 'specified-for-this-namespace' && ( onChange({ type: 'specified-for-this-namespace', maxConsumersPerTopic: parseInt(v) })} /> diff --git a/ui/components/NamespacePage/NamespaceDetails/fields/max-unacked-messages-per-consumer.tsx b/ui/components/NamespacePage/NamespaceDetails/fields/max-unacked-messages-per-consumer.tsx index 37c993de0..a21e0a18a 100644 --- a/ui/components/NamespacePage/NamespaceDetails/fields/max-unacked-messages-per-consumer.tsx +++ b/ui/components/NamespacePage/NamespaceDetails/fields/max-unacked-messages-per-consumer.tsx @@ -112,6 +112,7 @@ export const FieldInput: React.FC = (props) => { <>
    + testId="max-unacked-consumer-type-select" list={[ { type: 'item', value: 'inherited-from-broker-config', title: 'Inherited from broker config' }, { type: 'item', value: 'unlimited', title: 'Unlimited' }, @@ -129,6 +130,7 @@ export const FieldInput: React.FC = (props) => {
    {value.type === 'specified-for-this-namespace' && ( onChange({ type: 'specified-for-this-namespace', maxUnackedMessagesPerConsumer: parseInt(v) })} diff --git a/ui/components/NamespacePage/NamespaceDetails/fields/offload-policies/drivers/FilesystemInput/FilesystemInput.tsx b/ui/components/NamespacePage/NamespaceDetails/fields/offload-policies/drivers/FilesystemInput/FilesystemInput.tsx index f6f602409..95c0882df 100644 --- a/ui/components/NamespacePage/NamespaceDetails/fields/offload-policies/drivers/FilesystemInput/FilesystemInput.tsx +++ b/ui/components/NamespacePage/NamespaceDetails/fields/offload-policies/drivers/FilesystemInput/FilesystemInput.tsx @@ -24,6 +24,7 @@ const FilesystemInput: React.FC = (props) => { )} /> props.onChange({ ...props.value, fileSystemProfilePath: v })} placeholder="conf/filesystem_offload_core_site.xml" diff --git a/ui/components/NamespacePage/NamespaceDetails/fields/offload-policies/offload-policies.tsx b/ui/components/NamespacePage/NamespaceDetails/fields/offload-policies/offload-policies.tsx index 8b8ee68c5..310e5e074 100644 --- a/ui/components/NamespacePage/NamespaceDetails/fields/offload-policies/offload-policies.tsx +++ b/ui/components/NamespacePage/NamespaceDetails/fields/offload-policies/offload-policies.tsx @@ -106,6 +106,7 @@ export const FieldInput: React.FC = (props) => { help={Driver to use to offload old data to long term storage.} /> + testId='offload-driver-select' value={value.type} list={[ { type: 'item', value: 'inherited-from-broker-config', title: 'Inherited from broker config' }, @@ -148,6 +149,7 @@ export const FieldInput: React.FC = (props) => { )} /> onChange({ ...value, offloadersDirectory: v })} placeholder="offloaders" diff --git a/ui/components/NamespacePage/NamespaceDetails/fields/persistence.tsx b/ui/components/NamespacePage/NamespaceDetails/fields/persistence.tsx index 790f745e6..1ec353a38 100644 --- a/ui/components/NamespacePage/NamespaceDetails/fields/persistence.tsx +++ b/ui/components/NamespacePage/NamespaceDetails/fields/persistence.tsx @@ -144,6 +144,7 @@ export const FieldInput: React.FC = (props) => { <>
    + testId="persistence-type-select" list={[ { type: 'item', value: 'inherited-from-broker-config', title: 'Inherited from broker config' }, { type: 'item', value: 'specified-for-this-namespace', title: 'Specified for this namespace' } @@ -167,6 +168,7 @@ export const FieldInput: React.FC = (props) => {
    Ensemble onChange({ ...value, bookkeeperEnsemble: Number(v) })} value={String(value.bookkeeperEnsemble)} @@ -176,6 +178,7 @@ export const FieldInput: React.FC = (props) => {
    Write quorum onChange({ ...value, bookkeeperWriteQuorum: Number(v) })} value={String(value.bookkeeperWriteQuorum)} @@ -185,6 +188,7 @@ export const FieldInput: React.FC = (props) => {
    Ack quorum onChange({ ...value, bookkeeperAckQuorum: Number(v) })} value={String(value.bookkeeperAckQuorum)} diff --git a/ui/components/NamespacePage/NamespaceDetails/fields/replication-clusters.tsx b/ui/components/NamespacePage/NamespaceDetails/fields/replication-clusters.tsx index 8dc51bce7..0bd42a693 100644 --- a/ui/components/NamespacePage/NamespaceDetails/fields/replication-clusters.tsx +++ b/ui/components/NamespacePage/NamespaceDetails/fields/replication-clusters.tsx @@ -99,6 +99,8 @@ export const FieldInput: React.FC = (props) => { return ( + testId="replication-clusters-list" + addButtonTestId="replication-clusters-add" value={value.replicationClusters} getId={(v) => v} renderItem={(v) =>
    {v}
    } @@ -109,6 +111,7 @@ export const FieldInput: React.FC = (props) => { return ( + testId="replication-cluster-select" list={[{ type: 'empty', title: '' }, ...list]} value={v} onChange={(v) => onChange(v as string)} diff --git a/ui/components/NamespacePage/NamespaceDetails/fields/resource-group.tsx b/ui/components/NamespacePage/NamespaceDetails/fields/resource-group.tsx index c81b4ed49..b71705f7f 100644 --- a/ui/components/NamespacePage/NamespaceDetails/fields/resource-group.tsx +++ b/ui/components/NamespacePage/NamespaceDetails/fields/resource-group.tsx @@ -125,6 +125,7 @@ export const FieldInput: React.FC = (props) => { <>
    + testId="resource-group-type-select" list={[ { type: 'item', title: 'Undefined', value: 'undefined' }, { type: 'item', title: 'Specified for this namespace', value: 'specified-for-this-namespace' }, @@ -146,6 +147,7 @@ export const FieldInput: React.FC = (props) => { <>
    + testId="resource-group-select" list={resourceGroupsList.map(resourceGroup => { return { type: 'item', value: resourceGroup, title: resourceGroup } })} diff --git a/ui/components/NamespacePage/NamespaceDetails/fields/retention.tsx b/ui/components/NamespacePage/NamespaceDetails/fields/retention.tsx index 98a615ce7..4ec66b975 100644 --- a/ui/components/NamespacePage/NamespaceDetails/fields/retention.tsx +++ b/ui/components/NamespacePage/NamespaceDetails/fields/retention.tsx @@ -211,6 +211,7 @@ export const FieldInput: React.FC = (props) => { <>
    + testId="retention-type-select" value={value.type} list={[ { type: 'item', value: 'inherited-from-broker-config', title: 'Inherited from broker config' }, @@ -285,7 +286,7 @@ export const FieldInput: React.FC = (props) => { />
    {value.retentionSizeInMb.type === 'limit' && ( -
    +
    { diff --git a/ui/components/NamespacePage/NamespaceDetails/fields/schema-compatibility-strategy.tsx b/ui/components/NamespacePage/NamespaceDetails/fields/schema-compatibility-strategy.tsx index 1a34b5722..cb463508a 100644 --- a/ui/components/NamespacePage/NamespaceDetails/fields/schema-compatibility-strategy.tsx +++ b/ui/components/NamespacePage/NamespaceDetails/fields/schema-compatibility-strategy.tsx @@ -74,6 +74,7 @@ export const FieldInput: React.FC = (props) => { {({ value, onChange }) => { return ( + testId='schema-compatibility-type-select' list={strategies.map(s => ({ type: 'item', value: s, title: s.replace('SCHEMA_COMPATIBILITY_STRATEGY_', '') }))} value={value} onChange={onChange} diff --git a/ui/components/NamespacePage/Overview/NamespacePropertiesEditor/NamespacePropertiesEditor.tsx b/ui/components/NamespacePage/Overview/NamespacePropertiesEditor/NamespacePropertiesEditor.tsx index cd98187c0..2a77a8561 100644 --- a/ui/components/NamespacePage/Overview/NamespacePropertiesEditor/NamespacePropertiesEditor.tsx +++ b/ui/components/NamespacePage/Overview/NamespacePropertiesEditor/NamespacePropertiesEditor.tsx @@ -94,7 +94,7 @@ export const NamespacePropertiesEditor: FC = (pr
    )} /> - {mode === 'view' && setMode('edit')} title="Edit" />} + {mode === 'view' && setMode('edit')} title="Edit" />} {mode === 'edit' && setMode('view')} title="View" />}
    diff --git a/ui/components/SubscriptionPage/Overview/ExpireMessages/ExpireMessages.tsx b/ui/components/SubscriptionPage/Overview/ExpireMessages/ExpireMessages.tsx index 0c55cc052..c27eaee01 100644 --- a/ui/components/SubscriptionPage/Overview/ExpireMessages/ExpireMessages.tsx +++ b/ui/components/SubscriptionPage/Overview/ExpireMessages/ExpireMessages.tsx @@ -117,6 +117,7 @@ const ExpireMessages: React.FC = (props) => { + testId="expire-target-select" value={expirationTarget} onChange={setExpirationTarget} list={list} @@ -128,6 +129,7 @@ const ExpireMessages: React.FC = (props) => { setExpireByMessageId(value => { @@ -168,10 +170,12 @@ const ExpireMessages: React.FC = (props) => { {expirationTarget === 'expire-time-in-seconds' && - setExpireTimeInSeconds(timeInSeconds)} - /> +
    + setExpireTimeInSeconds(timeInSeconds)} + /> +
    }
    diff --git a/ui/components/SubscriptionPage/Overview/ResetCursor/ResetCursor.tsx b/ui/components/SubscriptionPage/Overview/ResetCursor/ResetCursor.tsx index 4b9a9437e..3e7e9799c 100644 --- a/ui/components/SubscriptionPage/Overview/ResetCursor/ResetCursor.tsx +++ b/ui/components/SubscriptionPage/Overview/ResetCursor/ResetCursor.tsx @@ -112,6 +112,7 @@ const ResetCursor: React.FC = (props) => { + testId="reset-target-select" value={resetCursorTarget} onChange={setResetCursorTarget} list={list} @@ -123,6 +124,7 @@ const ResetCursor: React.FC = (props) => { setResetCursorByMessageId(value => { @@ -164,6 +166,7 @@ const ResetCursor: React.FC = (props) => { = (props) => { + testId="skip-target-select" value={skipMessagesTarget} onChange={setSkipMessagesTarget} list={list} @@ -100,6 +101,7 @@ const SkipMessages: React.FC = (props) => { = (props) => { text: "Overview", onClick: () => { }, type: "regular", + active: Boolean(matchPath(routes.tenants.tenant.namespaces.namespace.topics.anyTopicPersistency.topic.subscriptions.subscription.overview._.path, pathname)), }, { linkTo: routes.tenants.tenant.namespaces.namespace.topics.anyTopicPersistency.topic.subscriptions.subscription.consumers._.get({ @@ -128,6 +129,7 @@ const SubscriptionPage: React.FC = (props) => { }), text: "Consumers", onClick: () => { }, + active: Boolean(matchPath(routes.tenants.tenant.namespaces.namespace.topics.anyTopicPersistency.topic.subscriptions.subscription.consumers._.path, pathname)), type: "regular", }, { diff --git a/ui/components/TenantPage/CreateNamespace/CreateNamespace.tsx b/ui/components/TenantPage/CreateNamespace/CreateNamespace.tsx index 623ec3abd..df351739f 100644 --- a/ui/components/TenantPage/CreateNamespace/CreateNamespace.tsx +++ b/ui/components/TenantPage/CreateNamespace/CreateNamespace.tsx @@ -86,9 +86,13 @@ const CreateNamespace: React.FC = (props) => { const res = await namespaceServiceClient .createNamespace(req, null) .catch((err) => { - `Unable to create namespace: ${err}`; + notifyError(`Unable to create namespace: ${err}`); + return undefined; }); - if (res !== undefined && res.getStatus()?.getCode() !== Code.OK) { + if (res === undefined) { + return; // transport failure - stay on the form, never a silent "success" + } + if (res.getStatus()?.getCode() !== Code.OK) { notifyError( `Unable to create namespace: ${res.getStatus()?.getMessage()}` ); @@ -102,6 +106,7 @@ const CreateNamespace: React.FC = (props) => { const namespaceNameInput = ( = (props) => { const adminRolesInput = ( + testId="tenant-admin-roles" value={ tenantInfo?.adminRoles.sort((a, b) => a.localeCompare(b, "en", { numeric: true }) @@ -185,6 +186,7 @@ const Configuration: React.FC = (props) => { const allowedClustersInput = ( + testId="tenant-allowed-clusters" value={ tenantInfo?.allowedClusters.sort((a, b) => a.localeCompare(b, "en", { numeric: true }) diff --git a/ui/components/TopicPage/Overview/UpdatePartitionedTopicButton/UpdatePartitionedTopicButton.tsx b/ui/components/TopicPage/Overview/UpdatePartitionedTopicButton/UpdatePartitionedTopicButton.tsx index a98903832..6ef59786f 100644 --- a/ui/components/TopicPage/Overview/UpdatePartitionedTopicButton/UpdatePartitionedTopicButton.tsx +++ b/ui/components/TopicPage/Overview/UpdatePartitionedTopicButton/UpdatePartitionedTopicButton.tsx @@ -15,6 +15,7 @@ const UpdatePartitionedTopicButton: React.FC { modals.push({ diff --git a/ui/components/TopicPage/Overview/ViewTopicPartitionsButton/ViewTopicPartitionsButton.tsx b/ui/components/TopicPage/Overview/ViewTopicPartitionsButton/ViewTopicPartitionsButton.tsx index eedba07a5..2d0a982a6 100644 --- a/ui/components/TopicPage/Overview/ViewTopicPartitionsButton/ViewTopicPartitionsButton.tsx +++ b/ui/components/TopicPage/Overview/ViewTopicPartitionsButton/ViewTopicPartitionsButton.tsx @@ -19,6 +19,7 @@ const ViewTopicPartitionsButton: React.FC = (pro { modals.push({ diff --git a/ui/components/TopicPage/Schema/AvroEditor/AvroEditor.tsx b/ui/components/TopicPage/Schema/AvroEditor/AvroEditor.tsx index 1165d9eaf..b80fd74df 100644 --- a/ui/components/TopicPage/Schema/AvroEditor/AvroEditor.tsx +++ b/ui/components/TopicPage/Schema/AvroEditor/AvroEditor.tsx @@ -77,6 +77,7 @@ const AvroEditor: React.FC = (props) => { ]} value={source} onChange={setSource} + testId="avro-source-select" />
    diff --git a/ui/components/TopicPage/Schema/Schema.tsx b/ui/components/TopicPage/Schema/Schema.tsx index 1146ede6d..e983120f5 100644 --- a/ui/components/TopicPage/Schema/Schema.tsx +++ b/ui/components/TopicPage/Schema/Schema.tsx @@ -291,7 +291,7 @@ type SchemaListEntryProps = { }; const SchemaListEntry: React.FC = (props) => { return ( -
    +
    Version: {props.version}
    diff --git a/ui/components/TopicPage/Schema/SchemaEntry/SchemaEntry.tsx b/ui/components/TopicPage/Schema/SchemaEntry/SchemaEntry.tsx index e04b101ae..17273e85a 100644 --- a/ui/components/TopicPage/Schema/SchemaEntry/SchemaEntry.tsx +++ b/ui/components/TopicPage/Schema/SchemaEntry/SchemaEntry.tsx @@ -53,7 +53,7 @@ const SchemaEntry: React.FC = (props) => { }, []); return ( -
    +

    View schema

    diff --git a/ui/components/TopicPage/Schema/SchemaTypeInput/SchemaTypeInput.tsx b/ui/components/TopicPage/Schema/SchemaTypeInput/SchemaTypeInput.tsx index 9b1bc4e35..006c3c2f3 100644 --- a/ui/components/TopicPage/Schema/SchemaTypeInput/SchemaTypeInput.tsx +++ b/ui/components/TopicPage/Schema/SchemaTypeInput/SchemaTypeInput.tsx @@ -50,6 +50,7 @@ const SchemaTypeInput: React.FC = (props) => { ]} onChange={props.onChange} value={props.value} + testId="schema-type-select" />
    ); diff --git a/ui/components/TopicPage/Subscriptions/CreateSubscription/CreateSubscription.tsx b/ui/components/TopicPage/Subscriptions/CreateSubscription/CreateSubscription.tsx index 9f7190942..c85c17a95 100644 --- a/ui/components/TopicPage/Subscriptions/CreateSubscription/CreateSubscription.tsx +++ b/ui/components/TopicPage/Subscriptions/CreateSubscription/CreateSubscription.tsx @@ -70,10 +70,14 @@ const CreateSubscription: React.FC = (props) => { const res = await topicServiceClient .createSubscription(req, null) .catch((err) => { - `Unable to create subscription: ${err}`; + notifyError(`Unable to create subscription: ${err}`); + return undefined; }); - if (res !== undefined && res.getStatus()?.getCode() !== Code.OK) { + if (res === undefined) { + return; // transport failure - stay on the form, never a silent "success" + } + if (res.getStatus()?.getCode() !== Code.OK) { notifyError( `Unable to create subscription: ${res.getStatus()?.getMessage()}` ); @@ -89,6 +93,7 @@ const CreateSubscription: React.FC = (props) => { const subscriptionNameInput = ( = (props) => { const messageIdInput = (
    + testId="create-subscription-cursor" list={list} value={initialCursorPosition} onChange={setInitialCursorPosition} @@ -143,9 +149,12 @@ const CreateSubscription: React.FC = (props) => { /> ); + // With the "Message with specific ID" cursor, an empty/invalid id must block Create - the old + // check compared the id VALUE against the literal "messageId", so it never gated anything. const isFormValid = - subscriptionName.length > 0 && (messageId !== "messageId" || - (initialCursorPosition === "messageId" && messageId !== undefined && messageIdRegexp.test(messageId)) + subscriptionName.length > 0 && ( + initialCursorPosition !== "messageId" || + (messageId !== undefined && messageIdRegexp.test(messageId)) ); return ( diff --git a/ui/components/TopicPage/TopicDetails/TopicDetails.tsx b/ui/components/TopicPage/TopicDetails/TopicDetails.tsx index cde68c401..4205ed9b1 100644 --- a/ui/components/TopicPage/TopicDetails/TopicDetails.tsx +++ b/ui/components/TopicPage/TopicDetails/TopicDetails.tsx @@ -280,7 +280,7 @@ const TopicDetails: React.FC = (props) => { return (
    -
    +
    setIsGlobal(v => !v)} diff --git a/ui/components/TopicPage/TopicDetails/fields/TopicCompactionEditor/TopicCompactionStatus/TopicCompactionStatus.tsx b/ui/components/TopicPage/TopicDetails/fields/TopicCompactionEditor/TopicCompactionStatus/TopicCompactionStatus.tsx index 6e860e954..f6055b0b2 100644 --- a/ui/components/TopicPage/TopicDetails/fields/TopicCompactionEditor/TopicCompactionStatus/TopicCompactionStatus.tsx +++ b/ui/components/TopicPage/TopicDetails/fields/TopicCompactionEditor/TopicCompactionStatus/TopicCompactionStatus.tsx @@ -157,6 +157,7 @@ const TopicCompactionStatus: React.FC = (props) => { { const req = new pb.TriggerCompactionRequest(); req.setTopicFqn(props.topicFqn); diff --git a/ui/components/TopicPage/TopicDetails/fields/delayed-delivery.tsx b/ui/components/TopicPage/TopicDetails/fields/delayed-delivery.tsx index 4c7491510..ba159c318 100644 --- a/ui/components/TopicPage/TopicDetails/fields/delayed-delivery.tsx +++ b/ui/components/TopicPage/TopicDetails/fields/delayed-delivery.tsx @@ -142,6 +142,7 @@ export const FieldInput: React.FC = (props) => { { type: 'item', value: 'disabled', title: 'Disabled' }, ]} value={value.type} + testId="topic-detail-delayed-delivery-select" onChange={(type) => { if (type === 'inherited-from-namespace-config') { onChange({ type }); diff --git a/ui/components/TopicPage/TopicDetails/fields/max-consumers.tsx b/ui/components/TopicPage/TopicDetails/fields/max-consumers.tsx index 876f59bdf..22d55766f 100644 --- a/ui/components/TopicPage/TopicDetails/fields/max-consumers.tsx +++ b/ui/components/TopicPage/TopicDetails/fields/max-consumers.tsx @@ -128,6 +128,7 @@ export const FieldInput: React.FC = (props) => { <>
    + testId='topic-max-consumers-type-select' list={[ { type: 'item', value: 'inherited-from-namespace-config', title: 'Inherited from namespace config' }, { type: 'item', value: 'unlimited', title: 'Unlimited' }, @@ -145,6 +146,7 @@ export const FieldInput: React.FC = (props) => {
    {value.type === 'specified-for-this-topic' && ( onChange({ type: 'specified-for-this-topic', maxConsumers: parseInt(v) })} diff --git a/ui/components/app/contexts/AppContext.tsx b/ui/components/app/contexts/AppContext.tsx index c6bf34a99..fec409ae9 100644 --- a/ui/components/app/contexts/AppContext.tsx +++ b/ui/components/app/contexts/AppContext.tsx @@ -1,6 +1,4 @@ import React, { ReactNode, useState } from 'react'; -import useLocalStorage from "use-local-storage-state"; -import { localStorageKeys } from '../../local-storage-keys'; type BuildInfo = { name: string, @@ -22,14 +20,14 @@ export type PerformanceOptimizations = { pulsarConsumerState: 'inactive' | 'active'; } +// Consumed in ui/Table via one GLOBAL localStorage key shared by every table (an owner design +// decision: either the app auto-refreshes its tables or it doesn't). Lives here only as a type. export type AutoRefresh = { type: 'enabled' | 'disabled' }; export type Value = { config: Config, performanceOptimizations: PerformanceOptimizations setPerformanceOptimizations: (performanceOptimizations: PerformanceOptimizations) => void; - autoRefresh: AutoRefresh; - setAutoRefresh: (autoRefresh: AutoRefresh) => void; } const defaultValue: Value = { @@ -48,8 +46,6 @@ const defaultValue: Value = { }, performanceOptimizations: { pulsarConsumerState: 'inactive' }, setPerformanceOptimizations: () => undefined, - autoRefresh: { type: 'enabled' }, - setAutoRefresh: () => undefined, }; const Context = React.createContext(defaultValue); @@ -61,7 +57,6 @@ type DefaultProviderProps = { export const DefaultProvider: React.FC = (props) => { const [performanceOptimizations, setPerformanceOptimizations] = useState(defaultValue.performanceOptimizations); - const [autoRefresh, setAutoRefresh] = useLocalStorage(localStorageKeys.autoRefresh, { defaultValue: defaultValue.autoRefresh }); return ( = (props) => { config: props.config, performanceOptimizations, setPerformanceOptimizations: (performanceOptimizations: PerformanceOptimizations) => setPerformanceOptimizations(performanceOptimizations), - autoRefresh, - setAutoRefresh }} > {props.children} diff --git a/ui/components/app/contexts/HealthCheckContext/HealthCheckContext.tsx b/ui/components/app/contexts/HealthCheckContext/HealthCheckContext.tsx index 7aa42e422..2c09496ae 100644 --- a/ui/components/app/contexts/HealthCheckContext/HealthCheckContext.tsx +++ b/ui/components/app/contexts/HealthCheckContext/HealthCheckContext.tsx @@ -103,7 +103,7 @@ export const DefaultProvider: React.FC = (props) => { const isShowOverlay = result.uiServerConnection === 'failed' || result.brokerConnection === 'failed'; const overlay = isShowOverlay ? createPortal( -
    +

    @@ -126,8 +126,6 @@ export const DefaultProvider: React.FC = (props) => { document.body ) : null; - console.log('overlay', isShowOverlay); - return ( = (props) => { exit={{ opacity: 0, scale: 0.5 }} transition={{ duration: 0.25, ease: 'easeInOut', delay: 0.1 }} tabIndex={0} + data-testid="modal" onKeyDown={(e) => { if (!props.entry.isNotCloseable && e.key === 'Escape') { props.onClose(); diff --git a/ui/components/app/pulsar-auth/Button/Button.tsx b/ui/components/app/pulsar-auth/Button/Button.tsx index f7ef77926..6c9208456 100644 --- a/ui/components/app/pulsar-auth/Button/Button.tsx +++ b/ui/components/app/pulsar-auth/Button/Button.tsx @@ -38,6 +38,7 @@ const Button: React.FC = (props) => { return (
    = (props) => {
    - + + testId="credentials-method-select" list={[ { type: 'item', title: 'Empty', value: 'empty' }, { type: 'item', title: 'OAuth2', value: 'oauth2' }, @@ -84,6 +85,7 @@ const CredentialsEditor: React.FC = (props) => {

    {currentCredentials === item.name && ( Current @@ -93,6 +93,7 @@ const Editor: React.FC = (props) => {
    { await fetch(`${config.publicBaseUrl}/pulsar-auth/use/${encodeURIComponent(item.name)}`, { method: 'POST' }) @@ -103,6 +104,7 @@ const Editor: React.FC = (props) => { text='Set as current' /> { await fetch(`${config.publicBaseUrl}/pulsar-auth/delete/${encodeURIComponent(item.name)}`, { method: 'POST' }) @@ -127,8 +129,8 @@ const Editor: React.FC = (props) => { )}
    -
    )} @@ -155,6 +157,8 @@ function credentialsTypeToLabel(type: MaskedCredentials['type']): string { return 'OAuth2'; case 'jwt': return 'JWT'; + case 'authParamsString': + return 'Auth Params String'; default: return 'Unknown'; } diff --git a/ui/components/app/pulsar-auth/Editor/methods/AuthParamsString/AuthParamsString.tsx b/ui/components/app/pulsar-auth/Editor/methods/AuthParamsString/AuthParamsString.tsx index 52a881daa..26f27c710 100644 --- a/ui/components/app/pulsar-auth/Editor/methods/AuthParamsString/AuthParamsString.tsx +++ b/ui/components/app/pulsar-auth/Editor/methods/AuthParamsString/AuthParamsString.tsx @@ -29,6 +29,7 @@ const AuthParamsString: React.FC = (props) => { )} /> props.onChange({ ...props.value, authPluginClassName: v })} /> @@ -50,6 +51,7 @@ const AuthParamsString: React.FC = (props) => { )} /> props.onChange({ ...props.value, authParams: v })} /> diff --git a/ui/components/app/pulsar-auth/Editor/methods/Jwt/Jwt.tsx b/ui/components/app/pulsar-auth/Editor/methods/Jwt/Jwt.tsx index 5aca118c2..17e32019c 100644 --- a/ui/components/app/pulsar-auth/Editor/methods/Jwt/Jwt.tsx +++ b/ui/components/app/pulsar-auth/Editor/methods/Jwt/Jwt.tsx @@ -16,6 +16,7 @@ const Jwt: React.FC = (props) => { props.onChange({ ...props.value, token: v })} inputProps={{ type: 'password' }} diff --git a/ui/components/app/pulsar-auth/Editor/methods/OAuth2/OAuth2.tsx b/ui/components/app/pulsar-auth/Editor/methods/OAuth2/OAuth2.tsx index 72404d067..982e3556c 100644 --- a/ui/components/app/pulsar-auth/Editor/methods/OAuth2/OAuth2.tsx +++ b/ui/components/app/pulsar-auth/Editor/methods/OAuth2/OAuth2.tsx @@ -47,6 +47,7 @@ const OAuth2: React.FC = (props) => { props.onChange({ ...props.value, issuerUrl: v })} value={props.value.issuerUrl} /> @@ -74,6 +75,7 @@ const OAuth2: React.FC = (props) => { props.onChange({ ...props.value, audience: v })} value={props.value.audience} /> diff --git a/ui/components/app/pulsar-auth/conversions.ts b/ui/components/app/pulsar-auth/conversions.ts index e5f4095a0..77a168f92 100644 --- a/ui/components/app/pulsar-auth/conversions.ts +++ b/ui/components/app/pulsar-auth/conversions.ts @@ -13,6 +13,9 @@ export function maskedCredentialsFromPb(credentialsPb: pb.MaskedCredentials): Ma case pb.CredentialsType.CREDENTIALS_TYPE_JWT: type = 'jwt'; break; + case pb.CredentialsType.CREDENTIALS_TYPE_AUTH_PARAMS_STRING: + type = 'authParamsString'; + break; default: throw new Error(`Unknown credentials type: ${credentialsPb.getType()}`); } diff --git a/ui/components/ui/ActionButton/ActionButton.tsx b/ui/components/ui/ActionButton/ActionButton.tsx index e66fa93e6..301d6991b 100644 --- a/ui/components/ui/ActionButton/ActionButton.tsx +++ b/ui/components/ui/ActionButton/ActionButton.tsx @@ -43,7 +43,7 @@ const ActionButton: React.FC = (props) => { type='regular' className={s.ActionButton} onClick={props.onClick} - data-testid={props.testId} + testId={props.testId} data-tooltip-id={tooltipId} data-tooltip-html={tooltipHtml} svgIcon={svgIcon} diff --git a/ui/components/ui/BreadCrumbs/BreadCrumbs.tsx b/ui/components/ui/BreadCrumbs/BreadCrumbs.tsx index bbf94c503..8ef24ef46 100644 --- a/ui/components/ui/BreadCrumbs/BreadCrumbs.tsx +++ b/ui/components/ui/BreadCrumbs/BreadCrumbs.tsx @@ -182,6 +182,8 @@ const BreadCrumbs: React.FC = (props) => { className={`${s.Crumb} ${className}`} to={href} onClick={onClick} + data-testid="breadcrumb" + data-crumb-type={crumb.type} > {icon ?
    {icon}
    : null}
    {crumbValue}
    @@ -198,10 +200,11 @@ const BreadCrumbs: React.FC = (props) => { return ( <> -
    +
    {resourceFqn !== undefined && (
    { if (resourceFqn !== undefined) { void copyToClipboard(resourceFqn).then((ok) => { diff --git a/ui/components/ui/ConfigurationTable/DurationInput/DurationInput.tsx b/ui/components/ui/ConfigurationTable/DurationInput/DurationInput.tsx index cba2778be..71fd5d000 100644 --- a/ui/components/ui/ConfigurationTable/DurationInput/DurationInput.tsx +++ b/ui/components/ui/ConfigurationTable/DurationInput/DurationInput.tsx @@ -9,6 +9,7 @@ export type DurationInputProps = { // Seconds initialValue: number; onChange: (seconds: number) => void; + testId?: string; }; const DurationInput: React.FC = (props) => { @@ -18,6 +19,7 @@ const DurationInput: React.FC = (props) => {
    } const Input: React.FC = (props) => { @@ -30,6 +31,7 @@ const Input: React.FC = (props) => { onChange={(e) => props.onChange(e.target.value)} placeholder={props.placeholder} {...props.inputProps} + data-testid={props.testId} /> {props.iconSvg && (
    diff --git a/ui/components/ui/ConfigurationTable/ListInput/ListInput.tsx b/ui/components/ui/ConfigurationTable/ListInput/ListInput.tsx index e4aaab205..c2642ba65 100644 --- a/ui/components/ui/ConfigurationTable/ListInput/ListInput.tsx +++ b/ui/components/ui/ConfigurationTable/ListInput/ListInput.tsx @@ -47,6 +47,7 @@ export type ListValue = { onAdd?: (value: T, { addUncollapsedItem }: { addUncollapsedItem: (id: string) => void }) => void; onChange?: (value: T[]) => void; testId?: string; + addButtonTestId?: string; itemName?: string; isHideNothingToShow?: boolean; isContentDoesntOverlapRemoveButton?: boolean; @@ -181,6 +182,7 @@ function ListInput(props: ListValue): React.ReactElement { onClick={add} itemName={props.itemName} disabled={Either.isLeft(validationResult)} + testId={props.addButtonTestId} />
    )} @@ -234,6 +236,8 @@ function SortableItem(props: SortableItemProps): ReactElement { ${listProps.isContentDoesntOverlapRemoveButton ? s.RemovableListFieldValueWithoutPadding : ''} ${isDragging ? s.DraggingItem : ''} `} + data-testid="list-item" + data-item-id={props.id} ref={setNodeRef} style={style} > {props.listProps.renderItem(props.value, props.index, { isCollapsed: Boolean(props.isCollapsed), isDragging, isDraggingSomeItem })} @@ -260,6 +264,7 @@ function SortableItem(props: SortableItemProps): ReactElement { {(isRemovable && listProps.onRemove) && ( listProps.onRemove!(props.id)} title="Remove this item" svgIcon={removeIcon} diff --git a/ui/components/ui/ConfigurationTable/MemorySizeInput/MemorySizeInput.tsx b/ui/components/ui/ConfigurationTable/MemorySizeInput/MemorySizeInput.tsx index 245604ef6..ef72566cd 100644 --- a/ui/components/ui/ConfigurationTable/MemorySizeInput/MemorySizeInput.tsx +++ b/ui/components/ui/ConfigurationTable/MemorySizeInput/MemorySizeInput.tsx @@ -11,6 +11,7 @@ export type MemorySizeInputProps = { initialValue: number; onChange: (value: number) => void; maxLimitBytes?: number; + testId?: string; }; const MemorySizeInput: React.FC = (props) => { @@ -33,6 +34,7 @@ const MemorySizeInput: React.FC = (props) => {
    = (props) => { )}
    -
    diff --git a/ui/components/ui/ConsumerSession/Console/Console.tsx b/ui/components/ui/ConsumerSession/Console/Console.tsx index c4bbcd573..37ae22c76 100644 --- a/ui/components/ui/ConsumerSession/Console/Console.tsx +++ b/ui/components/ui/ConsumerSession/Console/Console.tsx @@ -38,6 +38,7 @@ const Console: React.FC = (props) => { tabs = tabs.concat([{ key: 'producer', title: 'Produce', + testId: 'console-tab-produce', isRenderAlways: true, render: () => { if (props.sessionConfig === undefined) { @@ -60,6 +61,7 @@ const Console: React.FC = (props) => { { key: 'context-repl', title: 'Context REPL', + testId: 'console-tab-repl', isRenderAlways: true, render: () => ( = (props) => { { key: 'context-logs', title: 'Context Logs', + testId: 'console-tab-logs', isRenderAlways: true, render: () => ( = (props) => { } return ( -
    +
    ref={virtuosoRef} diff --git a/ui/components/ui/ConsumerSession/Console/ContextRepl/ContextRepl.tsx b/ui/components/ui/ConsumerSession/Console/ContextRepl/ContextRepl.tsx index a3aa2b098..317cee038 100644 --- a/ui/components/ui/ConsumerSession/Console/ContextRepl/ContextRepl.tsx +++ b/ui/components/ui/ConsumerSession/Console/ContextRepl/ContextRepl.tsx @@ -88,7 +88,7 @@ const ExpressionInspector: React.FC = (props) => {
    -
    +
    = (props) => {
    = (props) => { />
    -
    +
    {logs.map((r, i) => { const [level, message] = parseLogLine(r); const color = getLogColor(level); @@ -130,6 +131,7 @@ const ExpressionInspector: React.FC = (props) => {
    -
    +
    Value {valueType === 'json' && ( = (props) => {
    = (props) => { /> = (props) => { /> = (props) => { = (props) => { /> = (props) => { /> = (props) => { /> = (props) => { /> = (props) => { /> = (props) => { /> = (props) => { /> = (props) => { /> = (props) => { /> = (props) => { /> = (props) => { /> = (props) => { {messageDetails !== undefined && ( -
    +
    setSelectedMessages([])} title="Close Message Details" action={{ type: 'predefined', action: 'close' }} diff --git a/ui/components/ui/ConsumerSession/Message/Field/Field.tsx b/ui/components/ui/ConsumerSession/Message/Field/Field.tsx index adc6b7a5c..3155e4610 100644 --- a/ui/components/ui/ConsumerSession/Message/Field/Field.tsx +++ b/ui/components/ui/ConsumerSession/Message/Field/Field.tsx @@ -10,6 +10,7 @@ export type FieldProps = { rawValue?: string, title?: string, valueHref?: string, + testId?: string, } const Field: React.FC = (props) => { const { notifySuccess, notifyWarn } = Notifications.useContext(); @@ -38,6 +39,7 @@ const Field: React.FC = (props) => {
    { event.stopPropagation(); copyRawValue(); @@ -49,7 +51,7 @@ const Field: React.FC = (props) => { ); if (props.valueHref !== undefined) { - valueElement = {valueContent}; + valueElement = {valueContent}; } return ( diff --git a/ui/components/ui/ConsumerSession/Message/Message.tsx b/ui/components/ui/ConsumerSession/Message/Message.tsx index 1c1994de8..394af3fd9 100644 --- a/ui/components/ui/ConsumerSession/Message/Message.tsx +++ b/ui/components/ui/ConsumerSession/Message/Message.tsx @@ -33,6 +33,7 @@ const MessageComponent: React.FC = (props) => { <>
    = (props) => { = (props) => { { key: 'value', title: 'Value', + testId: 'cs-details-tab-value', render: () => { return (
    @@ -56,6 +57,7 @@ const MessageDetails: React.FC = (props) => { { key: 'metadata', title: 'Metadata', + testId: 'cs-details-tab-metadata', render: () => { return (
    @@ -79,6 +81,7 @@ const MessageDetails: React.FC = (props) => { { key: 'properties', title: propertiesFieldTitle, + testId: 'cs-details-tab-properties', render: () => { return (
    diff --git a/ui/components/ui/ConsumerSession/Message/Td.tsx b/ui/components/ui/ConsumerSession/Message/Td.tsx index 8af2d244c..09d136bf6 100644 --- a/ui/components/ui/ConsumerSession/Message/Td.tsx +++ b/ui/components/ui/ConsumerSession/Message/Td.tsx @@ -10,10 +10,11 @@ export type TdProps = { children: React.ReactNode, width?: string, coloring: Coloring, - isSelected: boolean + isSelected: boolean, + testId?: string } & React.ThHTMLAttributes; export const Td: React.FC = (props) => { - const { children, className, width, coloring, isSelected, ...restProps } = props; + const { children, className, width, coloring, isSelected, testId, ...restProps } = props; return (
    = (props) => { color: isSelected ? selectedColor : props.coloring?.foregroundColor, backgroundColor: isSelected ? selectedBackgroundColor : props.coloring?.backgroundColor }} + data-testid={testId} + data-cs-selected={isSelected} {...restProps} >
    diff --git a/ui/components/ui/ConsumerSession/Message/fields.tsx b/ui/components/ui/ConsumerSession/Message/fields.tsx index 20d9eb87e..423fd92cf 100644 --- a/ui/components/ui/ConsumerSession/Message/fields.tsx +++ b/ui/components/ui/ConsumerSession/Message/fields.tsx @@ -77,12 +77,12 @@ export const PublishTimeField: React.FC = (props) => { export const KeyField: React.FC = (props) => { const key = props.message.key === null ? undefined : props.message.key; - return + return } export const ValueField: React.FC = (props) => { const value = props.message.value === null ? undefined : limitString(props.message.value, 100); - return + return } export const SessionTargetIndexField: React.FC = (props) => { @@ -97,7 +97,7 @@ export const TopicField: React.FC = (props) => { undefined : routes.tenants.tenant.namespaces.namespace.topics.anyTopicPersistency.topic.overview._.get({ tenant: topicResource.tenant, namespace: topicResource.namespace, topic: topicResource.topic, topicPersistency: topicResource.topicPersistency }); - return + return } export const ProducerNameField: React.FC = (props) => { diff --git a/ui/components/ui/ConsumerSession/SessionConfiguration/ColoringRulesInput/ColoringRuleChainInput.tsx b/ui/components/ui/ConsumerSession/SessionConfiguration/ColoringRulesInput/ColoringRuleChainInput.tsx index 838f8dceb..e90a673f3 100644 --- a/ui/components/ui/ConsumerSession/SessionConfiguration/ColoringRulesInput/ColoringRuleChainInput.tsx +++ b/ui/components/ui/ConsumerSession/SessionConfiguration/ColoringRulesInput/ColoringRuleChainInput.tsx @@ -15,6 +15,7 @@ export type ColoringRuleChainInputProps = { onChange: (value: ManagedColoringRuleChainValOrRef) => void, libraryContext: LibraryContext, isReadOnly?: boolean, + testId?: string, libraryBrowserPanel?: Partial }; @@ -49,7 +50,7 @@ const ColoringRuleChainInput: React.FC = (props) => const cssFilter = itemSpec.isEnabled ? undefined : 'grayscale(0.5) opacity(0.75)'; return ( -
    +
    = (props) => { @@ -21,6 +22,7 @@ const ColorPickerButton: React.FC = (props) => { return (
    = (props) => {
    onSpecChange({ ...itemSpec, foregroundColor: v })} width='inherit' @@ -100,6 +101,7 @@ const ColoringRuleInput: React.FC = (props) => {
    onSpecChange({ ...itemSpec, backgroundColor: v })} width='inherit' diff --git a/ui/components/ui/ConsumerSession/SessionConfiguration/FilterChainEditor/FilterChainEditor.tsx b/ui/components/ui/ConsumerSession/SessionConfiguration/FilterChainEditor/FilterChainEditor.tsx index b3b7f97ca..0c9e5d201 100644 --- a/ui/components/ui/ConsumerSession/SessionConfiguration/FilterChainEditor/FilterChainEditor.tsx +++ b/ui/components/ui/ConsumerSession/SessionConfiguration/FilterChainEditor/FilterChainEditor.tsx @@ -24,6 +24,7 @@ export type FilterChainEditorProps = { onChange: (value: ManagedMessageFilterChainValOrRef) => void; libraryContext: LibraryContext; isReadOnly?: boolean; + testId?: string; libraryBrowserPanel?: Partial }; @@ -62,7 +63,7 @@ const FilterChainEditor: React.FC = (props) => { const cssFilter = itemSpec.isEnabled ? undefined : 'grayscale(0.5) opacity(0.75)'; return ( -
    +
    = (props) => { {!isLast && (
    + testId="cs-filter-logic" items={[ { type: 'item', label: 'AND', help: 'Every filter should match.', value: 'all' }, { type: 'item', label: 'OR', help: 'Some filter should match.', value: 'any' }, diff --git a/ui/components/ui/ConsumerSession/SessionConfiguration/FilterChainEditor/FilterEditor/BasicFilterEditor/BasicFilterEditor.tsx b/ui/components/ui/ConsumerSession/SessionConfiguration/FilterChainEditor/FilterEditor/BasicFilterEditor/BasicFilterEditor.tsx index 474760cc7..4a7799d94 100644 --- a/ui/components/ui/ConsumerSession/SessionConfiguration/FilterChainEditor/FilterEditor/BasicFilterEditor/BasicFilterEditor.tsx +++ b/ui/components/ui/ConsumerSession/SessionConfiguration/FilterChainEditor/FilterEditor/BasicFilterEditor/BasicFilterEditor.tsx @@ -15,6 +15,7 @@ const BasicFilterEditor: React.FC = (props) => {
    props.onChange({ ...props.value, op: v })} isShowEnableToggle={false} diff --git a/ui/components/ui/ConsumerSession/SessionConfiguration/FilterChainEditor/FilterEditor/BasicFilterEditor/BasicMessageFilterOpInput/AnyTestOpInput/TestOpStringIncludesInput/TestOpStringIncludesInput.tsx b/ui/components/ui/ConsumerSession/SessionConfiguration/FilterChainEditor/FilterEditor/BasicFilterEditor/BasicMessageFilterOpInput/AnyTestOpInput/TestOpStringIncludesInput/TestOpStringIncludesInput.tsx index 31e482c28..9ddeabcf0 100644 --- a/ui/components/ui/ConsumerSession/SessionConfiguration/FilterChainEditor/FilterEditor/BasicFilterEditor/BasicMessageFilterOpInput/AnyTestOpInput/TestOpStringIncludesInput/TestOpStringIncludesInput.tsx +++ b/ui/components/ui/ConsumerSession/SessionConfiguration/FilterChainEditor/FilterEditor/BasicFilterEditor/BasicMessageFilterOpInput/AnyTestOpInput/TestOpStringIncludesInput/TestOpStringIncludesInput.tsx @@ -15,6 +15,7 @@ const TestOpStringIncludesInput: React.FC = (pro
    props.onChange({ ...props.value, includes: v })} diff --git a/ui/components/ui/ConsumerSession/SessionConfiguration/FilterChainEditor/FilterEditor/BasicFilterEditor/BasicMessageFilterOpInput/AnyTestOpTypeSelect/AnyTestOpTypeSelect.tsx b/ui/components/ui/ConsumerSession/SessionConfiguration/FilterChainEditor/FilterEditor/BasicFilterEditor/BasicMessageFilterOpInput/AnyTestOpTypeSelect/AnyTestOpTypeSelect.tsx index 8e50737fc..612655b80 100644 --- a/ui/components/ui/ConsumerSession/SessionConfiguration/FilterChainEditor/FilterEditor/BasicFilterEditor/BasicMessageFilterOpInput/AnyTestOpTypeSelect/AnyTestOpTypeSelect.tsx +++ b/ui/components/ui/ConsumerSession/SessionConfiguration/FilterChainEditor/FilterEditor/BasicFilterEditor/BasicMessageFilterOpInput/AnyTestOpTypeSelect/AnyTestOpTypeSelect.tsx @@ -14,13 +14,15 @@ const defaultIsCaseSensitive = false; export type AnyTestOpTypeSelectProps = { value: AnyTestOp, onChange: (v: AnyTestOp) => void, - isReadOnly?: boolean + isReadOnly?: boolean, + testId?: string }; const AnyTestOpTypeSelect: React.FC = (props) => { return (
    + testId={props.testId} size='small' list={[ { type: "item", title: "is defined", value: "TestOpIsDefined" }, diff --git a/ui/components/ui/ConsumerSession/SessionConfiguration/FilterChainEditor/FilterEditor/BasicFilterEditor/BasicMessageFilterOpInput/BasicMessageFilterOpInput.tsx b/ui/components/ui/ConsumerSession/SessionConfiguration/FilterChainEditor/FilterEditor/BasicFilterEditor/BasicMessageFilterOpInput/BasicMessageFilterOpInput.tsx index 5759cd0be..05201b8ca 100644 --- a/ui/components/ui/ConsumerSession/SessionConfiguration/FilterChainEditor/FilterEditor/BasicFilterEditor/BasicMessageFilterOpInput/BasicMessageFilterOpInput.tsx +++ b/ui/components/ui/ConsumerSession/SessionConfiguration/FilterChainEditor/FilterEditor/BasicFilterEditor/BasicMessageFilterOpInput/BasicMessageFilterOpInput.tsx @@ -14,7 +14,8 @@ export type BasicMessageFilterOpInputProps = { value: BasicMessageFilterOp, onChange: (v: BasicMessageFilterOp) => void, isShowEnableToggle: boolean, - isReadOnly?: boolean + isReadOnly?: boolean, + testId?: string }; const BasicMessageFilterOpInput: React.FC = (props) => { @@ -84,6 +85,7 @@ const BasicMessageFilterOpInput: React.FC = (pro {props.value.op.type === "AnyTestOp" && (
    props.onChange({ ...props.value, op: v })} isReadOnly={props.isReadOnly} diff --git a/ui/components/ui/ConsumerSession/SessionConfiguration/FilterChainEditor/FilterEditor/FilterEditor.tsx b/ui/components/ui/ConsumerSession/SessionConfiguration/FilterChainEditor/FilterEditor/FilterEditor.tsx index 6efa531dd..bbc0c20bc 100644 --- a/ui/components/ui/ConsumerSession/SessionConfiguration/FilterChainEditor/FilterEditor/FilterEditor.tsx +++ b/ui/components/ui/ConsumerSession/SessionConfiguration/FilterChainEditor/FilterEditor/FilterEditor.tsx @@ -86,12 +86,14 @@ const FilterEditor: React.FC = (props) => { preItemType: (
    onSpecChange({ ...itemSpec, isEnabled: !itemSpec.isEnabled })} isReadOnly={props.isReadOnly} /> onSpecChange({ ...itemSpec, isNegated: !itemSpec.isNegated })} helpOverride="Invert result. If enabled, then messages that matches the filter will be not passed and vice versa." @@ -102,6 +104,7 @@ const FilterEditor: React.FC = (props) => { postItemType: (
    + testId="cs-filter-mode" items={[ { type: 'item', diff --git a/ui/components/ui/ConsumerSession/SessionConfiguration/FilterChainEditor/FilterEditor/JsFilterEditor/JsFilterEditor.tsx b/ui/components/ui/ConsumerSession/SessionConfiguration/FilterChainEditor/FilterEditor/JsFilterEditor/JsFilterEditor.tsx index f094f3095..e6c75aaa7 100644 --- a/ui/components/ui/ConsumerSession/SessionConfiguration/FilterChainEditor/FilterEditor/JsFilterEditor/JsFilterEditor.tsx +++ b/ui/components/ui/ConsumerSession/SessionConfiguration/FilterChainEditor/FilterEditor/JsFilterEditor/JsFilterEditor.tsx @@ -19,7 +19,7 @@ export type JsFilterEditorProps = { const JsFilterEditor: React.FC = (props) => { return ( -
    +
    = (props) => { /> {!isAdvancedConfig && setIsShowAdvanced(v)} label='Show advanced settings' @@ -173,6 +174,7 @@ const SessionConfiguration: React.FC = (props) => { onSpecChange({ ...itemSpec, messageFilterChain: v })} libraryContext={props.libraryContext} @@ -180,6 +182,7 @@ const SessionConfiguration: React.FC = (props) => { /> onSpecChange({ ...itemSpec, valueProjectionList: v })} libraryContext={props.libraryContext} @@ -187,6 +190,7 @@ const SessionConfiguration: React.FC = (props) => { /> onSpecChange({ ...itemSpec, coloringRuleChain: v })} libraryContext={props.libraryContext} @@ -212,6 +216,7 @@ const SessionConfiguration: React.FC = (props) => {
    = (props) => { {itemSpec.targets.length > 1 && ( <> = (props) => { disabled={i === 0} /> = (props) => { )} { const newTargets = [...itemSpec.targets]; @@ -271,6 +279,7 @@ const SessionConfiguration: React.FC = (props) => {
    {!props.isReadOnly && ( { const newTarget: ManagedConsumerSessionTargetValOrRef = { diff --git a/ui/components/ui/ConsumerSession/SessionConfiguration/SessionTargetInput/DeserializerInput/DeserializerInput.tsx b/ui/components/ui/ConsumerSession/SessionConfiguration/SessionTargetInput/DeserializerInput/DeserializerInput.tsx index 625b142e2..e63fb86c7 100644 --- a/ui/components/ui/ConsumerSession/SessionConfiguration/SessionTargetInput/DeserializerInput/DeserializerInput.tsx +++ b/ui/components/ui/ConsumerSession/SessionConfiguration/SessionTargetInput/DeserializerInput/DeserializerInput.tsx @@ -74,6 +74,7 @@ const DeserializerInput: React.FC = (props) => {
    + testId="cs-deserializer" list={[ { type: 'item', value: 'use-latest-topic-schema', title: 'Use topic schema' }, { type: 'item', value: 'treat-bytes-as-json', title: 'Treat raw bytes as JSON' }, diff --git a/ui/components/ui/ConsumerSession/SessionConfiguration/SessionTargetInput/SessionTargetInput.tsx b/ui/components/ui/ConsumerSession/SessionConfiguration/SessionTargetInput/SessionTargetInput.tsx index d92d33aab..8411c5121 100644 --- a/ui/components/ui/ConsumerSession/SessionConfiguration/SessionTargetInput/SessionTargetInput.tsx +++ b/ui/components/ui/ConsumerSession/SessionConfiguration/SessionTargetInput/SessionTargetInput.tsx @@ -132,6 +132,7 @@ const SessionTargetInput: React.FC = (props) => { /> onSpecChange({ ...itemSpec, messageFilterChain: v })} libraryContext={props.libraryContext} @@ -146,6 +147,7 @@ const SessionTargetInput: React.FC = (props) => { /> onSpecChange({ ...itemSpec, coloringRuleChain: v })} libraryContext={props.libraryContext} diff --git a/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/StartFromInput.tsx b/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/StartFromInput.tsx index c73eb7b6d..cbf5722ee 100644 --- a/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/StartFromInput.tsx +++ b/ui/components/ui/ConsumerSession/SessionConfiguration/StartFromInput/StartFromInput.tsx @@ -34,7 +34,7 @@ const list: List = [ }, { type: 'item', title: 'Specific time', value: 'dateTime' }, { type: 'item', title: 'Relative time ago', value: 'relativeDateTime' }, - { type: 'item', title: 'Skip fist n messages', value: 'nthMessageAfterEarliest' }, + { type: 'item', title: 'Skip first n messages', value: 'nthMessageAfterEarliest' }, { type: 'item', title: 'Latest n messages', value: 'nthMessageBeforeLatest' } ]; @@ -95,6 +95,7 @@ const StartFromInput: React.FC = (props) => { />
    + testId="cs-start-from" list={list} value={itemSpec.startFrom.type} onChange={(v) => { @@ -161,6 +162,7 @@ const StartFromInput: React.FC = (props) => { {itemSpec.startFrom.type === 'nthMessageAfterEarliest' && (
    onSpecChange({ startFrom: { type: 'nthMessageAfterEarliest', n: parseInt(v) } })} @@ -174,6 +176,7 @@ const StartFromInput: React.FC = (props) => { {itemSpec.startFrom.type === 'nthMessageBeforeLatest' && (
    onSpecChange({ startFrom: { type: 'nthMessageBeforeLatest', n: parseInt(v) } })} @@ -187,6 +190,7 @@ const StartFromInput: React.FC = (props) => { {itemSpec.startFrom.type === 'messageId' && (
    { diff --git a/ui/components/ui/ConsumerSession/Th.tsx b/ui/components/ui/ConsumerSession/Th.tsx index 878e478cf..4c862d06a 100644 --- a/ui/components/ui/ConsumerSession/Th.tsx +++ b/ui/components/ui/ConsumerSession/Th.tsx @@ -19,7 +19,8 @@ export type ThProps = { style?: React.CSSProperties, width?: number, onResizeStart?: (startClientX: number) => void, - suppressSortClickRef?: MutableRefObject + suppressSortClickRef?: MutableRefObject, + testId?: string }; export const Th: FC = (props: ThProps) => { @@ -42,7 +43,7 @@ export const Th: FC = (props: ThProps) => { } return ( -
    +
    = (props) => { return (
    { modals.push({ id: 'export-messages', diff --git a/ui/components/ui/ConsumerSession/Toolbar/ExportMessagesButton/MessagesExporter/MessageFieldsConfig/MessageFieldsConfig.tsx b/ui/components/ui/ConsumerSession/Toolbar/ExportMessagesButton/MessagesExporter/MessageFieldsConfig/MessageFieldsConfig.tsx index d9774848d..641fd4c52 100644 --- a/ui/components/ui/ConsumerSession/Toolbar/ExportMessagesButton/MessagesExporter/MessageFieldsConfig/MessageFieldsConfig.tsx +++ b/ui/components/ui/ConsumerSession/Toolbar/ExportMessagesButton/MessagesExporter/MessageFieldsConfig/MessageFieldsConfig.tsx @@ -107,7 +107,7 @@ const SortableMessageField: React.FC = (props) => { }; return ( -
    +
    + testId="cs-export-format" list={[ { type: "item", value: "json-message-per-entry", title: ".json - message per array entry" }, { type: "item", value: "json-value-per-entry", title: ".json - value per array entry" }, @@ -78,6 +79,7 @@ const _MessagesExporter: React.FC
    diff --git a/ui/components/ui/ConsumerSession/Toolbar/Toolbar.tsx b/ui/components/ui/ConsumerSession/Toolbar/Toolbar.tsx index 1214ce052..e8ca6fb1c 100644 --- a/ui/components/ui/ConsumerSession/Toolbar/Toolbar.tsx +++ b/ui/components/ui/ConsumerSession/Toolbar/Toolbar.tsx @@ -46,7 +46,12 @@ const Toolbar: React.FC = (props) => {
    = (props) => {
    props.onStopSession()} @@ -66,6 +72,7 @@ const Toolbar: React.FC = (props) => {
    = (props) => { data-tooltip-html={'Search in message keys and values.'} > = (props) => { /> {props.searchInResults !== '' && (
    - {props.numFoundInResults} found + {props.numFoundInResults} found
    )}
    @@ -98,7 +106,7 @@ const Toolbar: React.FC = (props) => {
    - {i18n.formatLongNumber(props.messagesProcessed)} + {i18n.formatLongNumber(props.messagesProcessed)}  processed
    @@ -109,7 +117,7 @@ const Toolbar: React.FC = (props) => {
    - {i18n.formatLongNumber(props.messagesLoaded)} + {i18n.formatLongNumber(props.messagesLoaded)}   loaded
    diff --git a/ui/components/ui/ConsumerSession/sort.ts b/ui/components/ui/ConsumerSession/sort.ts index 41a903c8f..62d4be2bb 100644 --- a/ui/components/ui/ConsumerSession/sort.ts +++ b/ui/components/ui/ConsumerSession/sort.ts @@ -31,6 +31,19 @@ export type SortKey = export type Sort = { key: SortKey; direction: "asc" | "desc" }; +// Byte-lexicographic order - matches the hex rendering of messageId/orderingKey cells. +const compareBytes = (a: Uint8Array | null | undefined, b: Uint8Array | null | undefined): number => { + const av = a || new Uint8Array(); + const bv = b || new Uint8Array(); + const n = Math.min(av.length, bv.length); + for (let i = 0; i < n; i++) { + if (av[i] !== bv[i]) { + return av[i] - bv[i]; + } + } + return av.length - bv.length; +}; + export const sortMessages = ( messages: MessageDescriptor[], sort: Sort @@ -141,6 +154,18 @@ export const sortMessages = ( return s(messages, [], sortFn); } + if (sort.key === "messageId") { + const [defs, undefs] = partition(messages, (m) => m.messageId != null); + const sortFn: SortFn = (a, b) => compareBytes(a.messageId, b.messageId); + return s(defs, undefs, sortFn); + } + + if (sort.key === "orderingKey") { + const [defs, undefs] = partition(messages, (m) => m.orderingKey != null); + const sortFn: SortFn = (a, b) => compareBytes(a.orderingKey, b.orderingKey); + return s(defs, undefs, sortFn); + } + if (sort.key === "redeliveryCount") { const sortFn: SortFn = (a, b) => (a.redeliveryCount || 0) - (b.redeliveryCount || 0); diff --git a/ui/components/ui/ConsumerSession/topic-selector/TopicSelectorInput/TopicSelectorInfo/TopicSelectorInfo.tsx b/ui/components/ui/ConsumerSession/topic-selector/TopicSelectorInput/TopicSelectorInfo/TopicSelectorInfo.tsx index 862c2db25..1118c19c0 100644 --- a/ui/components/ui/ConsumerSession/topic-selector/TopicSelectorInput/TopicSelectorInfo/TopicSelectorInfo.tsx +++ b/ui/components/ui/ConsumerSession/topic-selector/TopicSelectorInput/TopicSelectorInfo/TopicSelectorInfo.tsx @@ -62,7 +62,7 @@ const TopicSelectorInfo: React.FC = (props) => { data-tooltip-id={tooltipId} data-tooltip-html={tooltipContent === undefined ? undefined : renderToStaticMarkup(tooltipContent)} > - Non-partitioned topic{topicFqns.length > 1 ? 's' : ''} found: {topicFqns.length} + Non-partitioned topic{topicFqns.length > 1 ? 's' : ''} found: {topicFqns.length}
    )}
    diff --git a/ui/components/ui/ConsumerSession/topic-selector/TopicSelectorInput/TopicSelectorInput.tsx b/ui/components/ui/ConsumerSession/topic-selector/TopicSelectorInput/TopicSelectorInput.tsx index 8deed7ca2..b0a0dabfd 100644 --- a/ui/components/ui/ConsumerSession/topic-selector/TopicSelectorInput/TopicSelectorInput.tsx +++ b/ui/components/ui/ConsumerSession/topic-selector/TopicSelectorInput/TopicSelectorInput.tsx @@ -97,6 +97,7 @@ const TopicsSelectorInput: React.FC = (props) => { + testId="cs-target-mode" list={[ { type: 'item', title: 'Current Topic', value: 'current-topic' }, { type: 'item', title: 'Specific Topic(s)', value: 'multi-topic-selector' }, @@ -134,6 +135,7 @@ const TopicsSelectorInput: React.FC = (props) => { {itemSpec.topicSelector.type === 'multi-topic-selector' && ( + testId="cs-target-fqn-list" value={itemSpec.topicSelector.topicFqns} onChange={(v) => { if (itemSpec.topicSelector.type !== 'multi-topic-selector') { @@ -172,7 +174,7 @@ const TopicsSelectorInput: React.FC = (props) => { {v} )} editor={{ - render: (value, onChange) => , + render: (value, onChange) => , initialValue: '' }} itemName="Topic" @@ -241,6 +243,7 @@ const TopicsSelectorInput: React.FC = (props) => { { if (itemSpec.topicSelector.type !== 'namespaced-regex-topic-selector') { @@ -256,7 +259,7 @@ const TopicsSelectorInput: React.FC = (props) => { )} {isNotApplicableInThisContext && ( - The topic selector is not applicable in this context. + The topic selector is not applicable in this context. )}
    ); diff --git a/ui/components/ui/ConsumerSession/value-projections/ValueProjectionListInput/ValueProjectionInput/ValueProjectionInput.tsx b/ui/components/ui/ConsumerSession/value-projections/ValueProjectionListInput/ValueProjectionInput/ValueProjectionInput.tsx index 7bfc884c5..b5d788e1e 100644 --- a/ui/components/ui/ConsumerSession/value-projections/ValueProjectionListInput/ValueProjectionInput/ValueProjectionInput.tsx +++ b/ui/components/ui/ConsumerSession/value-projections/ValueProjectionListInput/ValueProjectionInput/ValueProjectionInput.tsx @@ -102,6 +102,7 @@ const ValueProjectionInput: React.FC = (props) => { )} /> onSpecChange({ ...itemSpec, shortName: v })} placeholder='Item Name' diff --git a/ui/components/ui/ConsumerSession/value-projections/ValueProjectionListInput/ValueProjectionListInput.tsx b/ui/components/ui/ConsumerSession/value-projections/ValueProjectionListInput/ValueProjectionListInput.tsx index 05049d7ae..a3ef30e76 100644 --- a/ui/components/ui/ConsumerSession/value-projections/ValueProjectionListInput/ValueProjectionListInput.tsx +++ b/ui/components/ui/ConsumerSession/value-projections/ValueProjectionListInput/ValueProjectionListInput.tsx @@ -15,6 +15,7 @@ export type ValueProjectionListInputProps = { onChange: (v: ManagedValueProjectionListValOrRef) => void, libraryContext: LibraryContext, isReadOnly?: boolean, + testId?: string, libraryBrowserPanel?: Partial }; @@ -49,7 +50,7 @@ const ValueProjectionListInput: React.FC = (props const cssFilter = itemSpec.isEnabled ? undefined : 'grayscale(0.5) opacity(0.75)'; return ( -
    +
    = { onChange: (v: T) => void, items: IconToggleItem[], isReadOnly?: boolean, - isWithShadow?: boolean + isWithShadow?: boolean, + testId?: string }; function IconToggle(props: IconToggleProps): React.ReactElement { @@ -56,6 +57,7 @@ function IconToggle(props: IconToggleProps): React.ReactElement { }} data-tooltip-id={tooltipId} data-tooltip-html={currentItem?.help === undefined ? undefined : renderToStaticMarkup(<>{currentItem.help})} + data-testid={props.testId} > {(currentItem?.iconSvg) && (
    void, helpOverride?: React.ReactElement | string, - isReadOnly?: boolean + isReadOnly?: boolean, + testId?: string }; const InvertedToggle: React.FC = (props) => { @@ -33,6 +34,7 @@ const InvertedToggle: React.FC = (props) => { value={props.value} onChange={props.onChange} isReadOnly={props.isReadOnly} + testId={props.testId} /> ); } diff --git a/ui/components/ui/IconToggle/OnOffToggle/OnOffToggle.tsx b/ui/components/ui/IconToggle/OnOffToggle/OnOffToggle.tsx index 6f382714c..104cd0a54 100644 --- a/ui/components/ui/IconToggle/OnOffToggle/OnOffToggle.tsx +++ b/ui/components/ui/IconToggle/OnOffToggle/OnOffToggle.tsx @@ -5,7 +5,8 @@ import powerIcon from './power.svg'; export type OnOffToggleProps = { value: boolean, onChange: (v: boolean) => void, - isReadOnly?: boolean + isReadOnly?: boolean, + testId?: string }; const OnOffToggle: React.FC = (props) => { @@ -32,6 +33,7 @@ const OnOffToggle: React.FC = (props) => { value={props.value} onChange={props.onChange} isReadOnly={props.isReadOnly} + testId={props.testId} /> ); } diff --git a/ui/components/ui/Icons/Icons.tsx b/ui/components/ui/Icons/Icons.tsx index b336211dc..464c818b4 100644 --- a/ui/components/ui/Icons/Icons.tsx +++ b/ui/components/ui/Icons/Icons.tsx @@ -37,6 +37,7 @@ export const NodeIcon: React.FC = (props) => { ${props.className || ''} ${props.size === 'small' ? s.NodeIconSmall : ''} `} + data-testid="nav-node-toggle" onClick={props.onClick} > {props.title} diff --git a/ui/components/ui/Input/StringFilterInput/StringFilterInput.tsx b/ui/components/ui/Input/StringFilterInput/StringFilterInput.tsx index 162a3c4d2..874083111 100644 --- a/ui/components/ui/Input/StringFilterInput/StringFilterInput.tsx +++ b/ui/components/ui/Input/StringFilterInput/StringFilterInput.tsx @@ -8,12 +8,14 @@ export type StringFilterInputProps = { isMatchCase: boolean, onIsMatchCaseChange: (v: boolean) => void, size?: InputProps['size'], - isReadOnly?: boolean + isReadOnly?: boolean, + testId?: string }; const StringFilterInput: React.FC = (props) => { return ( props.mode === 'edit' ? onChange : () => {}}` - the argument was discarded and a + * function was passed instead of applying the value), and readonly mode must suppress edits. + * + * JsonView renders Monaco (unavailable in jsdom), so it is mocked to a button that fires its + * onChange with a fixed value - exactly the wiring KeyValueEditor is responsible for. + */ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import KeyValueEditor from './KeyValueEditor'; + +type Kv = { key: string; value: string }[]; +const EDIT: Kv = [{ key: 'edited', value: 'v' }]; + +jest.mock('../JsonView/JsonView', () => ({ + __esModule: true, + default: (props: { onChange?: (v: Kv) => void; readonly?: boolean }) => ( + + ), +})); + +function renderInJsonView(mode: 'edit' | 'readonly', onChange: (v: Kv) => void) { + render(); + // Toggle from list to JSON view. + fireEvent.click(screen.getByTestId('key-value-display-changer-t')); +} + +describe('BUG-2: KeyValueEditor As-JSON wiring', () => { + it('applies JSON edits to onChange in edit mode', () => { + const onChange = jest.fn(); + renderInJsonView('edit', onChange); + fireEvent.click(screen.getByTestId('mock-jsonview-fire')); + expect(onChange).toHaveBeenCalledWith(EDIT); + }); + + it('suppresses edits and marks JsonView readonly in readonly mode', () => { + const onChange = jest.fn(); + renderInJsonView('readonly', onChange); + expect(screen.getByTestId('mock-jsonview-fire').getAttribute('data-readonly')).toBe('true'); + fireEvent.click(screen.getByTestId('mock-jsonview-fire')); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/components/ui/KeyValueEditor/KeyValueEditor.tsx b/ui/components/ui/KeyValueEditor/KeyValueEditor.tsx index 61942ea02..1c822da3b 100644 --- a/ui/components/ui/KeyValueEditor/KeyValueEditor.tsx +++ b/ui/components/ui/KeyValueEditor/KeyValueEditor.tsx @@ -68,11 +68,11 @@ const KeyValueEditor = (props: Props) => { {jsonView && props.mode === 'edit' ? onChange : () => {}} + onChange={(v) => { if (props.mode !== 'readonly') { onChange(v); } }} changeValidity={changeValidity} height={props.height || 'unset'} width={props.width || '100%'} - readonly={false} + readonly={props.mode === 'readonly'} /> }
    diff --git a/ui/components/ui/Layout/NavigationTree/NavigationTree.tsx b/ui/components/ui/Layout/NavigationTree/NavigationTree.tsx index 32b6b34c5..a59980dd4 100644 --- a/ui/components/ui/Layout/NavigationTree/NavigationTree.tsx +++ b/ui/components/ui/Layout/NavigationTree/NavigationTree.tsx @@ -146,9 +146,16 @@ const NavigationTree: React.FC = (props) => { const [_, rest] = filterQuery.split("://"); const [tenant, namespace, topic]: (string | undefined)[] = rest.split(filterQuerySep); - const isPartition = partitionRegexp.test(filterQuery); - const partition = topic.replace(partitionRegexp, "$2"); - const topicName = isPartition ? topic.replace(partitionRegexp, "$1") : topic; + if (topic === undefined) { + return; + } + + // Match against the TOPIC segment with a fresh non-global regex: the shared /g regex's + // lastIndex made .test() alternate results, and String.replace returns its input unchanged on + // no match (never undefined), which used to rewrite every non-partition FQN as t/ns/topic/topic. + const partitionMatch = new RegExp(partitionRegexp.source).exec(topic); + const topicName = partitionMatch === null ? topic : partitionMatch[1]; + const partition = partitionMatch === null ? undefined : partitionMatch[2]; const newFilterQuery = partition === undefined ? `${tenant}/${namespace}/${topicName}` : `${tenant}/${namespace}/${topicName}/${partition}`; setFilterQuery(newFilterQuery); @@ -463,6 +470,9 @@ const NavigationTree: React.FC = (props) => { key={`tree-node-${pathStr}`} className={s.Node} onClick={handleNodeClick} + data-testid="nav-tree-node" + data-node-type={node.type} + data-node-name={node.name} >
      @@ -488,6 +498,7 @@ const NavigationTree: React.FC = (props) => { value={filterQuery} onChange={v => setFilterQuery(v)} clearable={true} + testId="nav-tree-filter" />
    @@ -501,6 +512,7 @@ const NavigationTree: React.FC = (props) => { onClick={() => setExpandedPaths([])} appearance='borderless-semitransparent' type='regular' + testId="nav-collapse-all" /> = (props) => { appearance='borderless-semitransparent' type='regular' disabled={filterPath.length > 0} + testId="nav-show-current" />
    @@ -516,7 +529,7 @@ const NavigationTree: React.FC = (props) => {
    {(!isTreeInUndefinedState && !scrollToPath.isBeenFinishedOnce) && scrollToPath.state !== 'finished' && (
    - Navigating o the selected resource... + Navigating to the selected resource...
    )} {isTreeInUndefinedState && !scrollToPath.isBeenFinishedOnce && ( diff --git a/ui/components/ui/LibraryBrowser/LibraryBrowserPanel/LibraryBrowserButtons/PickLibraryItemButton/PickLibraryItemButton.tsx b/ui/components/ui/LibraryBrowser/LibraryBrowserPanel/LibraryBrowserButtons/PickLibraryItemButton/PickLibraryItemButton.tsx index 903bd0b7f..575b4103b 100644 --- a/ui/components/ui/LibraryBrowser/LibraryBrowserPanel/LibraryBrowserButtons/PickLibraryItemButton/PickLibraryItemButton.tsx +++ b/ui/components/ui/LibraryBrowser/LibraryBrowserPanel/LibraryBrowserButtons/PickLibraryItemButton/PickLibraryItemButton.tsx @@ -32,6 +32,10 @@ const LibraryBrowserPickButton: React.FC = (props const { data: itemCount, error: itemCountError } = useSWR( swrKeys.dekaf.library.itemCount._({ itemType: props.itemType, availableForContexts: props.availableForContexts }), async () => { + if (props.availableForContexts.length === 0) { + return; // no search contexts yet - nothing to count (the server rejects an empty filter) + } + const req = new pb.GetLibraryItemsCountRequest(); req.setTypesList([managedItemTypeToPb(props.itemType)]); @@ -68,6 +72,7 @@ const LibraryBrowserPickButton: React.FC = (props null : ( { modals.push({ id: `browse-library-${Date.now()}`, diff --git a/ui/components/ui/LibraryBrowser/LibraryBrowserPanel/LibraryBrowserButtons/SaveLibraryItemButton/SaveLibraryItemButton.tsx b/ui/components/ui/LibraryBrowser/LibraryBrowserPanel/LibraryBrowserButtons/SaveLibraryItemButton/SaveLibraryItemButton.tsx index 1a901da6e..4d8afdee8 100644 --- a/ui/components/ui/LibraryBrowser/LibraryBrowserPanel/LibraryBrowserButtons/SaveLibraryItemButton/SaveLibraryItemButton.tsx +++ b/ui/components/ui/LibraryBrowser/LibraryBrowserPanel/LibraryBrowserButtons/SaveLibraryItemButton/SaveLibraryItemButton.tsx @@ -19,7 +19,8 @@ export type SaveLibraryItemButtonProps = { item: ManagedItem, libraryContext: LibraryContext, onSaved: (item: ManagedItem) => void, - appearance?: 'save' | 'create' + appearance?: 'save' | 'create', + testId?: string }; const SaveLibraryItemButton: React.FC = (props) => { @@ -30,6 +31,7 @@ const SaveLibraryItemButton: React.FC = (props) => { return ( = (props) => { {props.managedItemReference && (
    { if (props.isReadOnly) { return; diff --git a/ui/components/ui/LibraryBrowser/LibraryBrowserPanel/LibraryItemName/LibraryItemName.tsx b/ui/components/ui/LibraryBrowser/LibraryBrowserPanel/LibraryItemName/LibraryItemName.tsx index 77d98774e..278b23a42 100644 --- a/ui/components/ui/LibraryBrowser/LibraryBrowserPanel/LibraryItemName/LibraryItemName.tsx +++ b/ui/components/ui/LibraryBrowser/LibraryBrowserPanel/LibraryItemName/LibraryItemName.tsx @@ -21,6 +21,7 @@ const LibraryItemName: React.FC = (props) => { id: 'rename-library-item', title: `Rename Library Item` }} + button={{ testId: 'lib-item-rename' }} initialValue={props.value} onConfirm={props.onChange} /> diff --git a/ui/components/ui/LibraryBrowser/LibraryItemEditor/AvailableInContextsButton/AvailableInContextsButton.test.tsx b/ui/components/ui/LibraryBrowser/LibraryItemEditor/AvailableInContextsButton/AvailableInContextsButton.test.tsx new file mode 100644 index 000000000..80464a6e7 --- /dev/null +++ b/ui/components/ui/LibraryBrowser/LibraryItemEditor/AvailableInContextsButton/AvailableInContextsButton.test.tsx @@ -0,0 +1,44 @@ +/** + * @jest-environment jsdom + * + * BUG-7 regression: the "Available in Contexts" dialog's Confirm must close the modal (it used to + * apply the change but leave the modal open - only Cancel closed it). The fix lives in the button's + * onChange wrapper (`props.onChange(v); modals.pop()`), so the dialog is mocked to a Confirm button + * and the assertion is on the real Modals stack. + */ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import * as Modals from '../../../../app/contexts/Modals/Modals'; +import AvailableInContextsButton from './AvailableInContextsButton'; + +// Replace the heavy dialog (ResourceMatchersInput etc.) with a Confirm button that fires onChange. +jest.mock('./AvailableInContextsDialog/AvailableInContextsDialog', () => ({ + __esModule: true, + default: (props: { onChange: (v: unknown[]) => void }) => ( + + ), +})); + +describe('BUG-7: Available-in-Contexts Confirm closes the modal', () => { + it('pops the modal on Confirm', async () => { + const onChange = jest.fn(); + render( + + + + + + ); + + // No modal yet; open it via the trigger button. + expect(screen.queryByTestId('modal')).toBeNull(); + fireEvent.click(screen.getByText(/Available in 0 contexts/)); + expect(screen.getByTestId('modal')).toBeTruthy(); + + // Confirm must apply the change AND close the modal. + fireEvent.click(screen.getByTestId('mock-confirm')); + expect(onChange).toHaveBeenCalledTimes(1); + await waitFor(() => expect(screen.queryByTestId('modal')).toBeNull()); + }); +}); diff --git a/ui/components/ui/LibraryBrowser/LibraryItemEditor/AvailableInContextsButton/AvailableInContextsButton.tsx b/ui/components/ui/LibraryBrowser/LibraryItemEditor/AvailableInContextsButton/AvailableInContextsButton.tsx index 727f5178b..1f07fb4e7 100644 --- a/ui/components/ui/LibraryBrowser/LibraryItemEditor/AvailableInContextsButton/AvailableInContextsButton.tsx +++ b/ui/components/ui/LibraryBrowser/LibraryItemEditor/AvailableInContextsButton/AvailableInContextsButton.tsx @@ -30,7 +30,10 @@ const AvailableInContextsButton: React.FC = (pro
    { + props.onChange(v); + modals.pop(); // Confirm must close the dialog, same as Cancel + }} onCanceled={modals.pop} libraryContext={props.libraryContext} isReadOnly={props.isReadOnly} diff --git a/ui/components/ui/LibraryBrowser/SearchEditor/ResourceMatchersInput/ResourceMatcherInput/NamespaceMatcherInput/ExactNamespaceMatcherInput/ExactNamespaceMatcherInput.tsx b/ui/components/ui/LibraryBrowser/SearchEditor/ResourceMatchersInput/ResourceMatcherInput/NamespaceMatcherInput/ExactNamespaceMatcherInput/ExactNamespaceMatcherInput.tsx index 029f50c32..94d2e3f32 100644 --- a/ui/components/ui/LibraryBrowser/SearchEditor/ResourceMatchersInput/ResourceMatcherInput/NamespaceMatcherInput/ExactNamespaceMatcherInput/ExactNamespaceMatcherInput.tsx +++ b/ui/components/ui/LibraryBrowser/SearchEditor/ResourceMatchersInput/ResourceMatcherInput/NamespaceMatcherInput/ExactNamespaceMatcherInput/ExactNamespaceMatcherInput.tsx @@ -20,6 +20,7 @@ const ExactNamespaceMatcherInput: React.FC = (p value={props.value.namespace} onChange={(v) => props.onChange({ ...props.value, namespace: v })} isReadOnly={props.isReadOnly} + testId="lib-matcher-namespace" /> diff --git a/ui/components/ui/LibraryBrowser/SearchEditor/ResourceMatchersInput/ResourceMatcherInput/ResourceMatcherInput.tsx b/ui/components/ui/LibraryBrowser/SearchEditor/ResourceMatchersInput/ResourceMatcherInput/ResourceMatcherInput.tsx index c0efcf6e3..bcc4fbc3a 100644 --- a/ui/components/ui/LibraryBrowser/SearchEditor/ResourceMatchersInput/ResourceMatcherInput/ResourceMatcherInput.tsx +++ b/ui/components/ui/LibraryBrowser/SearchEditor/ResourceMatchersInput/ResourceMatcherInput/ResourceMatcherInput.tsx @@ -23,6 +23,7 @@ const ResourceMatcherInput: React.FC = (props) => { Select size="small" + testId="lib-matcher-category" value={props.value.type} onChange={(v) => { switch (v) { diff --git a/ui/components/ui/LibraryBrowser/SearchEditor/ResourceMatchersInput/ResourceMatchersInput.tsx b/ui/components/ui/LibraryBrowser/SearchEditor/ResourceMatchersInput/ResourceMatchersInput.tsx index 0411d9289..9b9f2708d 100644 --- a/ui/components/ui/LibraryBrowser/SearchEditor/ResourceMatchersInput/ResourceMatchersInput.tsx +++ b/ui/components/ui/LibraryBrowser/SearchEditor/ResourceMatchersInput/ResourceMatchersInput.tsx @@ -11,6 +11,7 @@ export type ResourceMatchersInputProps = { onChange: (value: ResourceMatcher[]) => void libraryContext: LibraryContext; isReadOnly?: boolean + addButtonTestId?: string }; const ResourceMatchersInput: React.FC = (props) => { @@ -23,6 +24,7 @@ const ResourceMatchersInput: React.FC = (props) => { > isReadOnly={props.isReadOnly} + addButtonTestId={props.addButtonTestId} value={props.value} renderItem={(matcher) => { return ( diff --git a/ui/components/ui/LibraryBrowser/SearchResults/DeleteLibraryItemButton/DeleteLibraryItemButton.tsx b/ui/components/ui/LibraryBrowser/SearchResults/DeleteLibraryItemButton/DeleteLibraryItemButton.tsx index 45bf808d1..c1454fb20 100644 --- a/ui/components/ui/LibraryBrowser/SearchResults/DeleteLibraryItemButton/DeleteLibraryItemButton.tsx +++ b/ui/components/ui/LibraryBrowser/SearchResults/DeleteLibraryItemButton/DeleteLibraryItemButton.tsx @@ -25,7 +25,8 @@ const DeleteLibraryItemButton: React.FC = (props) type: 'regular', title: 'Delete this item', svgIcon: deleteIcon, - appearance: 'borderless-semitransparent' + appearance: 'borderless-semitransparent', + testId: 'lib-item-delete' }} modal={{ title: 'Delete Library Item', diff --git a/ui/components/ui/LibraryBrowser/SearchResults/SearchResults.tsx b/ui/components/ui/LibraryBrowser/SearchResults/SearchResults.tsx index c4a41da94..bdb39e85a 100644 --- a/ui/components/ui/LibraryBrowser/SearchResults/SearchResults.tsx +++ b/ui/components/ui/LibraryBrowser/SearchResults/SearchResults.tsx @@ -50,6 +50,11 @@ const SearchResults: React.FC = (props) => { useEffect(() => { async function fetchSearchResults() { + if (props.resourceMatchers.length === 0) { + props.onItems([]); // no search contexts - nothing to list (the server rejects an empty filter) + return; + } + const req = new pb.ListLibraryItemsRequest(); const contextsList = props.resourceMatchers.map(rm => resourceMatcherToPb(rm)) @@ -118,6 +123,7 @@ const SearchResults: React.FC = (props) => { onChange={setFilterInputValue} appearance='no-borders' clearable + testId="lib-search-filter" />
    @@ -126,6 +132,7 @@ const SearchResults: React.FC = (props) => { options={['Name', 'Last Modified']} value={sort} onChange={setSort} + testId="lib-search-sort" />
    @@ -187,7 +194,7 @@ const Item: React.FC = (props) => { const i18n = I18n.useContext(); return ( -
    +
    {props.libraryItem.spec.metadata.name ||
    Unnamed
    }
    @@ -205,7 +212,7 @@ const Item: React.FC = (props) => { {!props.isReadOnly && ( { modals.push({ id: `edit-library-item-${props.libraryItem.spec.metadata.id}`, diff --git a/ui/components/ui/LibraryBrowser/dialogs/BrowseDialog/BrowseDialog.tsx b/ui/components/ui/LibraryBrowser/dialogs/BrowseDialog/BrowseDialog.tsx index 9da391bc2..05bb841ad 100644 --- a/ui/components/ui/LibraryBrowser/dialogs/BrowseDialog/BrowseDialog.tsx +++ b/ui/components/ui/LibraryBrowser/dialogs/BrowseDialog/BrowseDialog.tsx @@ -85,6 +85,7 @@ const BrowseDialog: React.FC = (props) => { return (
    = (props) => { type='regular' text='Cancel' onClick={props.onCanceled} + testId="lib-browse-cancel" /> )} {!props.isHideSelectButton && (
    diff --git a/ui/components/ui/LibraryBrowser/dialogs/SaveItemDialog/SaveItemDialog.tsx b/ui/components/ui/LibraryBrowser/dialogs/SaveItemDialog/SaveItemDialog.tsx index 3a063b018..de83d4b17 100644 --- a/ui/components/ui/LibraryBrowser/dialogs/SaveItemDialog/SaveItemDialog.tsx +++ b/ui/components/ui/LibraryBrowser/dialogs/SaveItemDialog/SaveItemDialog.tsx @@ -112,7 +112,7 @@ const SaveItemDialog: React.FC = (props) => { } return ( -
    +
    @@ -161,10 +161,12 @@ const SaveItemDialog: React.FC = (props) => { type='regular' text='Cancel' onClick={props.onCanceled} + testId="lib-save-dialog-cancel" />
    diff --git a/ui/components/ui/LibrarySidebar/Library/AllObjectTypes/AllObjectTypes.tsx b/ui/components/ui/LibrarySidebar/Library/AllObjectTypes/AllObjectTypes.tsx index d5a8b93f3..fecc2b4fd 100644 --- a/ui/components/ui/LibrarySidebar/Library/AllObjectTypes/AllObjectTypes.tsx +++ b/ui/components/ui/LibrarySidebar/Library/AllObjectTypes/AllObjectTypes.tsx @@ -60,16 +60,19 @@ const AllObjectTypes: React.FC = (props) => { value={resourceMatchers} onChange={setResourceMatchers} libraryContext={props.libraryContext} + addButtonTestId="lib-add-context" />
    { itemTypes.sort((a, b) => getReadableItemType(a).localeCompare(getReadableItemType(b))).map(itemType => { - const itemCount = itemCountPerType[itemType] ?? 0; + // undefined = count not loaded yet (or the fetch failed) - distinct from a real zero, + // so "NoData" can't render (and tests can't assert emptiness) before the data arrived. + const loadedCount = itemCountPerType[itemType]; return ( -
    +
    @@ -79,7 +82,7 @@ const AllObjectTypes: React.FC = (props) => { />
    - {itemCount === 0 && ( + {loadedCount === 0 && (
    @@ -104,6 +107,7 @@ const AllObjectTypes: React.FC = (props) => { libraryContext={props.libraryContext} onSaved={() => setRefreshItemCountKey(v => v + 1)} appearance='create' + testId="lib-create-item" />
    diff --git a/ui/components/ui/LibrarySidebar/Library/ConsumerSessions/ConsumerSessions.tsx b/ui/components/ui/LibrarySidebar/Library/ConsumerSessions/ConsumerSessions.tsx index 61540dd4b..e8435aa52 100644 --- a/ui/components/ui/LibrarySidebar/Library/ConsumerSessions/ConsumerSessions.tsx +++ b/ui/components/ui/LibrarySidebar/Library/ConsumerSessions/ConsumerSessions.tsx @@ -18,6 +18,7 @@ const ConsumerSessions: React.FC = (props) => {
    = (props) => { tabs={[ { key: 'consumer-sessions', + testId: 'lib-subtab-consumer-sessions', render: () => ( = (props) => { }, { key: 'all-object-types', + testId: 'lib-subtab-all-items', render: () => ( = (props) => { tabs={[ { key: 'library', + testId: 'lib-tab-library', title: 📚 Library{itemsCount.library === undefined ? : <> {itemsCount.library}}, render: () => ( = (props) => { }, { key: 'notes', + testId: 'lib-tab-notes', title: 🗒 Notes{itemsCount.notes === undefined ? : <> {itemsCount.notes}}, render: () => ( = (props) => { const [notes, setNotes] = useState([]); const [fetchCount, setFetchCount] = useState(0); const [selectedNoteId, setSelectedNoteId] = useState(props.libraryContext.pulsarResource.type === 'instance' ? blogNote.metadata.id : undefined); - const [refreshIframeKey, setRefreshIframeKey] = useState(0); const modals = Modals.useContext(); const fetchNotes = async () => { @@ -213,6 +211,7 @@ const Notes: React.FC = (props) => { return (
    = (props) => { const isFactoryNote = factoryNotes.some(n => n.metadata.id === note.metadata.id); const tab: Tab = { key: note.metadata.id, + testId: 'lib-note-tab', title: note.metadata.name, render: () => (
    - {selectedNoteId === blogNote.metadata.id && ( -
    -
    - setRefreshIframeKey(v => v + 1)} - text='Show all updates' - svgIcon={backIcon} - /> -
    -