@camunda/e2e-test-suite 0.0.1182 → 0.0.1184

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.
@@ -44,6 +44,7 @@ declare class AppsPage {
44
44
  private doClickClusterInOptimize;
45
45
  clickOperate(clusterName: string): Promise<void>;
46
46
  clickOptimize(clusterName: string): Promise<void>;
47
+ openOptimizeWithRetry(banner: Locator, clusterName: string, timeout?: number, maxRetries?: number, totalTimeout?: number): Promise<void>;
47
48
  clickCamundaApps(maxRetries?: number): Promise<void>;
48
49
  private dismissBlockingModal;
49
50
  clickConsoleLink(): Promise<void>;
@@ -322,6 +322,56 @@ class AppsPage {
322
322
  }
323
323
  throw new Error(`Failed to click the optimize link after ${maxRetries} attempts.`);
324
324
  }
325
+ // `clickOptimize` returns once it has clicked, not once Optimize has
326
+ // rendered, so a hand-off that lands somewhere else (an SSO bounce to
327
+ // `/login`, or `/login?error`) is reported as success. Retrying the click
328
+ // from there does not help: on a dead page none of the app-switcher buttons
329
+ // is visible, so `clickCamundaApps` below burns its three rounds probing and
330
+ // then throws `Failed to open app switcher after 3 attempts` -- and a caller
331
+ // that just re-clicks re-probes the same page until its budget runs out.
332
+ // Going back to `/` first is the only thing that recovers, which is what this
333
+ // wrapper adds: the throw is caught below and turned into a re-entry.
334
+ //
335
+ // Note this is where 8.8 differs from 8.9/8.10, whose `clickCamundaApps`
336
+ // falls out of the same loop and returns *without* throwing. The recovery
337
+ // this wrapper provides is needed either way, but do not carry that
338
+ // description back here -- on 8.8 the failure arrives as an exception.
339
+ // `totalTimeout` is what makes the stated budget true. Retrying on a thrown
340
+ // hand-off (rather than escaping on the first throw) is correct, but it also
341
+ // multiplies the cost: a worst-case attempt here is clickCamundaApps (~188s
342
+ // when it has to hunt for a trigger) + clickOptimize (~456s across its own
343
+ // three retries) + the banner wait, so four attempts could reach ~43 min and
344
+ // blow the 36-minute test.slow() budget of the specs that call this. Checked
345
+ // between attempts, so the real ceiling is the deadline plus at most one
346
+ // attempt -- bounded, which "N x timeout" arithmetic alone never was.
347
+ async openOptimizeWithRetry(banner, clusterName, timeout = 60000, maxRetries = 3, totalTimeout = 600000) {
348
+ const start = Date.now();
349
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
350
+ try {
351
+ // Inside the try, not before it: both of these throw -- `clickOptimize`
352
+ // after its own retries, `clickCamundaApps` once its probes are
353
+ // exhausted or a click times out. With those calls outside, the first
354
+ // such throw escaped this helper before it ever reached the
355
+ // `page.goto('/')` below -- so in exactly the dead page case this
356
+ // wrapper exists for, the recovery was skipped and the remaining
357
+ // attempts never ran.
358
+ await this.clickCamundaApps();
359
+ await this.clickOptimize(clusterName);
360
+ await (0, test_1.expect)(banner).toBeVisible({ timeout });
361
+ return;
362
+ }
363
+ catch (error) {
364
+ const elapsed = Date.now() - start;
365
+ if (attempt === maxRetries - 1 || elapsed >= totalTimeout) {
366
+ throw error;
367
+ }
368
+ console.warn(`Optimize hand-off failed on attempt ${attempt + 1} ` +
369
+ `(${Math.round(elapsed / 1000)}s elapsed); re-entering through ` +
370
+ `the apps menu. ${error}`);
371
+ await this.page.goto('/', { waitUntil: 'domcontentloaded' });
372
+ }
373
+ }
374
+ }
325
375
  async clickCamundaApps(maxRetries = 3) {
326
376
  await (0, sleep_1.sleep)(2000);
327
377
  const appButtons = [
@@ -29,12 +29,15 @@ declare class ModelerHomePage {
29
29
  readonly createIdpApplicationButton: Locator;
30
30
  readonly dialog: Locator;
31
31
  readonly closeButton: Locator;
32
+ readonly blockingDialog: Locator;
33
+ readonly blockingDialogDismiss: Locator;
32
34
  readonly cancelButton: Locator;
33
35
  readonly optionsButton: Locator;
34
36
  readonly formNameBreadcrumb: (formName: string) => Locator;
35
37
  readonly buttonSkipCustomization: Locator;
36
38
  constructor(page: Page);
37
39
  dismissBlockingDialog(): Promise<void>;
40
+ private dismissUncataloguedDialog;
38
41
  dismissOverlays(): Promise<void>;
39
42
  clickCreateNewProjectButton(): Promise<void>;
40
43
  enterNewProjectName(name: string): Promise<void>;
@@ -49,6 +49,8 @@ class ModelerHomePage {
49
49
  createIdpApplicationButton;
50
50
  dialog;
51
51
  closeButton;
52
+ blockingDialog;
53
+ blockingDialogDismiss;
52
54
  cancelButton;
53
55
  optionsButton;
54
56
  formNameBreadcrumb;
@@ -127,6 +129,37 @@ class ModelerHomePage {
127
129
  this.closeButton = page
128
130
  .locator('button:visible', { hasText: /^Got it/ })
129
131
  .first();
132
+ // Not every modal hub stacks over the projects list carries a "Got it"
133
+ // control. When one does not, the `closeButton` probe finds nothing and
134
+ // `dismissBlockingDialog` returns having dismissed *nothing*, so the caller
135
+ // clicks straight into an overlay that is still there. Observed on this
136
+ // version in run 35102270880 (chromium-v2), which lost `Public Start Form`,
137
+ // `RBA On User Flow - No User Permission`, `- Permission for One Process`
138
+ // and `Form.js Integration with User Task and AI Generated Form` to exactly
139
+ // that.
140
+ //
141
+ // Anchored on the visible dialog so an announcement nobody has catalogued
142
+ // yet is still cleared, rather than on one more label. `:visible` matters --
143
+ // the Modeler home page keeps several `role="dialog"` containers mounted
144
+ // and hidden, which is why the label-first lookup above exists at all.
145
+ //
146
+ // `.last()`, not `.first()`: Carbon stacks modals in DOM order, so the last
147
+ // one is the topmost, and it is the topmost backdrop that intercepts the
148
+ // pointer events this whole helper exists to clear. Targeting `.first()`
149
+ // would click the dismiss button of a *covered* dialog and leave the
150
+ // blocker up -- reproducing the interception rather than fixing it. Same
151
+ // reasoning, and the same conclusion, as `HomePage.closeInformationDialog`.
152
+ // The nth here is deliberate rather than the accidental kind CLAUDE.md
153
+ // warns about: picking the top of a page-wide stack is the whole point, so
154
+ // there is no parent to scope to, and `:visible` already excludes the
155
+ // mounted-but-hidden containers.
156
+ this.blockingDialog = page.locator('[role="dialog"]:visible').last();
157
+ // Only unambiguous dismissals. Deliberately NOT "Cancel": that is a form
158
+ // action on the dialogs specs open on purpose (cluster select, deploy), and
159
+ // clicking it would break a working test rather than unblock a stuck one.
160
+ this.blockingDialogDismiss = this.blockingDialog
161
+ .getByRole('button', { name: /^(Got it|Close|Dismiss)/i })
162
+ .first();
130
163
  this.optionsButton = page.getByRole('button', { name: 'Options' });
131
164
  this.formNameBreadcrumb = (formName) => page.locator('button[data-test="breadcrumb-form"]', {
132
165
  hasText: formName,
@@ -151,17 +184,60 @@ class ModelerHomePage {
151
184
  .waitForLoadState('networkidle', { timeout: 15000 })
152
185
  .catch(() => false);
153
186
  for (let attempt = 0; attempt < 3; attempt++) {
154
- const isOpen = await this.closeButton
155
- .waitFor({ state: 'visible', timeout: 10000 })
156
- .then(() => true)
157
- .catch(() => false);
187
+ const isOpen = await this.becameVisible(this.closeButton, 10000);
188
+ // `break`, not `return`: a modal without a "Got it" control is exactly
189
+ // the case the generic pass below exists for, and returning here is what
190
+ // let it through.
158
191
  if (!isOpen)
159
- return;
192
+ break;
160
193
  await this.closeButton.click({ timeout: 5000 }).catch(() => { });
161
194
  await this.closeButton
162
195
  .waitFor({ state: 'hidden', timeout: 5000 })
163
196
  .catch(() => false);
164
197
  }
198
+ await this.dismissUncataloguedDialog();
199
+ }
200
+ // Second pass for a modal the label-based probe above cannot see. Kept cheap
201
+ // because `dismissBlockingDialog` is called from a dozen places on the happy
202
+ // path: when nothing is open this costs one 2s probe.
203
+ async dismissUncataloguedDialog() {
204
+ // Peel the stack from the top, re-resolving `blockingDialog` each round:
205
+ // closing the topmost uncovers the next, and `.last()` then points at it.
206
+ // Three rounds covers the two hub is known to stack (What's New plus the
207
+ // migration notice) and still terminates on the probe below when the stack
208
+ // is clear; it is not a budget to burn, so a clear page costs one 2s probe.
209
+ let lastClickError;
210
+ for (let attempt = 0; attempt < 3; attempt++) {
211
+ if (!(await this.becameVisible(this.blockingDialog, 2000)))
212
+ return;
213
+ if (!(await this.becameVisible(this.blockingDialogDismiss, 2000))) {
214
+ // Nothing safe to click. Leave it rather than guess: pressing Escape
215
+ // blindly does not close a Carbon dialog (focus sits on the body after
216
+ // a load), and clicking an unrecognised footer button could confirm
217
+ // something. The caller's own assertion then reports the real blocker.
218
+ return;
219
+ }
220
+ try {
221
+ await this.blockingDialogDismiss.click({ timeout: 5000 });
222
+ lastClickError = undefined;
223
+ }
224
+ catch (error) {
225
+ // Held, not swallowed. A click can fail here for reasons the next round
226
+ // clears on its own -- a backdrop fading in over the button, a dialog
227
+ // re-rendering mid-animation -- so give it that round. If a dialog is
228
+ // still up once the rounds are spent, this error is what gets reported.
229
+ lastClickError = error;
230
+ }
231
+ }
232
+ // Still blocked after three rounds. Say so here rather than letting the
233
+ // caller click into the overlay and report "intercepts pointer events" from
234
+ // a line that has nothing to do with the dialog.
235
+ if (await this.blockingDialog.isVisible().catch(() => false)) {
236
+ throw new Error('A blocking Modeler dialog is still visible after 3 dismiss attempts.' +
237
+ (lastClickError === undefined
238
+ ? ' Its dismiss control was clicked without closing it.'
239
+ : ` Last dismiss click failed: ${lastClickError}`));
240
+ }
165
241
  }
166
242
  async dismissOverlays() {
167
243
  // Before the generic pass: clickMessageBanner and the loop below resolve
@@ -10,6 +10,8 @@ declare class Authorization {
10
10
  protected readonly createAuthorizationOwnerOption: (name: string) => Locator;
11
11
  protected readonly createAuthorizationOwnerSearchInput: Locator;
12
12
  protected readonly createAuthorizationOwnerSearchOption: (name: string) => Locator;
13
+ protected readonly createAuthorizationOwnerCommandInput: Locator;
14
+ protected readonly createAuthorizationOwnerCommandOptions: Locator;
13
15
  protected readonly createAuthorizationResourceTypeDropdown: Locator;
14
16
  protected readonly createAuthorizationResourceTypeOption: (name: string) => Locator;
15
17
  protected readonly createAuthorizationResourceIdField: Locator;
@@ -16,6 +16,8 @@ class Authorization {
16
16
  createAuthorizationOwnerOption;
17
17
  createAuthorizationOwnerSearchInput;
18
18
  createAuthorizationOwnerSearchOption;
19
+ createAuthorizationOwnerCommandInput;
20
+ createAuthorizationOwnerCommandOptions;
19
21
  createAuthorizationResourceTypeDropdown;
20
22
  createAuthorizationResourceTypeOption;
21
23
  createAuthorizationResourceIdField;
@@ -72,6 +74,18 @@ class Authorization {
72
74
  .locator('.cds--list-box__menu-item, [role="option"]')
73
75
  .filter({ hasText: name })
74
76
  .first();
77
+ // In the design-system build the closed Owner control is a combobox-role
78
+ // trigger that paints "Search by owner ID" as static text (no placeholder
79
+ // attribute), and the cmdk search box plus its result listbox only mount
80
+ // in a popover once the trigger is opened. That popover is portalled out
81
+ // of the dialog, and while it is open Radix marks the dialog aria-hidden,
82
+ // so both must be queried from the page root.
83
+ this.createAuthorizationOwnerCommandInput = this.page
84
+ .locator('[data-slot="command-input"]')
85
+ .first();
86
+ this.createAuthorizationOwnerCommandOptions = this.page
87
+ .getByRole('listbox')
88
+ .getByRole('option');
75
89
  this.createAuthorizationResourceTypeDropdown =
76
90
  this.createAuthorizationModal.getByRole('combobox', {
77
91
  name: 'Resource type',
@@ -133,7 +147,10 @@ class Authorization {
133
147
  .click({ timeout: Math.min(timeout, 30000) });
134
148
  }
135
149
  // Released 8.10 alpha images still render the Carbon combobox that lists
136
- // every owner, so accept whichever owner field this build shows.
150
+ // every owner, so accept whichever of the three owner fields this build
151
+ // shows: the Carbon search dropdown, the Carbon combobox that lists every
152
+ // owner inline, or the design-system combobox whose cmdk popover only loads
153
+ // results once the owner ID is typed.
137
154
  async selectAuthorizationOwner(ownerId) {
138
155
  await (0, test_1.expect)(this.createAuthorizationOwnerSearchInput.or(this.createAuthorizationOwnerComboBox)).toBeVisible({ timeout: 30000 });
139
156
  if (await this.createAuthorizationOwnerSearchInput.isVisible()) {
@@ -151,10 +168,29 @@ class Authorization {
151
168
  return;
152
169
  }
153
170
  await this.createAuthorizationOwnerComboBox.click();
154
- await (0, test_1.expect)(this.createAuthorizationOwnerOption(ownerId)).toBeVisible({
171
+ const commandInput = this.createAuthorizationOwnerCommandInput;
172
+ const inlineOwnerOption = this.createAuthorizationOwnerOption(ownerId);
173
+ await (0, test_1.expect)(commandInput.or(inlineOwnerOption).first()).toBeVisible({
174
+ timeout: 30000,
175
+ });
176
+ if (await commandInput.isVisible()) {
177
+ await commandInput.fill(ownerId);
178
+ // The result list is served by the API and debounced, and each option is
179
+ // labelled "<owner id> — <name>" rather than the bare ID, so narrow the
180
+ // search to the single row this unique owner ID resolves to instead of
181
+ // matching on the label text.
182
+ const ownerOptions = this.createAuthorizationOwnerCommandOptions;
183
+ await (0, test_1.expect)(ownerOptions).toHaveCount(1, { timeout: 60000 });
184
+ await ownerOptions.first().click({ timeout: 30000, force: true });
185
+ // Committing the choice closes the popover, which is what hands the
186
+ // dialog back to the accessibility tree for the fields that follow.
187
+ await (0, test_1.expect)(commandInput).toBeHidden({ timeout: 30000 });
188
+ return;
189
+ }
190
+ await (0, test_1.expect)(inlineOwnerOption).toBeVisible({
155
191
  timeout: 30000,
156
192
  });
157
- await this.createAuthorizationOwnerOption(ownerId).click({
193
+ await inlineOwnerOption.click({
158
194
  timeout: 30000,
159
195
  });
160
196
  }
@@ -24,7 +24,7 @@ class ModelerHomePage {
24
24
  // Flag-on (workspaces-enabled) state. Under camunda-hub#27903 the c8-e2e
25
25
  // "project folder" maps to a Workspace and the "process application" to a
26
26
  // Project: `/` redirects to the `/workspaces` overview, a Workspace holds a
27
- // projects listing at `/workspace/{wsId}/projects`, and a Project (the
27
+ // projects listing at `/workspaces/{wsId}/projects`, and a Project (the
28
28
  // resource container) lives one level deeper. These remember the shared
29
29
  // Workspace's projects-listing URL and the overview URL so returns don't
30
30
  // re-walk the hierarchy from the root each time.
@@ -324,7 +324,7 @@ class ModelerHomePage {
324
324
  await this.ensureWorkspaceOpen();
325
325
  }
326
326
  // Ensures the shared "Cross Component Test Project" Workspace is open and the
327
- // browser is on its projects listing (`/workspace/{wsId}/projects`), where the
327
+ // browser is on its projects listing (`/workspaces/{wsId}/projects`), where the
328
328
  // per-test Project is later created. Retries the whole navigation+login on a
329
329
  // transient web-modeler 500 rather than a bare reload, which would drop the
330
330
  // authenticated SPA state and land on the empty login form.
@@ -440,7 +440,7 @@ class ModelerHomePage {
440
440
  ? openButton.first()
441
441
  : this.crossComponentProjectFolder;
442
442
  await target.click({ timeout: 60000 });
443
- await this.page.waitForURL(/\/workspace\/[^/]+\/projects/, {
443
+ await this.page.waitForURL(/\/workspaces\/[^/]+\/projects/, {
444
444
  timeout: 120000,
445
445
  });
446
446
  this.workspaceProjectsUrl = this.page.url();
@@ -303,6 +303,17 @@ class OCTenantPage {
303
303
  }
304
304
  }
305
305
  async fillRoleIdSearchBox(roleId, roleIdentifier) {
306
+ // The option row renders the ID followed by the name, so the name is the
307
+ // tail of `roleIdentifier`.
308
+ const roleName = roleIdentifier.startsWith(roleId)
309
+ ? roleIdentifier.slice(roleId.length)
310
+ : '';
311
+ // The design-system row separates the two parts with an em dash
312
+ // ('connectors — Connectors') while the legacy Carbon row concatenates
313
+ // them ('connectorsConnectors'), so match both with an optional
314
+ // dash/whitespace run between the ID and the name.
315
+ const escapeForRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
316
+ const rolePattern = new RegExp(`${escapeForRegExp(roleId)}[-\\s\\u2010-\\u2015]*${escapeForRegExp(roleName)}`);
306
317
  // The design-system shell portals the matches into a listbox rendered as
307
318
  // a sibling of the dialog, so they cannot be scoped to the modal. The
308
319
  // legacy Carbon list stays inside it and must remain scoped: the tenant's
@@ -311,19 +322,15 @@ class OCTenantPage {
311
322
  // the modal overlay.
312
323
  const result = this.page
313
324
  .getByRole('option')
314
- .filter({ hasText: roleIdentifier })
315
- .or(this.assignRoleModal.getByText(roleIdentifier))
325
+ .filter({ hasText: rolePattern })
326
+ .or(this.assignRoleModal.getByText(rolePattern))
316
327
  .first();
317
328
  // The dropdown no longer filters client-side: it queries
318
329
  // `/v2/roles/search` with `name: {$like: '*<typed>*'}`, which is
319
330
  // case-sensitive, so the role ID ('connectors') never matches the default
320
- // role's name ('Connectors'). The option row renders the ID followed by
321
- // the name, so the name is the tail of `roleIdentifier`: search the ID
322
- // first for the shells that still match on it, then fall back to the name.
331
+ // role's name ('Connectors'): search the ID first for the shells that
332
+ // still match on it, then fall back to the name.
323
333
  const searchTerms = [roleId];
324
- const roleName = roleIdentifier.startsWith(roleId)
325
- ? roleIdentifier.slice(roleId.length)
326
- : '';
327
334
  if (roleName !== '' && roleName !== roleId) {
328
335
  searchTerms.push(roleName);
329
336
  }
@@ -12,7 +12,7 @@ const resourceContainers = new WeakMap();
12
12
  // the resource container -- files, folders and uploads live directly inside it,
13
13
  // there is no separate process-application level to descend into. It is created
14
14
  // from the workspace's projects listing via the "Create project" split button +
15
- // dialog, and the app lands on `/workspace/{wsId}/projects/{projectId}`.
15
+ // dialog, and the app lands on `/workspaces/{wsId}/projects/{projectId}`.
16
16
  //
17
17
  // This page object owns that container's surface (creating a Project, and binding
18
18
  // its development-stage cluster). Entering and leaving it is driven from
@@ -157,7 +157,33 @@ _8_8_1.test.describe('User Roles User Flow', () => {
157
157
  username: process.env.C8_USERNAME_TEST_9,
158
158
  password: process.env.C8_PASSWORD_TEST_9,
159
159
  });
160
- await (0, sleep_1.sleep)(600000); //wait for Optimize cache to refresh
160
+ });
161
+ await _8_8_1.test.step('Wait for the role change to reach Optimize', async () => {
162
+ // Replaces a blind `sleep(600000)`. The role change only takes effect in
163
+ // Optimize once its permission cache refreshes, and reaching Optimize is
164
+ // the single thing the rest of this test depends on -- so retry the
165
+ // hand-off until it succeeds rather than waiting out a fixed ten minutes.
166
+ // Sized against the helper's REAL per-attempt cost, not just the banner
167
+ // timeout: each attempt also pays clickCamundaApps() and clickOptimize()
168
+ // before the banner is ever asserted. Driving the real method under a
169
+ // virtual clock, (40000, 15) -- which looks like "15 x 40s = 600s" --
170
+ // actually runs 986s in the ordinary bad case and 4581s when
171
+ // clickCamundaApps has to hunt for its trigger, the latter alone
172
+ // exceeding this test's 36-minute test.slow() budget. 4 x 120s measures
173
+ // at 581s, which is the honest equivalent of the 600s sleep, and four
174
+ // attempts is ample for the page.goto('/') recovery below (the helper's
175
+ // own default is 3). It still rethrows once exhausted, so a cache that
176
+ // never refreshes fails here rather than further downstream.
177
+ //
178
+ // openOptimizeWithRetry rather than a hand-rolled poll because it carries
179
+ // the recovery this needs: a failed hand-off can leave the page on
180
+ // /login?error, where no app-switcher button is visible, so
181
+ // clickCamundaApps() burns its rounds probing and then throws
182
+ // 'Failed to open app switcher after 3 attempts'. Re-clicking would just
183
+ // retry the same dead page until the budget ran out. openOptimizeWithRetry
184
+ // catches that throw and does page.goto('/') between attempts, which is
185
+ // the only thing that gets back to a usable state.
186
+ await appsPage.openOptimizeWithRetry(optimizeHomePage.optimizeBanner, clusterName, 120000, 4);
161
187
  });
162
188
  await _8_8_1.test.step('Navigate to Console and Ensure Apps are Available in App Switcher', async () => {
163
189
  await appsPage.clickCamundaApps();
@@ -247,7 +273,33 @@ _8_8_1.test.describe('User Roles User Flow', () => {
247
273
  username: process.env.C8_USERNAME_TEST_10,
248
274
  password: process.env.C8_PASSWORD_TEST_10,
249
275
  });
250
- await (0, sleep_1.sleep)(600000); //wait for Optimize cache to refresh
276
+ });
277
+ await _8_8_1.test.step('Wait for the role change to reach Optimize', async () => {
278
+ // Replaces a blind `sleep(600000)`. The role change only takes effect in
279
+ // Optimize once its permission cache refreshes, and reaching Optimize is
280
+ // the single thing the rest of this test depends on -- so retry the
281
+ // hand-off until it succeeds rather than waiting out a fixed ten minutes.
282
+ // Sized against the helper's REAL per-attempt cost, not just the banner
283
+ // timeout: each attempt also pays clickCamundaApps() and clickOptimize()
284
+ // before the banner is ever asserted. Driving the real method under a
285
+ // virtual clock, (40000, 15) -- which looks like "15 x 40s = 600s" --
286
+ // actually runs 986s in the ordinary bad case and 4581s when
287
+ // clickCamundaApps has to hunt for its trigger, the latter alone
288
+ // exceeding this test's 36-minute test.slow() budget. 4 x 120s measures
289
+ // at 581s, which is the honest equivalent of the 600s sleep, and four
290
+ // attempts is ample for the page.goto('/') recovery below (the helper's
291
+ // own default is 3). It still rethrows once exhausted, so a cache that
292
+ // never refreshes fails here rather than further downstream.
293
+ //
294
+ // openOptimizeWithRetry rather than a hand-rolled poll because it carries
295
+ // the recovery this needs: a failed hand-off can leave the page on
296
+ // /login?error, where no app-switcher button is visible, so
297
+ // clickCamundaApps() burns its rounds probing and then throws
298
+ // 'Failed to open app switcher after 3 attempts'. Re-clicking would just
299
+ // retry the same dead page until the budget ran out. openOptimizeWithRetry
300
+ // catches that throw and does page.goto('/') between attempts, which is
301
+ // the only thing that gets back to a usable state.
302
+ await appsPage.openOptimizeWithRetry(optimizeHomePage.optimizeBanner, clusterName, 120000, 4);
251
303
  });
252
304
  await _8_8_1.test.step('Navigate to Console and Ensure Apps are Available in App Switcher', async () => {
253
305
  await appsPage.clickCamundaApps();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camunda/e2e-test-suite",
3
- "version": "0.0.1182",
3
+ "version": "0.0.1184",
4
4
  "description": "End-to-end test helpers for Camunda 8",
5
5
  "repository": {
6
6
  "type": "git",