@camunda/e2e-test-suite 0.0.1247 → 0.0.1249

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.
@@ -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
  clickTasklist(clusterName: string): Promise<void>;
25
27
  clickAdmin(clusterName: string): Promise<void>;
@@ -23,9 +23,15 @@ 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 });
30
+ // Camunda Hub's persistent "Main navigation" sidebar lists each cluster
31
+ // with its own apps (Operate/Tasklist/Optimize/Identity) as child links
32
+ // -- there is no per-app switcher panel to open on Hub (see
33
+ // clickCamundaApps below). Mirrors pages/8.7/AppsPage.ts.
34
+ this.hubSidebar = page.getByRole('navigation', { name: 'Main navigation' });
29
35
  this.appSwitcherButton = page.getByLabel('App Switcher');
30
36
  this.tasklistLink = page.getByRole('link', { name: 'Tasklist', exact: true });
31
37
  this.adminLink = page.getByRole('link', { name: /^(Admin|Identity)$/ });
@@ -75,7 +81,78 @@ class AppsPage {
75
81
  await (0, test_1.expect)(cluster).toBeVisible({ timeout: 30000 });
76
82
  await cluster.click();
77
83
  }
84
+ // Camunda Hub's sidebar groups by cluster, not by app: each cluster row
85
+ // expands to reveal its own per-app child links (Operate/Tasklist/...).
86
+ // camunda-hub's own nav-shell-ds.spec.saas.tsx documents the shape: "the
87
+ // design system splits the group row into a label control and a chevron
88
+ // control, both labelled by the cluster name; either one opens it" -- and
89
+ // the child links do not exist in the DOM at all until expanded (not just
90
+ // hidden), and are not nested under any container matchable by cluster
91
+ // name once expanded. So: find the toggle by the cluster's own name
92
+ // (`.first()` because both controls share that name), click it, then
93
+ // look up the app link sidebar-wide rather than scoped to a "group"
94
+ // container that doesn't reliably exist.
95
+ async clickHubClusterApp(clusterName, appName) {
96
+ // Not exact: camunda-hub's own nav-shell-ds.spec.saas.tsx asserts this same
97
+ // link with a substring/regex match (`{name: /Operate/}`), not an exact
98
+ // string -- and CI traces confirm why: even after the toggle click
99
+ // succeeds (no error thrown), an exact-match lookup here still times out
100
+ // with "element(s) not found", meaning the rendered link's accessible
101
+ // name is not byte-for-byte "Operate"/"Tasklist"/"Optimize" (likely
102
+ // additional content from the design-system sidebar item, e.g. an icon
103
+ // or badge). The app names are distinct enough that a substring match
104
+ // carries no ambiguity risk.
105
+ const appLink = this.hubSidebar.getByRole('link', {
106
+ name: appName,
107
+ });
108
+ // Decide whether to expand from clusterName's OWN toggle state, not from
109
+ // whether "some" appLink is already visible sidebar-wide: in a
110
+ // multi-cluster org another cluster's group can already be expanded with
111
+ // the same app name, which made this check pass while clusterName's own
112
+ // group stayed collapsed -- appLink.click() below then fired on the
113
+ // wrong cluster's link (see PR #3627 review).
114
+ const collapsedToggle = this.hubSidebar
115
+ .getByRole('button', { name: clusterName, expanded: false })
116
+ .first();
117
+ if (await collapsedToggle.isVisible({ timeout: 5000 }).catch(() => false)) {
118
+ // This is the only expansion action in the Hub path -- swallowing its
119
+ // failure made a broken expansion look successful and left the caller
120
+ // to burn the full 30s below waiting on an app link that could never
121
+ // appear, hiding the actionable error (e.g. a toggle that doesn't
122
+ // exist or isn't clickable) behind an unrelated timeout.
123
+ await collapsedToggle.click({ timeout: 10000 });
124
+ }
125
+ await (0, test_1.expect)(appLink).toBeVisible({ timeout: 30000 });
126
+ await appLink.click({ timeout: 10000 });
127
+ }
78
128
  async clickModeler() {
129
+ // Camunda Hub (hub.*) replaced Console + Web Modeler on SaaS and has no
130
+ // app switcher. Its home is the Modeler/Workspaces home, but a hostname
131
+ // check alone can't tell whether we're already there -- Hub's cluster
132
+ // pages are on the same hub.* hostname, and a no-op here would leave a
133
+ // caller that reaches clickModeler() from a cluster/API-client page
134
+ // stranded with the next locator running against the wrong page.
135
+ // Navigate home through the header logo link instead, same as
136
+ // ModelerHomePage.clickHomeBreadcrumb -- clicking it when already on the
137
+ // Modeler home is a harmless in-SPA no-op.
138
+ if (new URL(this.page.url()).hostname.startsWith('hub.')) {
139
+ const header = this.page.locator('header[data-slot="app-header"]');
140
+ // The href stays literally "/login" until the client's auth state
141
+ // catches up just after login (camunda-hub NavShellDs.common.tsx) --
142
+ // clicking it in that window would log the test out instead of
143
+ // navigating home, so exclude that href and wait for the authenticated
144
+ // one rather than racing it.
145
+ const homeLink = header
146
+ .getByRole('link', { name: 'Camunda Modeler home' })
147
+ .and(this.page.locator(':not([href$="/login"])'))
148
+ .or(header
149
+ .getByRole('link', { name: 'Camunda Hub home' })
150
+ .and(this.page.locator(':not([href$="/login"])')))
151
+ .first();
152
+ await (0, test_1.expect)(homeLink).toBeVisible({ timeout: 30000 });
153
+ await homeLink.click({ timeout: 30000 });
154
+ return;
155
+ }
79
156
  const maxRetries = 3;
80
157
  for (let retries = 0; retries < maxRetries; retries++) {
81
158
  try {
@@ -106,6 +183,10 @@ class AppsPage {
106
183
  throw new Error(`Failed to click the modeler link after ${maxRetries} attempts.`);
107
184
  }
108
185
  async clickTasklist(clusterName) {
186
+ if (new URL(this.page.url()).hostname.startsWith('hub.')) {
187
+ await this.clickHubClusterApp(clusterName, 'Tasklist');
188
+ return;
189
+ }
109
190
  const maxRetries = 3;
110
191
  for (let retries = 0; retries < maxRetries; retries++) {
111
192
  try {
@@ -144,6 +225,14 @@ class AppsPage {
144
225
  throw new Error(`Failed to click the tasklist link after ${maxRetries} attempts.`);
145
226
  }
146
227
  async clickAdmin(clusterName) {
228
+ if (new URL(this.page.url()).hostname.startsWith('hub.')) {
229
+ // Non-anchored, matching the substring lookup clickHubClusterApp uses
230
+ // for Operate/Tasklist/Optimize -- an anchored pattern fails the same
231
+ // way an exact-string match did for those apps if the rendered link's
232
+ // accessible name carries any extra content (icon/badge).
233
+ await this.clickHubClusterApp(clusterName, /Admin|Identity/);
234
+ return;
235
+ }
147
236
  const maxRetries = 5;
148
237
  for (let retries = 0; retries < maxRetries; retries++) {
149
238
  try {
@@ -218,6 +307,10 @@ class AppsPage {
218
307
  await this.clickCluster(this.optimizeListItem, clusterName);
219
308
  }
220
309
  async clickOperate(clusterName) {
310
+ if (new URL(this.page.url()).hostname.startsWith('hub.')) {
311
+ await this.clickHubClusterApp(clusterName, 'Operate');
312
+ return;
313
+ }
221
314
  const maxRetries = 3;
222
315
  for (let retries = 0; retries < maxRetries; retries++) {
223
316
  try {
@@ -252,6 +345,10 @@ class AppsPage {
252
345
  throw new Error(`Failed to click the operate link after ${maxRetries} attempts.`);
253
346
  }
254
347
  async clickOptimize(clusterName) {
348
+ if (new URL(this.page.url()).hostname.startsWith('hub.')) {
349
+ await this.clickHubClusterApp(clusterName, 'Optimize');
350
+ return;
351
+ }
255
352
  const maxRetries = 3;
256
353
  for (let retries = 0; retries < maxRetries; retries++) {
257
354
  try {
@@ -329,6 +426,13 @@ class AppsPage {
329
426
  }
330
427
  }
331
428
  async clickCamundaApps(maxRetries = 3) {
429
+ // Camunda Hub's rewritten shells have no app-switcher button to open --
430
+ // Modeler is already Hub's home area and the other apps are reached
431
+ // per-cluster via the persistent sidebar, not through a global switcher
432
+ // panel. Mirrors the same hostname guard in clickModeler() above.
433
+ if (new URL(this.page.url()).hostname.startsWith('hub.')) {
434
+ return;
435
+ }
332
436
  await (0, sleep_1.sleep)(2000);
333
437
  await this.dismissBlockingOverlays();
334
438
  const appButtons = [
@@ -357,6 +461,16 @@ class AppsPage {
357
461
  }
358
462
  }
359
463
  async clickConsoleLink() {
464
+ // Camunda Hub folded Console into the same persistent shell as
465
+ // Modeler/home: Console's Dashboard/Clusters/Organization links are
466
+ // just another section of the same "Main navigation" sidebar, not a
467
+ // separate app to switch into. Its header banner (homePage.consoleBanner,
468
+ // via hubHeader) is already visible from any Hub page, so there is no
469
+ // navigation action needed here -- mirrors the same hostname guard in
470
+ // clickModeler().
471
+ if (new URL(this.page.url()).hostname.startsWith('hub.')) {
472
+ return;
473
+ }
360
474
  const maxRetries = 3;
361
475
  for (let retries = 0; retries < maxRetries; retries++) {
362
476
  try {
@@ -3,6 +3,7 @@ import { ClientCredentialsDetailsPage } from './ClientCredentialsDetailsPage';
3
3
  import { McpCredentials } from '../../utils/mcpSecrets';
4
4
  declare class ClusterDetailsPage {
5
5
  private page;
6
+ private pendingClientScopeCheck;
6
7
  readonly apiTab: Locator;
7
8
  readonly createFirstClientButton: Locator;
8
9
  readonly clientNameTextbox: Locator;
@@ -54,7 +55,9 @@ declare class ClusterDetailsPage {
54
55
  readonly updateButton: Locator;
55
56
  constructor(page: Page);
56
57
  clickAPITab(): Promise<void>;
58
+ dismissOopsIfPresent(): Promise<void>;
57
59
  clickCreateClientButton(): Promise<void>;
60
+ assertAPIClientScopes(name: string): Promise<void>;
58
61
  clickClientNameTextbox(): Promise<void>;
59
62
  fillClientNameTextbox(name: string): Promise<void>;
60
63
  clickTasklistCheckbox(): Promise<void>;
@@ -8,6 +8,9 @@ const expectLocatorWithRetry_1 = require("../../utils/assertionHelpers/expectLoc
8
8
  const ClientCredentialsDetailsPage_1 = require("./ClientCredentialsDetailsPage");
9
9
  class ClusterDetailsPage {
10
10
  page;
11
+ // Name of a client whose scopes still have to be checked against the
12
+ // clients list — see createAPIClient() / clickCloseModalButton().
13
+ pendingClientScopeCheck = null;
11
14
  apiTab;
12
15
  createFirstClientButton;
13
16
  clientNameTextbox;
@@ -80,9 +83,6 @@ class ClusterDetailsPage {
80
83
  .locator('label')
81
84
  .filter({ hasText: /^Administration API - Resource: Secrets$/ });
82
85
  this.createButton = page.getByRole('button', { name: 'Create', exact: true });
83
- this.closeModalButton = page
84
- .getByRole('dialog', { name: 'Client credentials', exact: true })
85
- .getByLabel('Close');
86
86
  this.createClientButton = page.getByRole('button', {
87
87
  name: 'Create new Client',
88
88
  });
@@ -113,9 +113,18 @@ class ClusterDetailsPage {
113
113
  this.createClientCredentialsDialog = page.getByRole('dialog', {
114
114
  name: 'Create new client credentials',
115
115
  });
116
- this.clientCredentialsDialog = page.getByRole('dialog', {
117
- name: 'Client credentials',
118
- exact: true,
116
+ // Use a content-based locator so the test stays robust if the dialog
117
+ // title is ever renamed in the Console UI.
118
+ this.clientCredentialsDialog = page.getByRole('dialog').filter({
119
+ has: page.getByText('The Client Secret will not be shown again.'),
120
+ });
121
+ // Hub's client-credentials dialog (design-system Dialog, not the old
122
+ // Carbon one) exposes its close affix as an accessible button named
123
+ // "Close" rather than via an aria-label picked up by getByLabel -- see
124
+ // camunda-hub's own e2e suite, ConsoleClusterDetailsPage.ts
125
+ // (clientCredentialsDialogCloseButton), which already uses this pattern.
126
+ this.closeModalButton = this.clientCredentialsDialog.getByRole('button', {
127
+ name: 'Close',
119
128
  });
120
129
  this.clustersLink = page
121
130
  .getByRole('banner')
@@ -202,8 +211,68 @@ class ClusterDetailsPage {
202
211
  await (0, test_1.expect)(this.apiTab).toBeVisible({ timeout: 60000 });
203
212
  await this.apiTab.click({ timeout: 60000 });
204
213
  }
214
+ async dismissOopsIfPresent() {
215
+ // The clients list panel sometimes lands in a Carbon "Oops ... something
216
+ // went wrong." state when a background ClustersService request fails on
217
+ // SaaS INT. The panel replaces the create-client button, so any click
218
+ // attempt will time out until the user clicks the inline Reload button.
219
+ const oopsHeading = this.page.getByRole('heading', { name: /Oops/i });
220
+ if (!(await oopsHeading.isVisible({ timeout: 1000 }).catch(() => false))) {
221
+ return;
222
+ }
223
+ const reloadButton = this.page.getByRole('button', {
224
+ name: 'Reload',
225
+ exact: true,
226
+ });
227
+ if (await reloadButton.isVisible({ timeout: 1000 }).catch(() => false)) {
228
+ await reloadButton.click({ timeout: 15000 });
229
+ }
230
+ else {
231
+ await this.page.reload();
232
+ }
233
+ await this.page.waitForLoadState('domcontentloaded');
234
+ // The swallowed click error above let a failed Reload look like recovery,
235
+ // leaving the caller to burn its own timeout against the same Oops state
236
+ // and report an unrelated downstream failure. Verify the panel actually
237
+ // recovered instead of assuming either recovery path worked.
238
+ await (0, test_1.expect)(oopsHeading).not.toBeVisible({ timeout: 15000 });
239
+ }
205
240
  async clickCreateClientButton() {
206
- await (0, clickLocatorWithRetry_1.clickLocatorWithRetry)(this.page, this.createFirstClientButton.or(this.createNewClientButton));
241
+ await (0, clickLocatorWithRetry_1.clickLocatorWithRetry)(this.page, this.createFirstClientButton.or(this.createNewClientButton), {
242
+ preAction: async () => {
243
+ await this.dismissOopsIfPresent();
244
+ },
245
+ });
246
+ }
247
+ // Asserts the scopes an API client was created with, read from its row in
248
+ // the clients list. Only meaningful once the credentials modal is closed:
249
+ // while it is open the panel behind it can still be showing its pre-create
250
+ // state and lists no row for the new client at all.
251
+ async assertAPIClientScopes(name) {
252
+ const row = this.clientsList.filter({ hasText: name });
253
+ const scopes = /(?=.*Orchestration)(?=.*Optimize)(?=.*Secrets)/;
254
+ const maxAttempts = 3;
255
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
256
+ try {
257
+ await (0, test_1.expect)(row).toContainText(scopes, { timeout: 60000 });
258
+ return;
259
+ }
260
+ catch (error) {
261
+ if (attempt === maxAttempts) {
262
+ throw error;
263
+ }
264
+ // A panel that rendered before the client existed — or that failed
265
+ // into the "Oops ... something went wrong." state — only picks the
266
+ // new client up on a refetch, which is what reloading the API tab
267
+ // forces. A single reload isn't always enough on a nightly cluster
268
+ // this suite reuses across parallel test workers, so retry more
269
+ // than once before failing the test.
270
+ console.warn(`assertAPIClientScopes: "${name}" is not listed yet (attempt ${attempt}/${maxAttempts}); reloading the API tab and retrying. ${error}`);
271
+ await this.dismissOopsIfPresent();
272
+ await this.page.reload({ waitUntil: 'domcontentloaded' });
273
+ await this.clickAPITab();
274
+ }
275
+ }
207
276
  }
208
277
  async clickClientNameTextbox() {
209
278
  await this.clientNameTextbox.click({ timeout: 60000 });
@@ -253,6 +322,12 @@ class ClusterDetailsPage {
253
322
  }
254
323
  async clickCloseModalButton() {
255
324
  await this.closeModalButton.click({ timeout: 60000 });
325
+ const pendingClient = this.pendingClientScopeCheck;
326
+ if (pendingClient === null) {
327
+ return;
328
+ }
329
+ this.pendingClientScopeCheck = null;
330
+ await this.assertAPIClientScopes(pendingClient);
256
331
  }
257
332
  async clickSettingsTab() {
258
333
  await this.settingsTab.click({ timeout: 60000 });
@@ -582,24 +657,16 @@ class ClusterDetailsPage {
582
657
  await (0, test_1.expect)(this.clientCredentialsDialog
583
658
  .getByText('The Client Secret will not be shown again.')
584
659
  .first()).toBeVisible();
585
- try {
586
- await (0, test_1.expect)(this.clientsList.filter({ hasText: attemptName })).toContainText(/(?=.*Orchestration)(?=.*Optimize)(?=.*Secrets)/, {
587
- timeout: 10000,
588
- });
589
- }
590
- catch (err) {
591
- // The clients list panel can land in "Oops ... something went wrong."
592
- // while the post-create modal is still open. The modal blocks pointer
593
- // events so we can't click Reload here; the modal's "Client Secret will
594
- // not be shown again" message above already proves the client exists.
595
- const oopsVisible = await this.page
596
- .getByRole('heading', { name: /Oops/i })
597
- .isVisible({ timeout: 1000 })
598
- .catch(() => false);
599
- if (!oopsVisible)
600
- throw err;
601
- console.warn(`createAPIClient: clients list panel is in Oops state; skipping row scope assertion for "${attemptName}".`);
602
- }
660
+ // The client now exists — the modal above is proof — so the scopes it
661
+ // was created with are checked in clickCloseModalButton() rather than
662
+ // here. The clients list behind the open modal is not a reliable
663
+ // source for that check: it can still be rendering its pre-create
664
+ // state (no row for this client) or have failed into "Oops ...
665
+ // something went wrong.", and the modal blocks the pointer events a
666
+ // reload would need. Asserting it here instead sent a created client
667
+ // round the retry loop, which both left orphan clients behind and
668
+ // discarded the credentials the caller has yet to read.
669
+ this.pendingClientScopeCheck = attemptName;
603
670
  return;
604
671
  }
605
672
  catch (error) {
@@ -123,10 +123,30 @@ class ClusterPage {
123
123
  this.clusterVersion = (clusterName, versionText) => this.cluster(clusterName).getByText(versionText);
124
124
  }
125
125
  async clickClusterBanner() {
126
- await this.page
126
+ // Camunda Hub has no `banner` landmark; "Clusters" instead lives in its
127
+ // persistent "Main navigation" sidebar, and once the org has environments
128
+ // enabled that link is further renamed "Environments" (same rollout
129
+ // HomePage.clusterTab handles).
130
+ const clusterLink = this.page
127
131
  .getByRole('banner')
128
132
  .getByRole('link', { name: 'Clusters' })
129
- .click({ timeout: 60000 });
133
+ .or(this.page
134
+ .getByRole('navigation', { name: 'Main navigation' })
135
+ .getByRole('link', { name: 'Clusters', exact: true }))
136
+ .or(this.page
137
+ .getByRole('navigation', { name: 'Main navigation' })
138
+ .getByRole('link', { name: 'Environments', exact: true }));
139
+ await clusterLink.click({ timeout: 60000 });
140
+ // Landing on Hub's Environments overview (see above) renders clusters as
141
+ // cards, not the getByRole('row') table this page's own locators expect
142
+ // -- route through the page's own "Clusters" tab to reach the classic
143
+ // route. Same fix as HomePage.clickClusters().
144
+ const clustersPageTab = this.page
145
+ .getByRole('navigation', { name: 'Environments views' })
146
+ .getByRole('link', { name: 'Clusters', exact: true });
147
+ if (await clustersPageTab.isVisible({ timeout: 5000 }).catch(() => false)) {
148
+ await clustersPageTab.click({ timeout: 15000 });
149
+ }
130
150
  }
131
151
  async deleteCluster(name) {
132
152
  await this.page.waitForTimeout(1000);
@@ -302,12 +322,33 @@ class ClusterPage {
302
322
  });
303
323
  }
304
324
  async clickConnectorSecretsTab(clusterName) {
305
- await (0, clickLocatorWithRetry_1.clickLocatorWithRetry)(this.page, this.connectorSecretsTab, {
306
- postAction: async () => {
307
- await this.clickClusterBanner();
308
- await this.clickClusterLink(clusterName);
309
- },
310
- });
325
+ // clickLocatorWithRetry only reports whether the click itself threw --
326
+ // Radix's tab trigger always accepts the click, so a click that lands
327
+ // right before the panel re-renders (e.g. Hub's cluster-page background
328
+ // reauth reload) can report success while the "Connector Secrets" panel
329
+ // never actually activates. Verify aria-selected rather than trusting
330
+ // the click outcome, and redo the full navigate-and-click on a miss.
331
+ const maxAttempts = 3;
332
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
333
+ await (0, clickLocatorWithRetry_1.clickLocatorWithRetry)(this.page, this.connectorSecretsTab, {
334
+ postAction: async () => {
335
+ await this.clickClusterBanner();
336
+ await this.clickClusterLink(clusterName);
337
+ },
338
+ });
339
+ const isSelected = (await this.connectorSecretsTab
340
+ .getAttribute('aria-selected')
341
+ .catch(() => null)) === 'true';
342
+ if (isSelected) {
343
+ return;
344
+ }
345
+ if (attempt === maxAttempts) {
346
+ throw new Error('clickConnectorSecretsTab: "Connector Secrets" tab click did not activate the tab after retries');
347
+ }
348
+ console.warn(`clickConnectorSecretsTab: click succeeded but the tab did not become active (attempt ${attempt}/${maxAttempts}); retrying`);
349
+ await this.clickClusterBanner();
350
+ await this.clickClusterLink(clusterName);
351
+ }
311
352
  }
312
353
  async clickLatestStableForMinorVersion() {
313
354
  const minorVersion = process.env.MINOR_VERSION;
@@ -55,10 +55,28 @@ class ClusterSecretsPage {
55
55
  this.dialogCancelButton = this.page
56
56
  .getByRole('dialog')
57
57
  .getByRole('button', { name: 'Cancel', exact: true });
58
- this.secretsSearchBox = page.getByRole('searchbox', { name: 'Filter table' });
58
+ // "Filter table" was Carbon's default TableToolbarSearch label. A live
59
+ // trace off this branch shows the panel now rendering a plain "Search"
60
+ // input instead (Camunda Hub's rebranded UI, not Carbon), so the old
61
+ // exact-name match never resolves and every lookup falls through to
62
+ // creating a secret that may already exist. Match either.
63
+ this.secretsSearchBox = page
64
+ .getByRole('searchbox', { name: 'Filter table' })
65
+ .or(page.getByRole('searchbox', { name: 'Search' }))
66
+ .or(page.getByPlaceholder('Search'));
67
+ // Camunda Hub has no `banner` landmark; "Clusters" instead lives in its
68
+ // persistent "Main navigation" sidebar, and once the org has environments
69
+ // enabled that link is further renamed "Environments" (same rollout
70
+ // HomePage.clusterTab handles).
59
71
  this.clusterBanner = this.page
60
72
  .getByRole('banner')
61
- .getByRole('link', { name: 'Clusters' });
73
+ .getByRole('link', { name: 'Clusters' })
74
+ .or(this.page
75
+ .getByRole('navigation', { name: 'Main navigation' })
76
+ .getByRole('link', { name: 'Clusters', exact: true }))
77
+ .or(this.page
78
+ .getByRole('navigation', { name: 'Main navigation' })
79
+ .getByRole('link', { name: 'Environments', exact: true }));
62
80
  this.clusterLink = (name) => page.getByRole('link', { name: name });
63
81
  this.clusterSecretTab = this.page.getByRole('tab', {
64
82
  name: 'Connector Secrets',
@@ -115,6 +133,17 @@ class ClusterSecretsPage {
115
133
  async clickClusterBanner() {
116
134
  await (0, test_1.expect)(this.clusterBanner).toBeVisible({ timeout: 30000 });
117
135
  await this.clusterBanner.click({ timeout: 60000 });
136
+ // Landing on Hub's Environments overview (see clusterBanner above) renders
137
+ // clusters as cards, not the getByRole('link') the clusterLink() locator
138
+ // below expects to find directly -- route through the page's own
139
+ // "Clusters" tab to reach the classic route. Same fix as
140
+ // HomePage.clickClusters().
141
+ const clustersPageTab = this.page
142
+ .getByRole('navigation', { name: 'Environments views' })
143
+ .getByRole('link', { name: 'Clusters', exact: true });
144
+ if (await clustersPageTab.isVisible({ timeout: 5000 }).catch(() => false)) {
145
+ await clustersPageTab.click({ timeout: 15000 });
146
+ }
118
147
  }
119
148
  async clickCluster(name) {
120
149
  await (0, test_1.expect)(this.clusterLink(name)).toBeVisible({ timeout: 30000 });
@@ -169,6 +198,22 @@ class ClusterSecretsPage {
169
198
  await (0, test_1.expect)(this.page.getByText('Creating...')).not.toBeVisible({
170
199
  timeout: 60000,
171
200
  });
201
+ // A live trace off this exact branch (test-setup.spec.ts, PR#3627) shows
202
+ // the panel taking the reload path for every one of 7 secrets created in
203
+ // a row, not "near-universally" -- so the 15s pre-reload probe this used
204
+ // to run first never once paid off and only burned 15s x N secrets.
205
+ // Reload unconditionally, matching the pattern createSetOfSecrets's own
206
+ // bulkImportSecrets() already uses after an import on this identical
207
+ // panel.
208
+ await this.page.reload({ waitUntil: 'domcontentloaded' });
209
+ // The reload lands back on the cluster's default Overview tab, not
210
+ // Connector Secrets -- a CI failure screenshot confirmed the page stuck
211
+ // there with the secrets panel never reached, so createNewSecretButton
212
+ // was never going to reappear regardless of whether the reload fixed the
213
+ // underlying render issue. Re-navigate to the tab before waiting for it.
214
+ await this.clickClusterBanner();
215
+ await this.clickCluster(cluster);
216
+ await this.clusterSecretTab.click({ timeout: 30000 });
172
217
  await (0, test_1.expect)(this.createNewSecretButton).toBeVisible({
173
218
  timeout: 60000,
174
219
  });
@@ -12,11 +12,14 @@ declare class HomePage {
12
12
  readonly appSwitcher: Locator;
13
13
  readonly openOrganizationButton: Locator;
14
14
  readonly consoleBanner: Locator;
15
+ readonly hubHeader: Locator;
16
+ readonly hubSidebar: Locator;
15
17
  readonly modelerBanner: Locator;
16
18
  readonly gettingStartedHeading: Locator;
17
19
  readonly buttonSkipCustomization: Locator;
18
20
  constructor(page: Page);
19
21
  clickClusters(): Promise<void>;
22
+ private clickClustersPageTabIfPresent;
20
23
  clickClustersWithReloadRecovery(): Promise<void>;
21
24
  clickDashboard(): Promise<void>;
22
25
  clickOrganization(): Promise<void>;
@@ -16,16 +16,35 @@ class HomePage {
16
16
  appSwitcher;
17
17
  openOrganizationButton;
18
18
  consoleBanner;
19
+ hubHeader;
20
+ hubSidebar;
19
21
  modelerBanner;
20
22
  gettingStartedHeading;
21
23
  buttonSkipCustomization;
22
24
  constructor(page) {
23
25
  this.page = page;
26
+ // Camunda Hub replaces Console on SaaS: an app header (only carrying the
27
+ // user menu once authenticated) plus a sidebar whose Console section
28
+ // links to /org/<id>/clusters and /org/<id>/management.
29
+ this.hubHeader = page
30
+ .locator('header[data-slot="app-header"]')
31
+ .filter({ has: page.locator('[data-slot="user-menu-trigger"]') });
32
+ this.hubSidebar = page.locator('nav[data-slot="app-sidebar"]');
24
33
  this.dashboardTab = page.getByRole('link', { name: 'Dashboard' });
34
+ // Once an org has environmentsEnabled, camunda-hub's
35
+ // use-ds-sidebar-items.tsx replaces the sidebar's Clusters link with one
36
+ // labelled "Environments" whose href is '/environments', not
37
+ // '/clusters' -- accept both hrefs so this resolves either way.
25
38
  this.clusterTab = page
26
39
  .getByRole('banner')
27
- .getByRole('link', { name: 'Clusters' });
28
- this.organizationTab = page.getByRole('link', { name: 'Organization' });
40
+ .getByRole('link', { name: 'Clusters' })
41
+ .or(this.hubSidebar.locator('a[href$="/clusters"]'))
42
+ .or(this.hubSidebar.locator('a[href$="/environments"]'))
43
+ .first();
44
+ this.organizationTab = page
45
+ .getByRole('link', { name: 'Organization' })
46
+ .or(this.hubSidebar.locator('a[href$="/management"]'))
47
+ .first();
29
48
  this.organizationViewTitle = page.getByText('Organization Management');
30
49
  this.welcomeMessage = page.getByText('Welcome to Camunda!').first();
31
50
  this.informationDialog = page.getByRole('button', {
@@ -38,9 +57,14 @@ class HomePage {
38
57
  .or(this.camundaAppsButton)
39
58
  .or(this.appSwitcher);
40
59
  this.openOrganizationButton = page.getByLabel('Open Organizations');
41
- this.consoleBanner = page.getByRole('link', {
42
- name: 'Camunda logo Console',
43
- });
60
+ // "Camunda logo Console" was the old Carbon header's logo link. Camunda
61
+ // Hub has no equivalent link -- its post-login landing is `hubHeader`
62
+ // (see above) -- so callers that assert this banner as a login/Console
63
+ // arrival signal need the Hub fallback too (UtilitiesPage.ts already
64
+ // does this ad hoc at one call site; fold it in here instead).
65
+ this.consoleBanner = page
66
+ .getByRole('link', { name: 'Camunda logo Console' })
67
+ .or(this.hubHeader);
44
68
  this.gettingStartedHeading = page.getByText('start by creating a project');
45
69
  this.buttonSkipCustomization = page.getByRole('button', {
46
70
  name: 'Skip customization',
@@ -52,6 +76,20 @@ class HomePage {
52
76
  async clickClusters() {
53
77
  await (0, test_1.expect)(this.clusterTab).toBeVisible({ timeout: 30000 });
54
78
  await this.clusterTab.click({ timeout: 15000 });
79
+ await this.clickClustersPageTabIfPresent();
80
+ }
81
+ // Landing on Hub's Environments overview (see clusterTab above) doesn't put
82
+ // us on the classic row-based cluster list ClusterPage.ts expects -- that
83
+ // page renders clusters as cards. Its own header renders a separate
84
+ // "Clusters" tab (camunda-hub EnvironmentsPageHeader.tsx, href /clusters)
85
+ // that does.
86
+ async clickClustersPageTabIfPresent() {
87
+ const clustersPageTab = this.page
88
+ .getByRole('navigation', { name: 'Environments views' })
89
+ .getByRole('link', { name: 'Clusters', exact: true });
90
+ if (await clustersPageTab.isVisible({ timeout: 5000 }).catch(() => false)) {
91
+ await clustersPageTab.click({ timeout: 15000 });
92
+ }
55
93
  }
56
94
  // Same navigation as clickClusters(), but recovers from a Console SPA crash
57
95
  // by remounting the app. Toggling authorizations restarts the cluster, and
@@ -70,6 +108,7 @@ class HomePage {
70
108
  await this.page.reload();
71
109
  },
72
110
  });
111
+ await this.clickClustersPageTabIfPresent();
73
112
  }
74
113
  async clickDashboard() {
75
114
  await this.dashboardTab.click({ timeout: 90000 });
@@ -26,6 +26,7 @@ declare class LoginPage {
26
26
  clickContinueButton(): Promise<void>;
27
27
  fillPassword(password: string): Promise<void>;
28
28
  clickLoginButton(): Promise<void>;
29
+ private throwIfCredentialsRejected;
29
30
  login(credentials?: {
30
31
  username?: string;
31
32
  password?: string;