@camunda/e2e-test-suite 0.0.1276 → 0.0.1277

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.
@@ -21,8 +21,13 @@ declare class AppsPage {
21
21
  readonly hubSidebar: Locator;
22
22
  constructor(page: Page);
23
23
  clickCluster(component: Locator, name: string): Promise<void>;
24
+ private expandHubClusterGroup;
25
+ private expandHubSidebar;
26
+ private showAllWorkspaces;
24
27
  private clickHubClusterApp;
28
+ private isOnHub;
25
29
  clickModeler(): Promise<void>;
30
+ private clickHubModelerHome;
26
31
  /**
27
32
  * Waits for the Modeler app to actually render, and recovers the one landing
28
33
  * state a hostname check cannot see: Modeler's OAuth `/login-callback`
@@ -4,6 +4,9 @@ exports.AppsPage = void 0;
4
4
  const test_1 = require("@playwright/test");
5
5
  const sleep_1 = require("../../utils/sleep");
6
6
  const UtilitiesPage_1 = require("../8.8/UtilitiesPage");
7
+ function escapeRegExp(value) {
8
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
9
+ }
7
10
  class AppsPage {
8
11
  page;
9
12
  modelerLink;
@@ -85,6 +88,187 @@ class AppsPage {
85
88
  // up the app link sidebar-wide rather than scoped to a "group" container
86
89
  // that doesn't reliably exist. Ported from pages/8.9/AppsPage.ts, where
87
90
  // this shape is already nightly-proven.
91
+ async expandHubClusterGroup(clusterName) {
92
+ const toggle = this.hubSidebar
93
+ .getByRole('button', { name: clusterName })
94
+ .first();
95
+ // The diagram editor (and other full-screen views reached from it, e.g.
96
+ // right after runProcessInstance/deployDiagram) has no "Main navigation"
97
+ // sidebar at all -- confirmed from a nightly trace's DOM snapshot: the
98
+ // page's only <nav> is aria-label="Context navigation" (the breadcrumb),
99
+ // and neither "Operate"/"Tasklist"/"Optimize" nor any app-switcher text
100
+ // appears anywhere on it. clickCamundaApps() is a no-op on Hub (there is
101
+ // no switcher panel to open), so a caller reaching clickOperate/
102
+ // clickTasklist/clickOptimize straight from a diagram was left on a page
103
+ // this sidebar could never resolve on, no matter how the toggle itself
104
+ // is matched. Navigate home first, the same way clickModeler() does,
105
+ // whenever the sidebar isn't already there.
106
+ //
107
+ // A freshly re-authenticated page can also land on a workspace-agnostic
108
+ // route (e.g. bare /workspaces right after loginWithTestUser(), which
109
+ // clickCamundaApps() does nothing to fix -- it is a no-op on Hub) whose
110
+ // sidebar renders but has not resolved this cluster's own group yet.
111
+ // Confirmed from a nightly trace: both the getAttribute and click below
112
+ // timed out (10s each) against this exact toggle while the URL sat on
113
+ // /workspaces, right after a mid-test re-login. Route through the same
114
+ // clickModeler() recovery whenever the toggle itself hasn't shown up
115
+ // either, not only when the whole sidebar is missing.
116
+ // Before deciding whether the group is reachable: a collapsed rail hides
117
+ // every cluster trigger, so the probe below would report "not ready" and
118
+ // the recovery would then wait out its own timeout on the same hidden
119
+ // element.
120
+ await this.expandHubSidebar();
121
+ const sidebarReady = (await this.hubSidebar.isVisible({ timeout: 5000 }).catch(() => false)) &&
122
+ (await toggle
123
+ .waitFor({ state: 'visible', timeout: 10000 })
124
+ .then(() => true)
125
+ .catch(() => false));
126
+ if (!sidebarReady) {
127
+ await this.clickModeler();
128
+ await (0, test_1.expect)(this.hubSidebar).toBeVisible({ timeout: 30000 });
129
+ // Since camunda-hub #28956 the Workspaces overview defaults to "My
130
+ // workspaces" -- only the ones the current user is an EXPLICIT member
131
+ // of -- and resets to it on every load. The shared workspace is created
132
+ // by whichever setup user won the race, so a role-flow user lands on
133
+ // "No workspaces match this filter / You don't have explicit membership
134
+ // in any workspace in this organization" (confirmed live) and the
135
+ // sidebar never renders this cluster's group. Widening the scope is
136
+ // what makes it appear; without it the wait below can only time out.
137
+ // ModelerHomePage.showAllWorkspaces() covers the project-folder paths,
138
+ // but nothing covered the app-switcher path until here.
139
+ await this.showAllWorkspaces(5000);
140
+ await this.expandHubSidebar();
141
+ await (0, test_1.expect)(toggle).toBeVisible({ timeout: 30000 });
142
+ }
143
+ // camunda-hub's own nav-shell-ds.spec.saas.tsx documents the actual
144
+ // shape: "the design system splits the group row into a label control
145
+ // and a chevron control, both labelled by the cluster name; either one
146
+ // opens it" -- there is no separate "Toggle <name>" control, and the
147
+ // child links do not exist in the DOM at all until expanded (not merely
148
+ // hidden), nor are they nested under any container matchable by cluster
149
+ // name once expanded. So: find the toggle by the cluster's own name
150
+ // (`.first()` because both controls share that name), click it, then
151
+ // look up the app link sidebar-wide rather than scoped to a "group"
152
+ // container that doesn't reliably exist.
153
+ // Check THIS cluster's own toggle state, not "is any app link with this
154
+ // name visible anywhere in the sidebar" -- with more than one cluster in
155
+ // the org, a DIFFERENT cluster's group can already be expanded (left over
156
+ // from an earlier action on the same page), and that cluster's own
157
+ // Operate/Tasklist/Optimize link would satisfy an unscoped visibility
158
+ // check, skipping expansion of the requested cluster entirely and then
159
+ // clicking the wrong cluster's link. The child links are not nested under
160
+ // any container matchable by cluster name once expanded (confirmed
161
+ // against camunda-hub's own nav-shell-ds.spec.saas.tsx), so the toggle's
162
+ // own aria-expanded is the only reliable per-cluster signal available.
163
+ // Not exact: the same suite asserts this row with a substring/regex
164
+ // match (`{name: /Production/}`), not an exact string -- and nightly
165
+ // traces confirm why: the rendered toggle's accessible name is not
166
+ // byte-for-byte the bare cluster name (it carries additional content,
167
+ // e.g. a trailing tag summary), so an exact-match lookup here finds
168
+ // nothing and the click below times out waiting on a locator that can
169
+ // never resolve.
170
+ const alreadyExpanded = (await toggle.getAttribute('aria-expanded').catch(() => null)) === 'true';
171
+ if (!alreadyExpanded) {
172
+ // This is the only expansion action in the Hub path -- swallowing its
173
+ // failure made a broken expansion look successful and left the caller
174
+ // to burn the full 30s below waiting on an app link that could never
175
+ // appear, hiding the actionable error (e.g. a toggle that doesn't
176
+ // exist or isn't clickable) behind an unrelated timeout.
177
+ await toggle.click({ timeout: 10000 });
178
+ }
179
+ // An app link resolved after this is sidebar-wide (see callers below), so
180
+ // if an EARLIER call in the same test left a different cluster's group
181
+ // expanded too, two "Operate" (say) links now exist and that becomes a
182
+ // strict-mode violation. Collapse every other expanded group before
183
+ // resolving it -- there is no container to scope the link to instead,
184
+ // since the child links aren't nested under anything matchable by
185
+ // cluster name once expanded.
186
+ // hasNotText filters DOM text content, not the accessible name -- the
187
+ // expanded chevron can be labelled via aria-label with no text content
188
+ // of its own, in which case hasNotText would fail to exclude the toggle
189
+ // we just expanded and this loop would immediately collapse it again.
190
+ // Match by accessible name (same algorithm getByRole's own name option
191
+ // uses) instead, excluding any name CONTAINING clusterName -- not just
192
+ // one exactly equal to it, since (per the toggle locator above) the
193
+ // current cluster's own toggle name is not exactly clusterName either,
194
+ // and an exact-equality exclusion would wrongly treat it as "other" and
195
+ // immediately re-collapse the group this method just expanded.
196
+ const otherExpandedToggles = this.hubSidebar.getByRole('button', {
197
+ expanded: true,
198
+ name: new RegExp(`^(?!.*${escapeRegExp(clusterName)}).+$`),
199
+ });
200
+ for (let i = 0; i < 5 && (await otherExpandedToggles.count()) > 0; i++) {
201
+ // Let a failed collapse throw rather than swallowing it: the app link
202
+ // is intentionally sidebar-wide, so a group left expanded here produces
203
+ // a strict-mode violation (or the wrong cluster's link) below anyway --
204
+ // failing at the actual cause is more useful than at that symptom.
205
+ await otherExpandedToggles.first().click({ timeout: 10000 });
206
+ }
207
+ }
208
+ // Widens Hub's Workspaces scope filter to "All workspaces". Duplicated from
209
+ // ModelerHomePage.showAllWorkspaces() rather than imported: ModelerHomePage
210
+ // constructs page objects of its own, and pulling it in here would make the
211
+ // Apps -> Modeler navigation path circular. The control is absent outside
212
+ // Hub and for non-elevated users, where a membership-scoped list is already
213
+ // correct, so a missing control is a no-op.
214
+ // Opens Hub's collapsed left rail. While collapsed it shows only the
215
+ // top-level nav icons: the per-cluster group triggers are in the DOM
216
+ // (data-slot="app-sidebar-group-trigger", aria-label="<cluster>") but never
217
+ // visible, so a toBeVisible() wait on one can only time out. A failing run's
218
+ // trace carries "Expand sidebar" seven times and "Collapse sidebar" not
219
+ // once, i.e. the rail stayed shut for its whole duration. A no-op when the
220
+ // rail is already open, since the toggle is then labelled "Collapse sidebar".
221
+ async expandHubSidebar() {
222
+ const expandToggle = this.page
223
+ .getByRole('button', { name: 'Expand sidebar' })
224
+ .or(this.page
225
+ .locator('[data-slot="app-sidebar-collapse-toggle"]')
226
+ .and(this.page.getByLabel('Expand sidebar')))
227
+ .first();
228
+ if (!(await expandToggle.isVisible().catch(() => false))) {
229
+ return;
230
+ }
231
+ await expandToggle.click({ timeout: 10000 });
232
+ await (0, test_1.expect)(expandToggle).toBeHidden({ timeout: 10000 });
233
+ }
234
+ async showAllWorkspaces(probeTimeout) {
235
+ // The empty state offers an explicit "Switch to All workspaces" button
236
+ // next to "You don't have explicit membership in any workspace in this
237
+ // organization" -- prefer it. Driving the combobox alone was not enough:
238
+ // when its accessible name does not match, the probe below simply
239
+ // returns and the caller waits out its whole timeout on a page that is
240
+ // still scoped to "My workspaces" (observed live, twice).
241
+ const switchButton = this.page.getByRole('button', {
242
+ name: 'Switch to All workspaces',
243
+ });
244
+ const scopeFilter = this.page.getByRole('combobox', {
245
+ name: 'Filter workspaces',
246
+ });
247
+ const appeared = await switchButton
248
+ .or(scopeFilter)
249
+ .first()
250
+ .waitFor({ state: 'visible', timeout: probeTimeout })
251
+ .then(() => true)
252
+ .catch(() => false);
253
+ if (!appeared) {
254
+ return;
255
+ }
256
+ if (await switchButton.isVisible().catch(() => false)) {
257
+ await switchButton.click({ timeout: 10000 });
258
+ await (0, test_1.expect)(switchButton).toBeHidden({ timeout: 10000 });
259
+ return;
260
+ }
261
+ if ((await scopeFilter.innerText()).includes('All workspaces')) {
262
+ return;
263
+ }
264
+ await scopeFilter.click({ timeout: 10000 });
265
+ await this.page
266
+ .getByRole('option', { name: 'All workspaces' })
267
+ .click({ timeout: 10000 });
268
+ await (0, test_1.expect)(scopeFilter).toContainText('All workspaces', {
269
+ timeout: 10000,
270
+ });
271
+ }
88
272
  async clickHubClusterApp(clusterName, appName) {
89
273
  // When the sidebar is collapsed (the default on a fresh session), the
90
274
  // group toggle opens a Radix popover -- a role="dialog" labelled with the
@@ -104,12 +288,12 @@ class AppsPage {
104
288
  // multi-cluster org another cluster's group can already be expanded with
105
289
  // the same app name, and the click would then fire on the wrong
106
290
  // 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
- }
291
+ // Expansion, sidebar recovery and collapsing every OTHER expanded group
292
+ // all live in the ported helper: with more than one cluster in the org
293
+ // (this suite creates Test Cluster and AWS Cluster), a sidebar-wide app
294
+ // link can otherwise resolve to a different cluster's row, or become a
295
+ // strict-mode violation once two groups are open.
296
+ await this.expandHubClusterGroup(clusterName);
113
297
  await (0, test_1.expect)(appLink).toBeVisible({ timeout: 30000 });
114
298
  // Hub's cluster-app links carry target="_blank", so clicking them opens
115
299
  // the app in a new tab while every caller keeps asserting on this.page.
@@ -122,6 +306,11 @@ class AppsPage {
122
306
  }
123
307
  await appLink.click({ timeout: 10000 });
124
308
  }
309
+ // True once the tab is on a Camunda Hub host, where Modeler home is Hub's
310
+ // own home rather than a modeler.* hostname.
311
+ isOnHub() {
312
+ return new URL(this.page.url()).hostname.startsWith('hub.');
313
+ }
125
314
  async clickModeler() {
126
315
  // Camunda Hub (hub.*) replaced Console + Web Modeler on SaaS and has no
127
316
  // app switcher. Its home is the Modeler/Workspaces home, but a hostname
@@ -132,22 +321,7 @@ class AppsPage {
132
321
  // Navigate home through the header logo link instead, same as
133
322
  // ModelerHomePage.clickHomeBreadcrumb -- clicking it when already on the
134
323
  // 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 });
324
+ if (await this.clickHubModelerHome()) {
151
325
  return;
152
326
  }
153
327
  const maxRetries = 5;
@@ -160,6 +334,17 @@ class AppsPage {
160
334
  }
161
335
  for (let retries = 0; retries < maxRetries; retries++) {
162
336
  try {
337
+ // Re-checked every iteration, not once before the loop. A caller can
338
+ // reach here from a cluster app (Operate/Tasklist/Identity), which is
339
+ // NOT a hub.* host, so the pre-loop check above takes the legacy
340
+ // branch -- but the reload in the catch below lands back on Hub, and
341
+ // the loop's own success signal (a modeler.* hostname) can never come
342
+ // true there. A nightly trace shows exactly that: every retry
343
+ // snapshot sits on hub.ultrawombat.com/workspaces, which IS Modeler
344
+ // home, while the loop kept failing until it ran out of attempts.
345
+ if (await this.clickHubModelerHome()) {
346
+ return;
347
+ }
163
348
  await this.clickCamundaApps();
164
349
  await (0, test_1.expect)(this.modelerLink).toBeVisible({ timeout: 30000 });
165
350
  await this.modelerLink.click({ timeout: 30000 });
@@ -194,6 +379,30 @@ class AppsPage {
194
379
  }
195
380
  throw new Error(`Failed to click the modeler link after ${maxRetries} attempts.`);
196
381
  }
382
+ // Navigates to Hub's Modeler home through the header logo link. Returns
383
+ // false when this tab is not on a Hub host, so the caller falls through to
384
+ // the legacy Console app-switcher flow.
385
+ async clickHubModelerHome() {
386
+ if (!this.isOnHub()) {
387
+ return false;
388
+ }
389
+ const header = this.page.locator('header[data-slot="app-header"]');
390
+ // The href stays literally "/login" until the client's auth state
391
+ // catches up just after login (camunda-hub NavShellDs.common.tsx) --
392
+ // clicking it in that window would log the test out instead of
393
+ // navigating home, so exclude that href and wait for the authenticated
394
+ // one rather than racing it.
395
+ const homeLink = header
396
+ .getByRole('link', { name: 'Camunda Modeler home' })
397
+ .and(this.page.locator(':not([href$="/login"])'))
398
+ .or(header
399
+ .getByRole('link', { name: 'Camunda Hub home' })
400
+ .and(this.page.locator(':not([href$="/login"])')))
401
+ .first();
402
+ await (0, test_1.expect)(homeLink).toBeVisible({ timeout: 30000 });
403
+ await homeLink.click({ timeout: 30000 });
404
+ return true;
405
+ }
197
406
  /**
198
407
  * Waits for the Modeler app to actually render, and recovers the one landing
199
408
  * state a hostname check cannot see: Modeler's OAuth `/login-callback`
@@ -22,8 +22,13 @@ declare class ClusterDetailsPage {
22
22
  readonly clientCredentialsDialog: Locator;
23
23
  readonly clustersLink: Locator;
24
24
  readonly alertsTab: Locator;
25
+ readonly alertsPanel: Locator;
25
26
  readonly activeTabPanel: Locator;
26
27
  readonly alertsList: Locator;
28
+ readonly alertExpandRowButtons: Locator;
29
+ readonly alertRowActionsButtons: Locator;
30
+ readonly deleteAlertMenuItem: Locator;
31
+ readonly deleteAlertConfirmButton: Locator;
27
32
  readonly createFirstAlertButton: Locator;
28
33
  readonly createNewAlertButton: Locator;
29
34
  readonly emailRadioButton: Locator;
@@ -37,6 +42,7 @@ declare class ClusterDetailsPage {
37
42
  readonly expandButton: Locator;
38
43
  readonly rbaEnabledMessage: Locator;
39
44
  readonly authorizationsHeading: Locator;
45
+ readonly authorizationsSwitch: Locator;
40
46
  readonly orchestrationClusterCheckbox: Locator;
41
47
  readonly clientCredentialsLink: (clientCredentials: string) => Locator;
42
48
  readonly clientRow: (name: string) => Locator;
@@ -64,6 +70,7 @@ declare class ClusterDetailsPage {
64
70
  clickCloseModalButton(): Promise<void>;
65
71
  clickSettingsTab(): Promise<void>;
66
72
  private waitForAuthorizationsSection;
73
+ private setAuthorizationsViaSwitch;
67
74
  enableAuthorizations(): Promise<void>;
68
75
  disableAuthorizations(): Promise<void>;
69
76
  searchAndClickClientCredentialsLink(name: string): Promise<ClientCredentialsDetailsPage>;
@@ -76,6 +83,8 @@ declare class ClusterDetailsPage {
76
83
  selectEmailRadioButton(): Promise<void>;
77
84
  clickCreateAlertButton(): Promise<void>;
78
85
  deleteAlerts(): Promise<void>;
86
+ ensureAlertsTabSelected(): Promise<void>;
87
+ expandAlerts(): Promise<void>;
79
88
  private confirmDelete;
80
89
  private dismissDeleteDialog;
81
90
  private doDelete;
@@ -28,8 +28,13 @@ class ClusterDetailsPage {
28
28
  clientCredentialsDialog;
29
29
  clustersLink;
30
30
  alertsTab;
31
+ alertsPanel;
31
32
  activeTabPanel;
32
33
  alertsList;
34
+ alertExpandRowButtons;
35
+ alertRowActionsButtons;
36
+ deleteAlertMenuItem;
37
+ deleteAlertConfirmButton;
33
38
  createFirstAlertButton;
34
39
  createNewAlertButton;
35
40
  emailRadioButton;
@@ -43,6 +48,7 @@ class ClusterDetailsPage {
43
48
  expandButton;
44
49
  rbaEnabledMessage;
45
50
  authorizationsHeading;
51
+ authorizationsSwitch;
46
52
  orchestrationClusterCheckbox;
47
53
  clientCredentialsLink;
48
54
  clientRow;
@@ -123,12 +129,39 @@ class ClusterDetailsPage {
123
129
  this.alertsList = this.activeTabPanel
124
130
  .getByRole('row')
125
131
  .filter({ hasNotText: 'Delivery method' }); //Filter out header row
132
+ // Hub's Alerts tab is a design-system data table: one summary row per
133
+ // alert, its detail fields in a sibling expansion row that only exists
134
+ // once the row's "Expand row" toggle is clicked, and Delete behind the
135
+ // row's "Row actions" kebab -> "Delete" -> "Delete alert" dialog. A
136
+ // nightly trace's DOM carries aria-label="Row actions" and no row-level
137
+ // "Delete" button at all, which is why doDelete() below saw "no items"
138
+ // and silently deleted nothing. Same shape as pages/8.9, scoped to the
139
+ // visible tab panel.
140
+ this.alertExpandRowButtons = this.activeTabPanel.getByRole('button', {
141
+ name: 'Expand row',
142
+ exact: true,
143
+ });
144
+ this.alertRowActionsButtons = this.activeTabPanel.getByRole('button', {
145
+ name: 'Row actions',
146
+ exact: true,
147
+ });
148
+ this.deleteAlertMenuItem = page.getByRole('menuitem', {
149
+ name: 'Delete',
150
+ exact: true,
151
+ });
152
+ this.deleteAlertConfirmButton = page
153
+ .getByRole('dialog', { name: 'Delete alert' })
154
+ .getByRole('button', { name: 'Delete', exact: true });
126
155
  this.createFirstAlertButton = page.getByRole('button', {
127
156
  name: 'Create an alert',
128
157
  });
129
158
  this.createNewAlertButton = page.getByRole('button', {
130
159
  name: 'Create new alert',
131
160
  });
161
+ // The Alerts panel offers "Create an alert" while empty and "Create new
162
+ // alert" once it holds one, so either proves the panel itself mounted.
163
+ // Ported from pages/8.9.
164
+ this.alertsPanel = this.createFirstAlertButton.or(this.createNewAlertButton);
132
165
  this.emailRadioButton = page.getByRole('radio', {
133
166
  name: 'Email',
134
167
  });
@@ -155,6 +188,14 @@ class ClusterDetailsPage {
155
188
  });
156
189
  this.expandButton = page.locator('button[class="cds--table-expand__button"]');
157
190
  this.rbaEnabledMessage = page.getByText('Authorizations enabled');
191
+ // Hub's design-system Settings tab renders this as
192
+ // <button role="switch" data-slot="switch" aria-label="Switch Enable
193
+ // authorizations" aria-checked="true|false">, confirmed from a live
194
+ // trace. The Carbon `.cds--toggle__*` nodes the old code indexed into do
195
+ // not exist there at all.
196
+ this.authorizationsSwitch = this.page.getByRole('switch', {
197
+ name: /Enable authorizations/i,
198
+ });
158
199
  this.authorizationsHeading = this.page.getByRole('heading', {
159
200
  name: 'Authorizations',
160
201
  });
@@ -280,9 +321,34 @@ class ClusterDetailsPage {
280
321
  },
281
322
  });
282
323
  }
324
+ // Drives the design-system Authorizations switch. Returns false when this
325
+ // shell does not render one, so the caller can fall through to the legacy
326
+ // Carbon toggle.
327
+ async setAuthorizationsViaSwitch(enabled) {
328
+ if (!(await this.authorizationsSwitch.isVisible().catch(() => false))) {
329
+ return false;
330
+ }
331
+ const isOn = (await this.authorizationsSwitch.getAttribute('aria-checked')) === 'true';
332
+ if (isOn === enabled) {
333
+ return true;
334
+ }
335
+ await this.authorizationsSwitch.click({ timeout: 30000 });
336
+ if (await this.dialog.isVisible({ timeout: 15000 }).catch(() => false)) {
337
+ const confirm = enabled ? this.rbaEnableButton : this.rbaDisableButton;
338
+ if (await confirm.isVisible().catch(() => false)) {
339
+ await confirm.click({ timeout: 30000 });
340
+ }
341
+ await (0, test_1.expect)(this.page.getByText(enabled ? 'Enabling...' : 'Disabling...')).not.toBeVisible({ timeout: 60000 });
342
+ }
343
+ await (0, test_1.expect)(this.authorizationsSwitch).toHaveAttribute('aria-checked', String(enabled), { timeout: 120000 });
344
+ return true;
345
+ }
283
346
  async enableAuthorizations() {
284
347
  await (0, sleep_1.sleep)(5000);
285
348
  await this.waitForAuthorizationsSection();
349
+ if (await this.setAuthorizationsViaSwitch(true)) {
350
+ return;
351
+ }
286
352
  // Locate all elements with class .cds--toggle__text
287
353
  const toggleTextElements = await this.page.$$('.cds--toggle__text');
288
354
  // Check if toggleTextElements has at least 2 elements
@@ -316,11 +382,22 @@ class ClusterDetailsPage {
316
382
  }
317
383
  }
318
384
  else {
319
- console.log('Toggle text elements not found or less than 2.');
385
+ // Previously a console.log that returned as though the work were done.
386
+ // Under Hub neither `.cds--toggle__text` nor `.cds--toggle__switch`
387
+ // exists, so this branch was taken on every run: authorizations were
388
+ // never enabled, the cluster kept its "Authorizations disabled" badge,
389
+ // and nothing downstream noticed until a permission assertion failed
390
+ // somewhere far away.
391
+ throw new Error('Could not enable authorizations: the Settings tab rendered neither ' +
392
+ 'the design-system "Enable authorizations" switch nor a Carbon ' +
393
+ 'toggle pair.');
320
394
  }
321
395
  }
322
396
  async disableAuthorizations() {
323
397
  await this.waitForAuthorizationsSection();
398
+ if (await this.setAuthorizationsViaSwitch(false)) {
399
+ return;
400
+ }
324
401
  // Locate all elements with class .cds--toggle__text
325
402
  const toggleTextElements = await this.page.$$('.cds--toggle__text');
326
403
  // Check if toggleTextElements has at least 2 elements
@@ -354,7 +431,11 @@ class ClusterDetailsPage {
354
431
  }
355
432
  }
356
433
  else {
357
- console.log('Toggle text elements not found or less than 2.');
434
+ // Same silent no-op as enableAuthorizations had: under Hub no Carbon
435
+ // toggle exists, so this returned as though it had disabled them.
436
+ throw new Error('Could not disable authorizations: the Settings tab rendered neither ' +
437
+ 'the design-system "Enable authorizations" switch nor a Carbon ' +
438
+ 'toggle pair.');
358
439
  }
359
440
  }
360
441
  async searchAndClickClientCredentialsLink(name) {
@@ -448,7 +529,65 @@ class ClusterDetailsPage {
448
529
  });
449
530
  }
450
531
  async deleteAlerts() {
451
- await this.doDelete(this.dialog, 'Alerts');
532
+ const hasRowActions = await this.alertRowActionsButtons
533
+ .first()
534
+ .waitFor({ state: 'visible', timeout: 10000 })
535
+ .then(() => true)
536
+ .catch(() => false);
537
+ if (!hasRowActions) {
538
+ await this.doDelete(this.dialog, 'Alerts');
539
+ return;
540
+ }
541
+ let remaining = await this.alertRowActionsButtons.count();
542
+ while (remaining > 0) {
543
+ // A re-render can detach the menu between opening it and the click, so
544
+ // open-menu-and-choose-Delete is retried as one unit.
545
+ await (0, test_1.expect)(async () => {
546
+ await this.alertRowActionsButtons.first().click({ timeout: 10000 });
547
+ await this.deleteAlertMenuItem.click({ timeout: 10000 });
548
+ await (0, test_1.expect)(this.deleteAlertConfirmButton).toBeVisible({
549
+ timeout: 10000,
550
+ });
551
+ }).toPass({ timeout: 60000 });
552
+ await this.deleteAlertConfirmButton.click({ timeout: 30000 });
553
+ await (0, test_1.expect)(this.alertRowActionsButtons).toHaveCount(remaining - 1, {
554
+ timeout: 60000,
555
+ });
556
+ remaining = await this.alertRowActionsButtons.count();
557
+ }
558
+ }
559
+ // Reveals each alert's detail row, where the design-system table keeps the
560
+ // recipient, creation date and trigger count the callers assert on. A no-op
561
+ // on the legacy table, whose detail rows are always rendered.
562
+ // Re-selects the Alerts tab when it is no longer the showing one. A
563
+ // page.reload() drops the tab selection and the cluster page comes back on
564
+ // Overview, whose Environments table supplies the very first row -- which
565
+ // is how `alertsList.nth(0)` came to read "NameAlert Flow Cluster" instead
566
+ // of "Email" (console-user-flows.spec.ts reloads and then reads the list
567
+ // straight away). `alertsList` is scoped to whichever tabpanel is visible,
568
+ // so restoring the tab is what makes those reads address the alerts table.
569
+ async ensureAlertsTabSelected() {
570
+ const selected = await this.alertsTab
571
+ .getAttribute('aria-selected')
572
+ .catch(() => null);
573
+ if (selected === 'true') {
574
+ return;
575
+ }
576
+ await this.clickAlertsTab();
577
+ }
578
+ async expandAlerts() {
579
+ await this.ensureAlertsTabSelected();
580
+ for (let i = 0; i < 10; i++) {
581
+ const toggle = this.alertExpandRowButtons.first();
582
+ if (!(await toggle.isVisible().catch(() => false))) {
583
+ return;
584
+ }
585
+ const before = await this.alertExpandRowButtons.count();
586
+ await toggle.click({ timeout: 10000 });
587
+ await (0, test_1.expect)(this.alertExpandRowButtons).toHaveCount(before - 1, {
588
+ timeout: 10000,
589
+ });
590
+ }
452
591
  }
453
592
  // The confirmation modal only closes once the DELETE request resolves. When
454
593
  // that call fails the modal stays open on an inline "We're having trouble
@@ -701,6 +840,18 @@ class ClusterDetailsPage {
701
840
  console.warn(`Attempt ${attempt + 1} failed for asserting ${text}. Retrying...`);
702
841
  await this.page.reload();
703
842
  await (0, sleep_1.sleep)(20000);
843
+ // A reload drops the tab selection, so the Alerts panel is no
844
+ // longer the visible one -- `alertsList` is scoped to whichever
845
+ // tabpanel is showing, which after a reload is Overview, whose
846
+ // first non-header row is the cluster-details "Name" row. That is
847
+ // exactly what the nightly reported: `Received string:
848
+ // "NameAlert Flow Cluster"`, nine times over, while the Email alert
849
+ // sat unread on a tab nobody had re-opened. Re-select the tab, wait
850
+ // for the panel to mount, and re-expand the detail rows the
851
+ // assertions read from.
852
+ await this.alertsTab.click({ timeout: 60000 });
853
+ await (0, test_1.expect)(this.alertsPanel).toBeVisible({ timeout: 60000 });
854
+ await this.expandAlerts();
704
855
  }
705
856
  else {
706
857
  throw new Error(`Assertion failed after ${maxRetries} attempts`);
@@ -47,6 +47,7 @@ declare class ClusterPage {
47
47
  private extractClusterIdFromUrl;
48
48
  private logClusterCreationInfo;
49
49
  createCluster(name: string, region?: string, tasklistV1Api?: boolean): Promise<void>;
50
+ clickProviderOption(region: string): Promise<void>;
50
51
  selectSubRegion(region: string): Promise<void>;
51
52
  determineTasklistAPI(tasklistV1Api?: boolean): Promise<void>;
52
53
  clickClusterType(): Promise<void>;