@camunda/e2e-test-suite 0.0.1273 → 0.0.1275

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/pages/8.7/AppsPage.js +11 -3
  2. package/dist/pages/8.7/ClusterDetailsPage.d.ts +1 -0
  3. package/dist/pages/8.7/ClusterDetailsPage.js +22 -64
  4. package/dist/pages/8.7/ModelerCreatePage.js +56 -14
  5. package/dist/pages/8.7/PlayPage.js +12 -1
  6. package/dist/pages/8.7/SettingsPage.js +15 -2
  7. package/dist/pages/8.7/UtilitiesPage.js +22 -0
  8. package/dist/pages/8.8/AppsPage.d.ts +2 -0
  9. package/dist/pages/8.8/AppsPage.js +122 -0
  10. package/dist/pages/8.8/ClusterDetailsPage.d.ts +1 -0
  11. package/dist/pages/8.8/ClusterDetailsPage.js +29 -10
  12. package/dist/pages/8.8/ClusterPage.js +35 -2
  13. package/dist/pages/8.8/ClusterSecretsPage.d.ts +1 -0
  14. package/dist/pages/8.8/ClusterSecretsPage.js +62 -2
  15. package/dist/pages/8.8/FormJsPage.js +55 -24
  16. package/dist/pages/8.8/HomePage.d.ts +3 -0
  17. package/dist/pages/8.8/HomePage.js +62 -7
  18. package/dist/pages/8.8/LoginPage.js +32 -1
  19. package/dist/pages/8.8/ModelerCreatePage.d.ts +1 -0
  20. package/dist/pages/8.8/ModelerCreatePage.js +78 -16
  21. package/dist/pages/8.8/ModelerHomePage.d.ts +10 -0
  22. package/dist/pages/8.8/ModelerHomePage.js +339 -14
  23. package/dist/pages/8.8/UtilitiesPage.js +47 -2
  24. package/dist/tests/8.7/rba-enabled-user-flows.spec.js +5 -1
  25. package/dist/tests/8.7/web-modeler-user-flows.spec.js +7 -4
  26. package/dist/tests/8.8/test-setup.spec.js +29 -4
  27. package/dist/utils/consoleApiHelpers.d.ts +5 -0
  28. package/dist/utils/consoleApiHelpers.js +177 -2
  29. package/dist/utils/hubDeployDialog.d.ts +3 -0
  30. package/dist/utils/hubDeployDialog.js +74 -0
  31. package/dist/utils/hubResourceRename.d.ts +4 -0
  32. package/dist/utils/hubResourceRename.js +74 -0
  33. package/package.json +1 -1
@@ -501,10 +501,18 @@ class AppsPage {
501
501
  // Camunda Hub folded Console into the same persistent shell as
502
502
  // Modeler/home: Console's Dashboard/Clusters/Organization links are just
503
503
  // another section of the same "Main navigation" sidebar, not a separate
504
- // app to switch into. Its header banner (homePage.consoleBanner) is
505
- // already visible from any Hub page, so there is no navigation action
506
- // needed here -- mirrors the same hostname guard in clickModeler().
504
+ // app to switch into -- but ONLY on routes the Hub sidebar registry marks
505
+ // sidebarMode 'default' ('/', '/org/:id', '/workspaces', '/environments',
506
+ // '/console/*'). Any '/workspaces/:wsId/...' route is sidebarMode
507
+ // 'workspace', whose sidebar drops the Environments/Clusters item
508
+ // entirely (camunda-hub features/navigation/registry.ts,
509
+ // use-ds-sidebar-items.tsx) -- so a caller landing there and finding
510
+ // "already on hub." would previously no-op onto a page that can never
511
+ // show homePage.clusterTab. '/workspaces' is unconditionally 'default',
512
+ // so navigate there directly rather than assuming the current route
513
+ // already has it.
507
514
  if (new URL(this.page.url()).hostname.startsWith('hub.')) {
515
+ await this.page.goto('/workspaces', { waitUntil: 'domcontentloaded' });
508
516
  return;
509
517
  }
510
518
  const maxRetries = 3;
@@ -22,6 +22,7 @@ declare class ClusterDetailsPage {
22
22
  readonly clientCredentialsDialog: Locator;
23
23
  readonly clustersLink: Locator;
24
24
  readonly userTaskRestriction: Locator;
25
+ readonly rbaToggle: Locator;
25
26
  readonly enableRBADialog: Locator;
26
27
  readonly disableRBADialog: Locator;
27
28
  readonly rbaEnabledMessage: Locator;
@@ -28,6 +28,7 @@ class ClusterDetailsPage {
28
28
  clientCredentialsDialog;
29
29
  clustersLink;
30
30
  userTaskRestriction;
31
+ rbaToggle;
31
32
  enableRBADialog;
32
33
  disableRBADialog;
33
34
  rbaEnabledMessage;
@@ -160,6 +161,13 @@ class ClusterDetailsPage {
160
161
  this.rbaHeading = this.page.getByRole('heading', {
161
162
  name: 'Resource-based authorizations',
162
163
  });
164
+ // A nightly trace screenshot confirmed this toggle rendered as a small
165
+ // pill switch, not a Carbon .cds--toggle -- the class scraping this used
166
+ // to rely on matched 0 elements. No DOM snapshot was available to read
167
+ // the exact markup (page.$$ calls aren't snapshotted), but
168
+ // userTaskRestriction above -- on this same Settings page, still passing
169
+ // -- proves this page's toggle buttons carry aria-label="Switch <Label>".
170
+ this.rbaToggle = page.locator('button[aria-label="Switch Resource-based authorizations"]');
163
171
  this.clientCredentialsLink = (clientCredentials) => page
164
172
  .getByRole('cell', { name: clientCredentials })
165
173
  .getByText(clientCredentials);
@@ -228,73 +236,23 @@ class ClusterDetailsPage {
228
236
  }
229
237
  async enableRBA() {
230
238
  await (0, test_1.expect)(this.rbaHeading).toBeVisible({ timeout: 60000 });
231
- // Locate all elements with class .cds--toggle__text
232
- const toggleTextElements = await this.page.$$('.cds--toggle__text');
233
- // Check if toggleTextElements has at least 2 elements
234
- if (toggleTextElements.length >= 2) {
235
- // Get the text content of the second toggle text element
236
- const toggleText = await toggleTextElements[1].textContent();
237
- // Check if the text content is 'Disabled'
238
- if (toggleText === 'Disabled') {
239
- // Locate all elements with class .cds--toggle__switch
240
- const toggleSwitchElements = await this.page.$$('.cds--toggle__switch');
241
- // Check if toggleSwitchElements has at least 2 elements
242
- if (toggleSwitchElements.length >= 2) {
243
- // Click the second toggle switch element
244
- await toggleSwitchElements[1].click();
245
- console.log('Second toggle switch clicked.');
246
- await (0, test_1.expect)(this.enableRBADialog).toBeVisible({ timeout: 60000 });
247
- await this.rbaEnableButton.click();
248
- await (0, test_1.expect)(this.page.getByText('Enabling...')).not.toBeVisible({
249
- timeout: 60000,
250
- });
251
- }
252
- else {
253
- console.log('Second toggle switch not found or less than 2.');
254
- }
255
- }
256
- else {
257
- console.log("Second toggle text does not contain 'Disabled'.");
258
- }
259
- }
260
- else {
261
- console.log('Toggle text elements not found or less than 2.');
262
- }
239
+ await (0, test_1.expect)(this.rbaToggle).toBeVisible({ timeout: 30000 });
240
+ await this.rbaToggle.click();
241
+ await (0, test_1.expect)(this.enableRBADialog).toBeVisible({ timeout: 60000 });
242
+ await this.rbaEnableButton.click();
243
+ await (0, test_1.expect)(this.page.getByText('Enabling...')).not.toBeVisible({
244
+ timeout: 60000,
245
+ });
263
246
  }
264
247
  async disableRBA() {
265
248
  await (0, test_1.expect)(this.rbaHeading).toBeVisible({ timeout: 60000 });
266
- // Locate all elements with class .cds--toggle__text
267
- const toggleTextElements = await this.page.$$('.cds--toggle__text');
268
- // Check if toggleTextElements has at least 2 elements
269
- if (toggleTextElements.length >= 2) {
270
- // Get the text content of the second toggle text element
271
- const toggleText = await toggleTextElements[1].textContent();
272
- // Check if the text content is 'Enabled'
273
- if (toggleText === 'Enabled') {
274
- // Locate all elements with class .cds--toggle__switch
275
- const toggleSwitchElements = await this.page.$$('.cds--toggle__switch');
276
- // Check if toggleSwitchElements has at least 2 elements
277
- if (toggleSwitchElements.length >= 2) {
278
- // Click the second toggle switch element
279
- await toggleSwitchElements[1].click();
280
- console.log('Second toggle switch clicked.');
281
- await (0, test_1.expect)(this.disableRBADialog).toBeVisible({ timeout: 60000 });
282
- await this.rbaDisableButton.click();
283
- await (0, test_1.expect)(this.page.getByText('Disabling...')).not.toBeVisible({
284
- timeout: 60000,
285
- });
286
- }
287
- else {
288
- console.log('Second toggle switch not found or less than 2.');
289
- }
290
- }
291
- else {
292
- console.log("Second toggle text does not contain 'Enabled'.");
293
- }
294
- }
295
- else {
296
- console.log('Toggle text elements not found or less than 2.');
297
- }
249
+ await (0, test_1.expect)(this.rbaToggle).toBeVisible({ timeout: 30000 });
250
+ await this.rbaToggle.click();
251
+ await (0, test_1.expect)(this.disableRBADialog).toBeVisible({ timeout: 60000 });
252
+ await this.rbaDisableButton.click();
253
+ await (0, test_1.expect)(this.page.getByText('Disabling...')).not.toBeVisible({
254
+ timeout: 60000,
255
+ });
298
256
  }
299
257
  async searchAndClickClientCredentialsLink(name) {
300
258
  await (0, test_1.expect)(this.clientsList.first()).toBeVisible({ timeout: 60000 });
@@ -6,6 +6,7 @@ const sleep_1 = require("../../utils/sleep");
6
6
  const ConnectorMarketplacePage_1 = require("./ConnectorMarketplacePage");
7
7
  const ProcessApplicationPage_1 = require("./ProcessApplicationPage");
8
8
  const modelerCluster_1 = require("../../utils/modelerCluster");
9
+ const sessionRecovery_1 = require("../../utils/sessionRecovery");
9
10
  class ModelerCreatePage {
10
11
  page;
11
12
  processApplicationPage;
@@ -364,9 +365,16 @@ class ModelerCreatePage {
364
365
  this.copyPublicLinkButton = page.getByRole('button', {
365
366
  name: 'Copy to clipboard',
366
367
  });
368
+ // Legacy Carbon toast vs. the design-system's Sonner-based toast
369
+ // (data-sonner-toast, confirmed from a nightly DOM snapshot) -- same
370
+ // migration already handled for the deploy/instance-started toasts
371
+ // elsewhere in this file and in UtilitiesPage.ts's runProcess.
367
372
  this.formLinkedSuccessMessage = page
368
373
  .locator('.cds--toast-notification__subtitle')
369
- .getByText('Form has been successfully linked.');
374
+ .getByText('Form has been successfully linked.')
375
+ .or(page
376
+ .locator('[data-sonner-toast]')
377
+ .getByText('Form has been successfully linked.'));
370
378
  this.diagramNameBreadcrumb = (diagramName) => page.locator('button[data-test="breadcrumb-diagram"]', {
371
379
  hasText: diagramName,
372
380
  });
@@ -609,13 +617,32 @@ class ModelerCreatePage {
609
617
  }
610
618
  }
611
619
  async clickDeployAndRunMainButton() {
612
- // This runs straight after a navigation back to the diagram, so the
613
- // modeler still has to boot and render its toolbar. The trace shows the
614
- // button first resolving ~35s after that navigation, which the previous
615
- // flat 30s click budget expired just short of. Wait for it to render
616
- // before spending the click budget on it.
617
- await (0, test_1.expect)(this.deployAndRunMainButton).toBeVisible({ timeout: 120000 });
618
- await this.deployAndRunMainButton.click({ timeout: 30000 });
620
+ for (let attempt = 0; attempt < 2; attempt++) {
621
+ try {
622
+ // This runs straight after a navigation back to the diagram, so the
623
+ // modeler still has to boot and render its toolbar. The trace shows
624
+ // the button first resolving ~35s after that navigation, which the
625
+ // previous flat 30s click budget expired just short of. Wait for it
626
+ // to render before spending the click budget on it.
627
+ await (0, test_1.expect)(this.deployAndRunMainButton).toBeVisible({
628
+ timeout: 120000,
629
+ });
630
+ await this.deployAndRunMainButton.click({ timeout: 30000 });
631
+ return;
632
+ }
633
+ catch (error) {
634
+ // Hub's own /login route can hang mid-wait when its session POST
635
+ // 5xx's, leaving the diagram (and this button) never rendering --
636
+ // the same session loss utils/sessionRecovery.ts already recovers
637
+ // from elsewhere. One retry after recovering; any other cause
638
+ // rethrows unchanged.
639
+ const recovered = await (0, sessionRecovery_1.recoverStuckModelerLogin)(this.page);
640
+ const restored = await (0, sessionRecovery_1.restoreSessionIfLoggedOut)(this.page);
641
+ if ((!recovered && !restored) || attempt === 1) {
642
+ throw error;
643
+ }
644
+ }
645
+ }
619
646
  }
620
647
  async clickDeployAndRunSubButton() {
621
648
  await this.deployAndRunSubButton.click({ timeout: 30000 });
@@ -1953,16 +1980,31 @@ class ModelerCreatePage {
1953
1980
  await this.copyPublicLinkButton.click({ timeout: 60000 });
1954
1981
  }
1955
1982
  async getPublicationLink() {
1983
+ // 'clipboard-write' must be granted BEFORE the click that copies the
1984
+ // link -- the nightly trace showed the read always resolving empty
1985
+ // (both attempts), with the click itself never observed to fail. The
1986
+ // previous code only granted 'clipboard-read', after the click, which
1987
+ // does nothing for the write the click triggers.
1988
+ const context = this.page.context();
1989
+ await context.grantPermissions(['clipboard-read', 'clipboard-write']);
1956
1990
  await this.clickPublicationTab();
1957
1991
  await (0, test_1.expect)(this.copyPublicLinkButton).toBeVisible({ timeout: 60000 });
1958
1992
  await this.clickCopyPublicLinkButton();
1959
- await (0, sleep_1.sleep)(1000);
1960
- const context = this.page.context();
1961
- await context.grantPermissions(['clipboard-read']);
1962
- const clipboardText = await this.page.evaluate(async () => {
1963
- return await navigator.clipboard.readText();
1964
- });
1993
+ // Poll instead of one blind read after a fixed sleep -- the write is
1994
+ // async on the page's side and has no visible completion signal to wait
1995
+ // on directly.
1996
+ let clipboardText = '';
1997
+ for (let attempt = 0; attempt < 10 && !clipboardText; attempt++) {
1998
+ await (0, sleep_1.sleep)(500);
1999
+ clipboardText = await this.page.evaluate(async () => {
2000
+ return await navigator.clipboard.readText();
2001
+ });
2002
+ }
1965
2003
  console.log('Captured public link:', clipboardText);
2004
+ if (!clipboardText) {
2005
+ throw new Error('getPublicationLink: clipboard read empty after clicking Copy to ' +
2006
+ 'clipboard -- the copy never reached the clipboard.');
2007
+ }
1966
2008
  return clipboardText;
1967
2009
  }
1968
2010
  async assertFormEmbedded() {
@@ -29,7 +29,18 @@ class PlayPage {
29
29
  .or(page
30
30
  .getByRole('tablist', { name: 'List of modes' })
31
31
  .getByRole('tab', { name: 'Test', exact: true }));
32
- this.completeJobButton = this.diagram.getByLabel('Complete job');
32
+ // Not getByLabel(): a DevTools capture during camunda-hub#29604's
33
+ // investigation showed this button rendering with NO aria-label
34
+ // attribute at all, yet still carrying a
35
+ // `<span class="sr-only">Complete job</span>` that gives it a perfectly
36
+ // valid accessible name from content. getByLabel() only resolves via
37
+ // aria-label/aria-labelledby/<label> association, so it can't see that
38
+ // span -- getByRole's name option does the full ARIA accessible-name
39
+ // computation (including visually-hidden content), so it matches this
40
+ // button regardless of whether the aria-label attribute itself is set.
41
+ this.completeJobButton = this.diagram.getByRole('button', {
42
+ name: 'Complete job',
43
+ });
33
44
  // Newer Modeler Play UI: the "Configure Scenario" floating card hosts a
34
45
  // primary "Start" Carbon button (variants: "Start", "Start with
35
46
  // variables", "Start with a form"). Source:
@@ -7,8 +7,21 @@ class SettingsPage {
7
7
  logoutButton;
8
8
  constructor(page) {
9
9
  this.page = page;
10
- this.openSettingsButton = page.getByLabel('Open Settings');
11
- this.logoutButton = page.getByRole('button', { name: 'Log out' });
10
+ // DS shell renders a `User menu, <full name>` button; the legacy Carbon
11
+ // shell renders useCamundaTools' `Open Settings` button. Both still ship.
12
+ this.openSettingsButton = page
13
+ .getByLabel('Open Settings')
14
+ .or(page.getByRole('button', { name: /^User menu,/ }))
15
+ .first();
16
+ // DS shell's user-menu dropdown renders items via Radix, which sets
17
+ // role="menuitem" (confirmed in camunda-hub's nav-shell-ds.spec.saas.tsx);
18
+ // the legacy Carbon panel renders a plain role="button". .first() avoids
19
+ // a strict-mode violation if both shells' triggers are ever present at
20
+ // once (SM-8.10's SettingsPage uses the same guard for this union).
21
+ this.logoutButton = page
22
+ .getByRole('button', { name: 'Log out' })
23
+ .or(page.getByRole('menuitem', { name: 'Log out' }))
24
+ .first();
12
25
  }
13
26
  async clickOpenSettingsButton(retries = 3) {
14
27
  for (let i = 0; i < retries; i++) {
@@ -34,6 +34,28 @@ async function suppressOnboardingTour(page) {
34
34
  }, '^hub\\.onboarding\\..*\\.seen\\.');
35
35
  }
36
36
  async function loginWithRetry(page, loginPage, testUser, timeout, maxRetries = 5, skipOrgAssertion = false) {
37
+ // Fail fast on a testUser missing credentials instead of burning the full
38
+ // exponential-backoff retry budget (~8-13 min across 5 attempts)
39
+ // resubmitting a login Auth0 rejects with "Password is required" every
40
+ // time, then surfacing the generic "Login failed after N attempts" with no
41
+ // mention of which credential was empty. test-setup.spec.ts's per-user
42
+ // loop and requireTestUser() already guard this for specs that reach a
43
+ // numbered credential slot directly -- callers that pass testUsers.mainUser
44
+ // or another named user straight through (e.g. "Create AWS Cluster") had no
45
+ // such guard, and hit exactly this failure when C8_USERNAME/C8_PASSWORD
46
+ // (the org owner) came back unset.
47
+ if (!testUser.username || !testUser.password) {
48
+ const missing = [
49
+ testUser.username ? null : 'username',
50
+ testUser.password ? null : 'password',
51
+ ]
52
+ .filter(Boolean)
53
+ .join(' and ');
54
+ throw new Error(`Cannot log in as ${testUser.username || '(unknown user)'}: ${missing} ` +
55
+ 'not configured -- this SaaS org was created without that ' +
56
+ "user's credentials. See scripts/flakiness-saas-create-org.sh, " +
57
+ 'which sizes the org from the slots each spec names.');
58
+ }
37
59
  await suppressOnboardingTour(page);
38
60
  // Callers derive `timeout` as (workerIndex + 1) * 1000, so it's a stable
39
61
  // per-worker identifier: a Playwright worker is a long-lived process that
@@ -18,8 +18,10 @@ declare class AppsPage {
18
18
  readonly consoleLink: Locator;
19
19
  readonly camundaComponentsButton: Locator;
20
20
  readonly camundaAppsButton: Locator;
21
+ readonly hubSidebar: Locator;
21
22
  constructor(page: Page);
22
23
  clickCluster(component: Locator, name: string): Promise<void>;
24
+ private clickHubClusterApp;
23
25
  clickModeler(): Promise<void>;
24
26
  /**
25
27
  * Waits for the Modeler app to actually render, and recovers the one landing
@@ -23,6 +23,7 @@ class AppsPage {
23
23
  consoleLink;
24
24
  camundaComponentsButton;
25
25
  camundaAppsButton;
26
+ hubSidebar;
26
27
  constructor(page) {
27
28
  this.page = page;
28
29
  this.modelerLink = page.getByRole('link', { name: 'Modeler', exact: true });
@@ -34,6 +35,11 @@ class AppsPage {
34
35
  this.consoleLink = page.getByRole('link', { name: 'Console', exact: true });
35
36
  this.camundaComponentsButton = page.getByLabel('Camunda components');
36
37
  this.camundaAppsButton = page.getByLabel('Camunda apps');
38
+ // Camunda Hub's persistent "Main navigation" sidebar lists each cluster
39
+ // with its own apps (Operate/Tasklist/Optimize/Identity) as child links
40
+ // -- there is no per-app switcher panel to open on Hub (see
41
+ // clickCamundaApps below). Ported from pages/8.9/AppsPage.ts.
42
+ this.hubSidebar = page.getByRole('navigation', { name: 'Main navigation' });
37
43
  this.tasklistListItem = this.page
38
44
  .getByRole('listitem')
39
45
  .filter({ hasText: 'Tasklist' });
@@ -67,7 +73,83 @@ class AppsPage {
67
73
  await (0, test_1.expect)(cluster).toBeVisible({ timeout: 60000 });
68
74
  await cluster.click();
69
75
  }
76
+ // Camunda Hub's sidebar groups by cluster, not by app: each cluster row
77
+ // expands to reveal its own per-app child links (Operate/Tasklist/...).
78
+ // camunda-hub's own nav-shell-ds.spec.saas.tsx documents the shape: the
79
+ // design system splits the group row into a label control and a chevron
80
+ // control, both labelled by the cluster name; either one opens it -- and
81
+ // the child links do not exist in the DOM at all until expanded (not just
82
+ // hidden), and are not nested under any container matchable by cluster
83
+ // name once expanded. So: find the toggle by the cluster's own name
84
+ // (`.first()` because both controls share that name), click it, then look
85
+ // up the app link sidebar-wide rather than scoped to a "group" container
86
+ // that doesn't reliably exist. Ported from pages/8.9/AppsPage.ts, where
87
+ // this shape is already nightly-proven.
88
+ async clickHubClusterApp(clusterName, appName) {
89
+ // When the sidebar is collapsed (the default on a fresh session), the
90
+ // group toggle opens a Radix popover -- a role="dialog" labelled with the
91
+ // cluster name and portalled OUTSIDE the "Main navigation" <nav> -- so a
92
+ // sidebar-scoped lookup finds nothing there. Look in that popover first;
93
+ // keep the sidebar-scoped lookup for the expanded-sidebar layout, where
94
+ // the links render inline. Not exact: the rendered accessible name
95
+ // carries more than the bare app name (e.g. "Operate in <cluster>
96
+ // (opens in a new tab)").
97
+ const appLink = this.page
98
+ .getByRole('dialog', { name: clusterName, exact: true })
99
+ .getByRole('link', { name: appName })
100
+ .or(this.hubSidebar.getByRole('link', { name: appName }))
101
+ .first();
102
+ // Decide whether to expand from clusterName's OWN toggle state, not from
103
+ // whether "some" appLink is already visible sidebar-wide: in a
104
+ // multi-cluster org another cluster's group can already be expanded with
105
+ // the same app name, and the click would then fire on the wrong
106
+ // cluster's link.
107
+ const collapsedToggle = this.hubSidebar
108
+ .getByRole('button', { name: clusterName, expanded: false })
109
+ .first();
110
+ if (await collapsedToggle.isVisible({ timeout: 5000 }).catch(() => false)) {
111
+ await collapsedToggle.click({ timeout: 10000 });
112
+ }
113
+ await (0, test_1.expect)(appLink).toBeVisible({ timeout: 30000 });
114
+ // Hub's cluster-app links carry target="_blank", so clicking them opens
115
+ // the app in a new tab while every caller keeps asserting on this.page.
116
+ // Follow the href in the current tab instead, matching the same-tab
117
+ // navigation the Console app switcher gave these callers.
118
+ const href = await appLink.getAttribute('href');
119
+ if (href) {
120
+ await this.page.goto(new URL(href, this.page.url()).toString());
121
+ return;
122
+ }
123
+ await appLink.click({ timeout: 10000 });
124
+ }
70
125
  async clickModeler() {
126
+ // Camunda Hub (hub.*) replaced Console + Web Modeler on SaaS and has no
127
+ // app switcher. Its home is the Modeler/Workspaces home, but a hostname
128
+ // check alone can't tell whether we're already there -- Hub's cluster
129
+ // pages are on the same hub.* hostname, and a no-op here would leave a
130
+ // caller that reaches clickModeler() from a cluster/API-client page
131
+ // stranded with the next locator running against the wrong page.
132
+ // Navigate home through the header logo link instead, same as
133
+ // ModelerHomePage.clickHomeBreadcrumb -- clicking it when already on the
134
+ // Modeler home is a harmless in-SPA no-op.
135
+ if (new URL(this.page.url()).hostname.startsWith('hub.')) {
136
+ const header = this.page.locator('header[data-slot="app-header"]');
137
+ // The href stays literally "/login" until the client's auth state
138
+ // catches up just after login (camunda-hub NavShellDs.common.tsx) --
139
+ // clicking it in that window would log the test out instead of
140
+ // navigating home, so exclude that href and wait for the authenticated
141
+ // one rather than racing it.
142
+ const homeLink = header
143
+ .getByRole('link', { name: 'Camunda Modeler home' })
144
+ .and(this.page.locator(':not([href$="/login"])'))
145
+ .or(header
146
+ .getByRole('link', { name: 'Camunda Hub home' })
147
+ .and(this.page.locator(':not([href$="/login"])')))
148
+ .first();
149
+ await (0, test_1.expect)(homeLink).toBeVisible({ timeout: 30000 });
150
+ await homeLink.click({ timeout: 30000 });
151
+ return;
152
+ }
71
153
  const maxRetries = 5;
72
154
  // If the click is already done and we are sitting on Modeler, return
73
155
  // immediately — observed in failing runs where the banner role lookup
@@ -148,6 +230,10 @@ class AppsPage {
148
230
  }
149
231
  }
150
232
  async clickTasklist(clusterName) {
233
+ if (new URL(this.page.url()).hostname.startsWith('hub.')) {
234
+ await this.clickHubClusterApp(clusterName, 'Tasklist');
235
+ return;
236
+ }
151
237
  const maxRetries = 5;
152
238
  for (let retries = 0; retries < maxRetries; retries++) {
153
239
  try {
@@ -185,6 +271,14 @@ class AppsPage {
185
271
  throw new Error(`Failed to click the tasklist link after ${maxRetries} attempts.`);
186
272
  }
187
273
  async clickIdentity(clusterName) {
274
+ if (new URL(this.page.url()).hostname.startsWith('hub.')) {
275
+ // Non-anchored, matching the substring lookup clickHubClusterApp uses
276
+ // for Operate/Tasklist/Optimize -- an anchored pattern fails the same
277
+ // way an exact-string match did for those apps if the rendered link's
278
+ // accessible name carries any extra content (icon/badge).
279
+ await this.clickHubClusterApp(clusterName, /Admin|Identity/);
280
+ return;
281
+ }
188
282
  const maxRetries = 5;
189
283
  for (let retries = 0; retries < maxRetries; retries++) {
190
284
  try {
@@ -251,6 +345,10 @@ class AppsPage {
251
345
  await this.clickCluster(this.optimizeListItem, clusterName);
252
346
  }
253
347
  async clickOperate(clusterName) {
348
+ if (new URL(this.page.url()).hostname.startsWith('hub.')) {
349
+ await this.clickHubClusterApp(clusterName, 'Operate');
350
+ return;
351
+ }
254
352
  const maxRetries = 3;
255
353
  for (let retries = 0; retries < maxRetries; retries++) {
256
354
  try {
@@ -287,6 +385,10 @@ class AppsPage {
287
385
  throw new Error(`Failed to click the operate link after ${maxRetries} attempts.`);
288
386
  }
289
387
  async clickOptimize(clusterName) {
388
+ if (new URL(this.page.url()).hostname.startsWith('hub.')) {
389
+ await this.clickHubClusterApp(clusterName, 'Optimize');
390
+ return;
391
+ }
290
392
  const maxRetries = 3;
291
393
  for (let retries = 0; retries < maxRetries; retries++) {
292
394
  try {
@@ -373,6 +475,13 @@ class AppsPage {
373
475
  }
374
476
  }
375
477
  async clickCamundaApps(maxRetries = 3) {
478
+ // Camunda Hub's rewritten shells have no app-switcher button to open --
479
+ // Modeler is already Hub's home area and the other apps are reached
480
+ // per-cluster via the persistent sidebar, not through a global switcher
481
+ // panel. Mirrors the same hostname guard in clickModeler() above.
482
+ if (new URL(this.page.url()).hostname.startsWith('hub.')) {
483
+ return;
484
+ }
376
485
  await (0, sleep_1.sleep)(2000);
377
486
  const appButtons = [
378
487
  this.camundaComponentsButton,
@@ -420,6 +529,19 @@ class AppsPage {
420
529
  .catch(() => { });
421
530
  }
422
531
  async clickConsoleLink() {
532
+ // Camunda Hub replaced Console on SaaS: there is no "Console" entry in a
533
+ // switcher, and the org-scoped Console area is the sidebar's
534
+ // Clusters/Environments section. Going there is what callers assert on
535
+ // (HomePage.consoleBanner resolves to Hub's authenticated header).
536
+ if (new URL(this.page.url()).hostname.startsWith('hub.')) {
537
+ const consoleArea = this.hubSidebar
538
+ .locator('a[href$="/clusters"]')
539
+ .or(this.hubSidebar.locator('a[href$="/environments"]'))
540
+ .first();
541
+ await (0, test_1.expect)(consoleArea).toBeVisible({ timeout: 30000 });
542
+ await consoleArea.click({ timeout: 30000 });
543
+ return;
544
+ }
423
545
  const maxRetries = 3;
424
546
  for (let retries = 0; retries < maxRetries; retries++) {
425
547
  try {
@@ -40,6 +40,7 @@ declare class ClusterDetailsPage {
40
40
  readonly orchestrationClusterCheckbox: Locator;
41
41
  readonly clientCredentialsLink: (clientCredentials: string) => Locator;
42
42
  readonly clientRow: (name: string) => Locator;
43
+ readonly clientRowByDom: (name: string) => Locator;
43
44
  readonly clientRowDeleteButton: (name: string) => Locator;
44
45
  readonly reviewUpdateButton: Locator;
45
46
  readonly selectTargetVersionDialog: Locator;
@@ -46,6 +46,7 @@ class ClusterDetailsPage {
46
46
  orchestrationClusterCheckbox;
47
47
  clientCredentialsLink;
48
48
  clientRow;
49
+ clientRowByDom;
49
50
  clientRowDeleteButton;
50
51
  reviewUpdateButton;
51
52
  selectTargetVersionDialog;
@@ -78,9 +79,6 @@ class ClusterDetailsPage {
78
79
  .locator('label')
79
80
  .filter({ hasText: /^Administration API - Resource: Secrets$/ });
80
81
  this.createButton = page.getByRole('button', { name: 'Create', exact: true });
81
- this.closeModalButton = page
82
- .getByRole('dialog', { name: 'Client credentials', exact: true })
83
- .getByLabel('Close');
84
82
  this.createClientButton = page.getByRole('button', {
85
83
  name: 'Create new Client',
86
84
  });
@@ -98,9 +96,18 @@ class ClusterDetailsPage {
98
96
  this.createClientCredentialsDialog = page.getByRole('dialog', {
99
97
  name: 'Create new client credentials',
100
98
  });
101
- this.clientCredentialsDialog = page.getByRole('dialog', {
102
- name: 'Client credentials',
103
- exact: true,
99
+ // Use a content-based locator so the test stays robust if the dialog
100
+ // title is ever renamed in the Console UI.
101
+ this.clientCredentialsDialog = page.getByRole('dialog').filter({
102
+ has: page.getByText('The Client Secret will not be shown again.'),
103
+ });
104
+ // Hub's client-credentials dialog (design-system Dialog, not the old
105
+ // Carbon one) exposes its close affix as an accessible button named
106
+ // "Close" rather than via an aria-label picked up by getByLabel -- see
107
+ // camunda-hub's own e2e suite, ConsoleClusterDetailsPage.ts
108
+ // (clientCredentialsDialogCloseButton), which already uses this pattern.
109
+ this.closeModalButton = this.clientCredentialsDialog.getByRole('button', {
110
+ name: 'Close',
104
111
  });
105
112
  this.clustersLink = page
106
113
  .getByRole('banner')
@@ -154,10 +161,22 @@ class ClusterDetailsPage {
154
161
  this.orchestrationClusterCheckbox = page
155
162
  .locator('label')
156
163
  .filter({ hasText: /^Orchestration Cluster API$/ });
164
+ // Camunda Hub's data table renders the client name as a link-styled
165
+ // <button data-slot="button"> rather than an <a>. The cell also holds a
166
+ // pointer-events-none row-overlay button with the same accessible name,
167
+ // so match the visible one by its data-slot.
157
168
  this.clientCredentialsLink = (clientCredentials) => page
158
169
  .getByRole('cell', { name: clientCredentials })
159
- .getByText(clientCredentials);
170
+ .locator('a, button[data-slot="button"]')
171
+ .first();
160
172
  this.clientRow = (name) => this.clientsList.filter({ hasText: name });
173
+ // Same row, matched through the DOM instead of the accessibility tree.
174
+ // Hub's client-credentials dialog is a Radix dialog: while it is open the
175
+ // whole app shell behind it carries aria-hidden/inert, so `getByRole`
176
+ // resolves nothing there and the post-create scope check below can never
177
+ // pass. `locator('tr')` is unaffected. The client name is unique per run,
178
+ // so matching page-wide cannot pick up another table's row.
179
+ this.clientRowByDom = (name) => page.locator('tr').filter({ hasText: name });
161
180
  this.clientRowDeleteButton = (name) => this.clientRow(name).getByRole('button', { name: 'Delete' });
162
181
  this.reviewUpdateButton = page.getByRole('button', { name: 'Review Update' });
163
182
  this.selectTargetVersionDialog = page
@@ -609,9 +628,9 @@ class ClusterDetailsPage {
609
628
  }
610
629
  await (0, test_1.expect)(this.clientCredentialsDialog.getByText('The Client Secret will not be shown again.')).toBeVisible({ timeout: 30000 });
611
630
  // The clients table behind the dialog re-fetches after the create, and
612
- // renders a skeleton (no `row` roles at all) until that GET resolves — so
613
- // the default 5s expect timeout can expire before the new row exists.
614
- await (0, test_1.expect)(this.clientsList.filter({ hasText: name })).toContainText(/(?=.*Orchestration)(?=.*Optimize)(?=.*Secrets)/, { timeout: 60000 });
631
+ // renders a skeleton (no rows at all) until that GET resolves — so the
632
+ // default 5s expect timeout can expire before the new row exists.
633
+ await (0, test_1.expect)(this.clientRowByDom(name)).toContainText(/(?=.*Orchestration)(?=.*Optimize)(?=.*Secrets)/, { timeout: 60000 });
615
634
  }
616
635
  async clickEnvVarsButton() {
617
636
  await (0, test_1.expect)(this.envVarsTab).toBeVisible({ timeout: 40000 });