@camunda/e2e-test-suite 0.0.1181 → 0.0.1183

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.
@@ -31,7 +31,7 @@ declare class AppsPage {
31
31
  openOperateWithRetry(banner: Locator, clusterName: string, timeout?: number, maxRetries?: number): Promise<void>;
32
32
  clickTasklist(clusterName: string): Promise<void>;
33
33
  openTasklistWithRetry(banner: Locator, clusterName: string, timeout?: number, maxRetries?: number): Promise<void>;
34
- openOptimizeWithRetry(banner: Locator, clusterName: string, timeout?: number, maxRetries?: number): Promise<void>;
34
+ openOptimizeWithRetry(banner: Locator, clusterName: string, timeout?: number, maxRetries?: number, totalTimeout?: number): Promise<void>;
35
35
  clickAdmin(clusterName: string): Promise<void>;
36
36
  private doClickClusterInOperate;
37
37
  private doClickClusterInTasklist;
@@ -352,20 +352,37 @@ class AppsPage {
352
352
  // instead. Every other 8.10 spec already asserts `optimizeBanner` right
353
353
  // after `clickOptimize` for exactly this reason; only the smoke flow did
354
354
  // not.
355
- async openOptimizeWithRetry(banner, clusterName, timeout = 60000, maxRetries = 3) {
355
+ // `totalTimeout` is what makes the stated budget true. Retrying on a thrown
356
+ // hand-off (rather than escaping on the first throw) is correct, but it also
357
+ // multiplies the cost: a worst-case attempt here is clickCamundaApps (~188s
358
+ // when it has to hunt for a trigger) + clickOptimize (~456s across its own
359
+ // three retries) + the banner wait, so four attempts could reach ~43 min and
360
+ // blow the 36-minute test.slow() budget of the specs that call this. Checked
361
+ // between attempts, so the real ceiling is the deadline plus at most one
362
+ // attempt -- bounded, which "N x timeout" arithmetic alone never was.
363
+ async openOptimizeWithRetry(banner, clusterName, timeout = 60000, maxRetries = 3, totalTimeout = 600000) {
364
+ const start = Date.now();
356
365
  for (let attempt = 0; attempt < maxRetries; attempt++) {
357
- await this.clickCamundaApps();
358
- await this.clickOptimize(clusterName);
359
366
  try {
367
+ // Inside the try, not before it: `clickOptimize` throws after its own
368
+ // retries, and `clickCamundaApps` can throw on a click timeout. With
369
+ // those calls outside, the first such throw escaped this helper before
370
+ // it ever reached the `page.goto('/')` below -- so in exactly the dead
371
+ // page case this wrapper exists for, the recovery was skipped and the
372
+ // remaining attempts never ran.
373
+ await this.clickCamundaApps();
374
+ await this.clickOptimize(clusterName);
360
375
  await (0, test_1.expect)(banner).toBeVisible({ timeout });
361
376
  return;
362
377
  }
363
378
  catch (error) {
364
- if (attempt === maxRetries - 1) {
379
+ const elapsed = Date.now() - start;
380
+ if (attempt === maxRetries - 1 || elapsed >= totalTimeout) {
365
381
  throw error;
366
382
  }
367
- console.warn(`Optimize banner not visible on attempt ${attempt + 1}; ` +
368
- 're-entering through the apps menu.');
383
+ console.warn(`Optimize hand-off failed on attempt ${attempt + 1} ` +
384
+ `(${Math.round(elapsed / 1000)}s elapsed); re-entering through ` +
385
+ `the apps menu. ${error}`);
369
386
  await this.page.goto('/', { waitUntil: 'domcontentloaded' });
370
387
  }
371
388
  }
@@ -462,37 +462,69 @@ class ClusterPage {
462
462
  exact: true,
463
463
  });
464
464
  const start = Date.now();
465
- const maxRetries = 5;
465
+ // The loop is bounded by `totalTimeout` alone; it used to also stop at a
466
+ // hardcoded `maxRetries = 5`. What that cap changed is the *reload* rate,
467
+ // not the budget: with `visibilityTimeout` at 120s (what
468
+ // createClusterIfNotExists passes) five attempts of ~123s each already
469
+ // consumed the full 600s, so the old loop was not giving budget back.
470
+ //
471
+ // What it did do is reload the cluster list only 5 times across those 600s.
472
+ // `pollTimeout` caps how long any single `waitFor` blocks, which raises that
473
+ // to ~33 reloads for the same wall clock. That matters because the Healthy
474
+ // badge is only re-read after a reload -- a `waitFor` sitting on an
475
+ // already-rendered page cannot observe a status that changed server-side,
476
+ // so the effective polling resolution is the reload interval (~123s before,
477
+ // ~18s now), not the `waitFor` timeout.
478
+ //
479
+ // Worth knowing if you touch this: if the cluster list ever starts
480
+ // live-updating its status without a reload, this change stops buying
481
+ // anything, because then the old loop's long `waitFor` would already notice
482
+ // the transition the moment it happened.
483
+ const pollTimeout = Math.min(visibilityTimeout, 15000);
466
484
  let attempt = 0;
467
- let unhealthyConsecutive = 0;
485
+ // Unhealthy is confirmed by how long it persists, not by a poll count: a
486
+ // cluster mid-provisioning can flash through Unhealthy during the
487
+ // Unhealthy -> Creating -> Healthy transition. The old "2 consecutive
488
+ // polls" gate was poll-rate dependent -- at a `visibilityTimeout`-long
489
+ // interval it meant roughly 2 x visibilityTimeout of Unhealthy -- so now
490
+ // that we poll every 15s it has to be expressed as elapsed time, or the
491
+ // tolerance silently shrinks to 30s.
492
+ //
493
+ // It is derived from the caller's `visibilityTimeout` rather than fixed, so
494
+ // each caller keeps the tolerance it had: createClusterIfNotExists (120s)
495
+ // gets 4 minutes, and console-user-flows' cluster-creation check (300s,
496
+ // with a 25-minute total budget) keeps its ~10 minutes instead of being cut
497
+ // to 2. The floor matters for callers that pass a small visibilityTimeout.
498
+ const unhealthyConfirmMs = Math.max(2 * visibilityTimeout, 120000);
499
+ let unhealthySince = null;
468
500
  let lastErr;
469
- while (Date.now() - start < totalTimeout && attempt < maxRetries) {
501
+ while (Date.now() - start < totalTimeout) {
470
502
  attempt++;
471
503
  try {
472
504
  await this.page.reload();
473
505
  await (0, test_1.expect)(this.clusterRow(name)).toBeVisible({ timeout: 10000 });
474
- // Only treat Unhealthy as terminal if it persists across two
475
- // consecutive polling attempts. A cluster mid-provisioning can flash
476
- // through Unhealthy briefly during the Unhealthy → Creating → Healthy
477
- // transition and we don't want a single transient reading to abort
478
- // the whole helper — earlier versions gated only on `attempt > 1`
479
- // which still false-positived when the transient Unhealthy showed up
480
- // on attempt 2.
506
+ // Only treat Unhealthy as terminal once it has persisted for
507
+ // `unhealthyConfirmMs`. A cluster mid-provisioning can flash through
508
+ // Unhealthy briefly during the Unhealthy → Creating → Healthy
509
+ // transition and we don't want a transient reading to abort the whole
510
+ // helper — earlier versions gated on `attempt > 1`, then on two
511
+ // consecutive polls, both of which are poll-rate dependent.
481
512
  const isUnhealthy = await unhealthy
482
513
  .isVisible({ timeout: 2000 })
483
514
  .catch(() => false);
484
515
  if (isUnhealthy) {
485
- unhealthyConsecutive++;
516
+ unhealthySince ??= Date.now();
517
+ const unhealthyFor = Date.now() - unhealthySince;
518
+ if (unhealthyFor >= unhealthyConfirmMs) {
519
+ throw new Error(`Cluster "${name}" reached terminal Unhealthy state after ${Math.round((Date.now() - start) / 1000)}s (Unhealthy continuously for ${Math.round(unhealthyFor / 1000)}s); aborting Healthy poll.`);
520
+ }
486
521
  }
487
522
  else {
488
- unhealthyConsecutive = 0;
489
- }
490
- if (unhealthyConsecutive >= 2) {
491
- throw new Error(`Cluster "${name}" reached terminal Unhealthy state after ${Math.round((Date.now() - start) / 1000)}s (${unhealthyConsecutive} consecutive Unhealthy polls); aborting Healthy poll.`);
523
+ unhealthySince = null;
492
524
  }
493
525
  await this.clusterHealthiness(name).waitFor({
494
526
  state: 'visible',
495
- timeout: visibilityTimeout,
527
+ timeout: pollTimeout,
496
528
  });
497
529
  console.log('Cluster is healthy:', name);
498
530
  return;
@@ -503,11 +535,17 @@ class ClusterPage {
503
535
  throw err;
504
536
  }
505
537
  lastErr = err;
506
- console.log(`Attempt ${attempt} failed for cluster "${name}" Healthy check, retrying...`, err instanceof Error ? err.message : err);
538
+ // Only log every 8th attempt now that we poll every 15s: the previous
539
+ // one-line-per-attempt output was 4 lines, it would otherwise be ~40
540
+ // identical lines per cluster and this suite creates several.
541
+ if (attempt === 1 || attempt % 8 === 0) {
542
+ console.log(`Attempt ${attempt} failed for cluster "${name}" Healthy check ` +
543
+ `(${Math.round((Date.now() - start) / 1000)}s elapsed), retrying...`, err instanceof Error ? err.message : err);
544
+ }
507
545
  await this.page.waitForTimeout(500);
508
546
  }
509
547
  }
510
- throw new Error(`Cluster "${name}" did not become Healthy after ${attempt} attempts. Last error: ${lastErr instanceof Error ? lastErr.message : lastErr}`);
548
+ throw new Error(`Cluster "${name}" did not become Healthy within ${Math.round(totalTimeout / 1000)}s (${attempt} polls). Last error: ${lastErr instanceof Error ? lastErr.message : lastErr}`);
511
549
  }
512
550
  async clickAlphaTab() {
513
551
  await this.alphaTab.click();
@@ -29,6 +29,8 @@ 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 optionsButton: Locator;
33
35
  readonly formNameBreadcrumb: (formName: string) => Locator;
34
36
  readonly breadcrumbProjectMenu: Locator;
@@ -68,6 +70,7 @@ declare class ModelerHomePage {
68
70
  clickManageButton(retries?: number): Promise<void>;
69
71
  clickUploadFilesButton(): Promise<void>;
70
72
  dismissBlockingDialog(): Promise<void>;
73
+ private dismissUncataloguedDialog;
71
74
  clickMessageBanner(): Promise<void>;
72
75
  selectCluster(clusterName: string): Promise<void>;
73
76
  clickIdpApplicationTemplateOption(): Promise<void>;
@@ -47,6 +47,8 @@ class ModelerHomePage {
47
47
  createIdpApplicationButton;
48
48
  dialog;
49
49
  closeButton;
50
+ blockingDialog;
51
+ blockingDialogDismiss;
50
52
  optionsButton;
51
53
  formNameBreadcrumb;
52
54
  breadcrumbProjectMenu;
@@ -132,6 +134,25 @@ class ModelerHomePage {
132
134
  this.closeButton = page
133
135
  .locator('button:visible', { hasText: /^Got it/ })
134
136
  .first();
137
+ // Not every modal hub stacks over the projects list carries a "Got it"
138
+ // control. When one does not, the `closeButton` probe above finds nothing
139
+ // and `dismissBlockingDialog` returns having dismissed *nothing*, so the
140
+ // caller clicks straight into an overlay that is still there. Run
141
+ // 35102164337 lost 9 tests that way: the project link resolved "visible,
142
+ // enabled and stable", then the click retried 116 times against a modal
143
+ // reported as intercepting pointer events.
144
+ //
145
+ // Anchored on the visible dialog so an announcement nobody has catalogued
146
+ // yet is still cleared, rather than on one more label. `:visible` matters —
147
+ // the Modeler home page keeps several `role="dialog"` containers mounted
148
+ // and hidden, which is why the label-first lookup above exists at all.
149
+ this.blockingDialog = page.locator('[role="dialog"]:visible').first();
150
+ // Only unambiguous dismissals. Deliberately NOT "Cancel": that is a form
151
+ // action on the dialogs specs open on purpose (cluster select, deploy), and
152
+ // clicking it would break a working test rather than unblock a stuck one.
153
+ this.blockingDialogDismiss = this.blockingDialog
154
+ .getByRole('button', { name: /^(Got it|Close|Dismiss)/i })
155
+ .first();
135
156
  this.optionsButton = page.getByRole('button', { name: 'Options' });
136
157
  this.formNameBreadcrumb = (formName) => page.locator('button[data-test="breadcrumb-form"]', {
137
158
  hasText: formName,
@@ -585,17 +606,42 @@ class ModelerHomePage {
585
606
  .waitForLoadState('networkidle', { timeout: 15000 })
586
607
  .catch(() => false);
587
608
  for (let attempt = 0; attempt < 3; attempt++) {
588
- const isOpen = await this.closeButton
589
- .waitFor({ state: 'visible', timeout: 10000 })
590
- .then(() => true)
591
- .catch(() => false);
609
+ const isOpen = await this.becameVisible(this.closeButton, 10000);
610
+ // `break`, not `return`: a modal without a "Got it" control is exactly
611
+ // the case the generic pass below exists for, and returning here is what
612
+ // let it through.
592
613
  if (!isOpen)
593
- return;
614
+ break;
594
615
  await this.closeButton.click({ timeout: 5000 }).catch(() => { });
595
616
  await this.closeButton
596
617
  .waitFor({ state: 'hidden', timeout: 5000 })
597
618
  .catch(() => false);
598
619
  }
620
+ await this.dismissUncataloguedDialog();
621
+ }
622
+ // Second pass for a modal the label-based probe above cannot see. Kept cheap
623
+ // because `dismissBlockingDialog` is called from a dozen places on the happy
624
+ // path: when nothing is open this costs one 2s probe, so it cannot turn into
625
+ // the kind of hidden budget the Optimize retry sizing did.
626
+ async dismissUncataloguedDialog() {
627
+ for (let attempt = 0; attempt < 2; attempt++) {
628
+ if (!(await this.becameVisible(this.blockingDialog, 2000)))
629
+ return;
630
+ if (!(await this.becameVisible(this.blockingDialogDismiss, 2000))) {
631
+ // Nothing safe to click. Leave it rather than guess: pressing Escape
632
+ // blindly does not close a Carbon dialog (focus sits on the body after
633
+ // a load), and clicking an unrecognised footer button could confirm
634
+ // something. The caller's own assertion then reports the real blocker.
635
+ return;
636
+ }
637
+ await this.blockingDialogDismiss.click({ timeout: 5000 }).catch(() => { });
638
+ const closed = await this.blockingDialog
639
+ .waitFor({ state: 'hidden', timeout: 5000 })
640
+ .then(() => true)
641
+ .catch(() => false);
642
+ if (closed)
643
+ return;
644
+ }
599
645
  }
600
646
  async clickMessageBanner() {
601
647
  try {
@@ -11,6 +11,7 @@ declare class ModelerCreatePage {
11
11
  readonly userTaskOption: Locator;
12
12
  readonly appendEndEventButton: Locator;
13
13
  readonly viewProcessInstanceLink: Locator;
14
+ readonly autosaveIndicator: Locator;
14
15
  readonly nameInput: Locator;
15
16
  readonly diagramBreadcrumb: Locator;
16
17
  readonly renameDiagramNameButton: Locator;
@@ -135,7 +136,8 @@ declare class ModelerCreatePage {
135
136
  runProcessInstance(clusterName: string, variables?: string, formVariables?: {
136
137
  [key: string]: string;
137
138
  }, textToVisible?: string): Promise<void>;
138
- retryRunProcess(maxRetries?: number): Promise<void>;
139
+ retryRunProcess(clusterName: string, fillDialog?: () => Promise<void>, maxRetries?: number): Promise<void>;
140
+ private reopenDeployAndRunDialog;
139
141
  clickViewProcessInstanceLink(): Promise<void>;
140
142
  clickNameInput(): Promise<void>;
141
143
  fillNamedInput(name: string): Promise<void>;
@@ -197,6 +199,7 @@ declare class ModelerCreatePage {
197
199
  clickIdInput(): Promise<void>;
198
200
  fillIdInput(id: string): Promise<void>;
199
201
  setAndVerifyProcessId(processId: string): Promise<void>;
202
+ waitForAutosaveAfterEdits(timeout?: number): Promise<void>;
200
203
  fillStartFormName(name: string): Promise<void>;
201
204
  fillStartFormDate(date: string): Promise<void>;
202
205
  checkStartFormCheckbox(): Promise<void>;
@@ -17,6 +17,7 @@ class ModelerCreatePage {
17
17
  userTaskOption;
18
18
  appendEndEventButton;
19
19
  viewProcessInstanceLink;
20
+ autosaveIndicator;
20
21
  nameInput;
21
22
  diagramBreadcrumb;
22
23
  renameDiagramNameButton;
@@ -150,6 +151,7 @@ class ModelerCreatePage {
150
151
  name: 'View process instance',
151
152
  });
152
153
  this.viewProcessLink = page.getByText('view process in Operate');
154
+ this.autosaveIndicator = page.locator('[data-test="autosave"]');
153
155
  this.nameInput = page.getByLabel('Name', { exact: true });
154
156
  this.diagramBreadcrumb = page.locator('[data-test="breadcrumb-diagram"]');
155
157
  this.renameDiagramNameButton = page.getByText('Rename');
@@ -558,35 +560,39 @@ class ModelerCreatePage {
558
560
  await this.clickDeployAndRunMainButton();
559
561
  await this.selectCluster(clusterName, 120000);
560
562
  }
561
- if (variables.length > 0) {
562
- await (0, test_1.expect)(this.variableInput).toBeVisible({
563
- timeout: 60000,
564
- });
565
- await this.clickVariableInput();
566
- await this.fillVariableInput(variables);
567
- }
568
- if (Object.keys(formVariables).length > 0) {
569
- if ('formName' in formVariables && formVariables['formName']) {
570
- await this.fillStartFormName(formVariables['formName']);
571
- }
572
- if ('checkbox' in formVariables && formVariables['checkbox'] == 'true') {
573
- await this.checkStartFormCheckbox();
563
+ const fillDialog = async () => {
564
+ if (variables.length > 0) {
565
+ await (0, test_1.expect)(this.variableInput).toBeVisible({
566
+ timeout: 60000,
567
+ });
568
+ await this.clickVariableInput();
569
+ await this.fillVariableInput(variables);
574
570
  }
575
- if ('startDate' in formVariables && formVariables['startDate']) {
576
- await this.fillStartFormDate(formVariables['startDate']);
571
+ if (Object.keys(formVariables).length > 0) {
572
+ if ('formName' in formVariables && formVariables['formName']) {
573
+ await this.fillStartFormName(formVariables['formName']);
574
+ }
575
+ if ('checkbox' in formVariables &&
576
+ formVariables['checkbox'] == 'true') {
577
+ await this.checkStartFormCheckbox();
578
+ }
579
+ if ('startDate' in formVariables && formVariables['startDate']) {
580
+ await this.fillStartFormDate(formVariables['startDate']);
581
+ }
582
+ if ('startNumber' in formVariables && formVariables['startNumber']) {
583
+ await this.fillStartFormNumber(formVariables['startNumber']);
584
+ }
577
585
  }
578
- if ('startNumber' in formVariables && formVariables['startNumber']) {
579
- await this.fillStartFormNumber(formVariables['startNumber']);
586
+ if (textToVisible.length > 0) {
587
+ await (0, test_1.expect)(this.dialog.getByText(textToVisible)).toBeVisible({
588
+ timeout: 30000,
589
+ });
580
590
  }
581
- }
582
- if (textToVisible.length > 0) {
583
- await (0, test_1.expect)(this.dialog.getByText(textToVisible)).toBeVisible({
584
- timeout: 30000,
585
- });
586
- }
587
- await this.retryRunProcess();
591
+ };
592
+ await fillDialog();
593
+ await this.retryRunProcess(clusterName, fillDialog);
588
594
  }
589
- async retryRunProcess(maxRetries = 3) {
595
+ async retryRunProcess(clusterName, fillDialog, maxRetries = 3) {
590
596
  for (let attempt = 0; attempt < maxRetries; attempt++) {
591
597
  try {
592
598
  await this.clickDeployAndRunSubButton();
@@ -596,16 +602,38 @@ class ModelerCreatePage {
596
602
  return;
597
603
  }
598
604
  catch (error) {
599
- if (attempt < maxRetries - 1) {
600
- console.warn(`Attempt ${attempt + 1} failed for running process. Retrying...`);
601
- }
602
- else {
605
+ if (attempt === maxRetries - 1) {
603
606
  console.error(error);
604
607
  throw new Error(`Running process failed after ${maxRetries} attempts`);
605
608
  }
609
+ console.warn(`Attempt ${attempt + 1} failed for running process. Retrying...`);
610
+ // The dialog closes on a successful deploy and switches to an error
611
+ // screen when the backend rejects one, so in both cases the sub button
612
+ // is gone and re-clicking it can only time out -- which is how a
613
+ // single rejected deploy burned all three attempts in the nightly.
614
+ // Drive the deploy from the top again whenever the button is no longer
615
+ // on offer, so the next attempt is a real one.
616
+ const stillOffered = await this.deployAndRunSubButton
617
+ .isVisible()
618
+ .catch(() => false);
619
+ if (!stillOffered) {
620
+ await this.reopenDeployAndRunDialog(clusterName);
621
+ await fillDialog?.();
622
+ }
606
623
  }
607
624
  }
608
625
  }
626
+ async reopenDeployAndRunDialog(clusterName) {
627
+ const closeButton = this.dialog
628
+ .getByRole('button', { name: 'Close', exact: true })
629
+ .first();
630
+ if (await closeButton.isVisible().catch(() => false)) {
631
+ await closeButton.click({ timeout: 30000 });
632
+ }
633
+ await (0, test_1.expect)(this.dialog).toBeHidden({ timeout: 30000 });
634
+ await this.clickDeployAndRunMainButton();
635
+ await this.selectCluster(clusterName, 120000);
636
+ }
609
637
  async clickViewProcessInstanceLink() {
610
638
  await this.viewProcessInstanceLink.click({ timeout: 180000 });
611
639
  }
@@ -998,6 +1026,33 @@ class ModelerCreatePage {
998
1026
  }
999
1027
  await (0, test_1.expect)(this.processIdInput).toHaveValue(processId, { timeout: 10000 });
1000
1028
  }
1029
+ // Web Modeler persists editor edits on a debounced autosave, and
1030
+ // "Deploy & run" deploys what the server holds for the process application,
1031
+ // not what is on screen. The nightly trace shows the PATCH that stored the
1032
+ // renamed process id being acknowledged 61ms before the deploy POST, so the
1033
+ // still-stale file was deployed and the follow-up instance creation was
1034
+ // rejected with "Command 'CREATE' rejected with code 'NOT_FOUND': Expected
1035
+ // to find process definition with process ID '<id>', but none found". The
1036
+ // fixed ten-second wait this replaces raced that debounce, which is itself
1037
+ // about ten seconds. Wait for the modeler's own autosave indicator to report
1038
+ // a save that started after the last edit instead.
1039
+ async waitForAutosaveAfterEdits(timeout = 60000) {
1040
+ const readIndicator = () => this.autosaveIndicator.textContent({ timeout: 10000 }).catch(() => null);
1041
+ const beforeEdits = await readIndicator();
1042
+ try {
1043
+ await test_1.expect
1044
+ .poll(readIndicator, { timeout, intervals: [1000] })
1045
+ .not.toBe(beforeEdits);
1046
+ }
1047
+ catch {
1048
+ // The window between the last edit and the baseline read is a few
1049
+ // milliseconds, so the only way the label does not move within a wait
1050
+ // this much longer than the debounce is that the save had already landed
1051
+ // when the baseline was taken -- the edits are persisted either way.
1052
+ console.warn('Autosave indicator did not change after the diagram edits; ' +
1053
+ 'treating the edits as already persisted.');
1054
+ }
1055
+ }
1001
1056
  async fillStartFormName(name) {
1002
1057
  await this.payloadNameInput.fill(name);
1003
1058
  }
@@ -302,7 +302,10 @@ async function modelDiagramFromFile(page, modelerHomePage, modelerCreatePage, pr
302
302
  await modelerCreatePage.clickCanvas();
303
303
  }
304
304
  }
305
- await (0, sleep_1.sleep)(10000);
305
+ // "Deploy & run" deploys the copy of the diagram the server holds, so the
306
+ // rename above has to be persisted first. A fixed ten-second sleep raced the
307
+ // modeler's own ten-second autosave debounce; wait for the save instead.
308
+ await modelerCreatePage.waitForAutosaveAfterEdits();
306
309
  }
307
310
  exports.modelDiagramFromFile = modelDiagramFromFile;
308
311
  async function disableRBA(clusterName, homePage, clusterPage, clusterDetailsPage, appsPage) {
@@ -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
@@ -29,6 +29,7 @@ declare class AppsPage {
29
29
  private doClickClusterInOptimize;
30
30
  clickOperate(clusterName: string): Promise<void>;
31
31
  clickOptimize(clusterName: string): Promise<void>;
32
+ openOptimizeWithRetry(banner: Locator, clusterName: string, timeout?: number, maxRetries?: number, totalTimeout?: number): Promise<void>;
32
33
  clickCamundaApps(maxRetries?: number): Promise<void>;
33
34
  private dismissBlockingOverlays;
34
35
  clickConsoleLink(): Promise<void>;
@@ -285,6 +285,49 @@ class AppsPage {
285
285
  }
286
286
  throw new Error(`Failed to click the optimize link after ${maxRetries} attempts.`);
287
287
  }
288
+ // `clickOptimize` returns once it has clicked, not once Optimize has
289
+ // rendered, so a hand-off that lands somewhere else (an SSO bounce to
290
+ // `/login`, or `/login?error`) is reported as success. Retrying the click
291
+ // from there does not help: `clickCamundaApps` below exits its loop without
292
+ // throwing when none of the app-switcher buttons is visible, which is exactly
293
+ // the state a dead page is in, so a caller that re-clicks just re-probes the
294
+ // same page until its budget runs out. Going back to `/` first is the only
295
+ // thing that recovers, which is what this wrapper adds.
296
+ // `totalTimeout` is what makes the stated budget true. Retrying on a thrown
297
+ // hand-off (rather than escaping on the first throw) is correct, but it also
298
+ // multiplies the cost: a worst-case attempt here is clickCamundaApps (~188s
299
+ // when it has to hunt for a trigger) + clickOptimize (~456s across its own
300
+ // three retries) + the banner wait, so four attempts could reach ~51 min and
301
+ // blow the 36-minute test.slow() budget of the specs that call this. Checked
302
+ // between attempts, so the real ceiling is the deadline plus at most one
303
+ // attempt -- bounded, which "N x timeout" arithmetic alone never was.
304
+ async openOptimizeWithRetry(banner, clusterName, timeout = 60000, maxRetries = 3, totalTimeout = 600000) {
305
+ const start = Date.now();
306
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
307
+ try {
308
+ // Inside the try, not before it: `clickOptimize` throws after its own
309
+ // retries, and `clickCamundaApps` can throw on a click timeout. With
310
+ // those calls outside, the first such throw escaped this helper before
311
+ // it ever reached the `page.goto('/')` below -- so in exactly the dead
312
+ // page case this wrapper exists for, the recovery was skipped and the
313
+ // remaining attempts never ran.
314
+ await this.clickCamundaApps();
315
+ await this.clickOptimize(clusterName);
316
+ await (0, test_1.expect)(banner).toBeVisible({ timeout });
317
+ return;
318
+ }
319
+ catch (error) {
320
+ const elapsed = Date.now() - start;
321
+ if (attempt === maxRetries - 1 || elapsed >= totalTimeout) {
322
+ throw error;
323
+ }
324
+ console.warn(`Optimize hand-off failed on attempt ${attempt + 1} ` +
325
+ `(${Math.round(elapsed / 1000)}s elapsed); re-entering through ` +
326
+ `the apps menu. ${error}`);
327
+ await this.page.goto('/', { waitUntil: 'domcontentloaded' });
328
+ }
329
+ }
330
+ }
288
331
  async clickCamundaApps(maxRetries = 3) {
289
332
  await (0, sleep_1.sleep)(2000);
290
333
  await this.dismissBlockingOverlays();
@@ -28,10 +28,13 @@ declare class ModelerHomePage {
28
28
  readonly createIdpApplicationButton: Locator;
29
29
  readonly dialog: Locator;
30
30
  readonly closeButton: Locator;
31
+ readonly blockingDialog: Locator;
32
+ readonly blockingDialogDismiss: Locator;
31
33
  readonly optionsButton: Locator;
32
34
  readonly formNameBreadcrumb: (formName: string) => Locator;
33
35
  constructor(page: Page);
34
36
  dismissBlockingDialog(): Promise<void>;
37
+ private dismissUncataloguedDialog;
35
38
  dismissOverlays(): Promise<void>;
36
39
  clickCreateNewProjectButton(): Promise<void>;
37
40
  enterNewProjectName(name: string): Promise<void>;
@@ -45,6 +45,8 @@ class ModelerHomePage {
45
45
  createIdpApplicationButton;
46
46
  dialog;
47
47
  closeButton;
48
+ blockingDialog;
49
+ blockingDialogDismiss;
48
50
  optionsButton;
49
51
  formNameBreadcrumb;
50
52
  constructor(page) {
@@ -122,6 +124,26 @@ class ModelerHomePage {
122
124
  this.closeButton = page
123
125
  .locator('button:visible', { hasText: /^Got it/ })
124
126
  .first();
127
+ // Not every modal hub stacks over the projects list carries a "Got it"
128
+ // control. When one does not, the `closeButton` probe finds nothing and
129
+ // `dismissBlockingDialog` returns having dismissed *nothing*, so the caller
130
+ // clicks straight into an overlay that is still there. Observed on this
131
+ // version in run 35102229469, which lost `RBA On User Flow - Permission for
132
+ // All Processes with Full Permissions` and `- Permission for Selected
133
+ // Processes Only` to exactly that, with 9 "New project" click failures in
134
+ // the log.
135
+ //
136
+ // Anchored on the visible dialog so an announcement nobody has catalogued
137
+ // yet is still cleared, rather than on one more label. `:visible` matters --
138
+ // the Modeler home page keeps several `role="dialog"` containers mounted
139
+ // and hidden, which is why the label-first lookup above exists at all.
140
+ this.blockingDialog = page.locator('[role="dialog"]:visible').first();
141
+ // Only unambiguous dismissals. Deliberately NOT "Cancel": that is a form
142
+ // action on the dialogs specs open on purpose (cluster select, deploy), and
143
+ // clicking it would break a working test rather than unblock a stuck one.
144
+ this.blockingDialogDismiss = this.blockingDialog
145
+ .getByRole('button', { name: /^(Got it|Close|Dismiss)/i })
146
+ .first();
125
147
  this.optionsButton = page.getByRole('button', { name: 'Options' });
126
148
  this.formNameBreadcrumb = (formName) => page.locator('button[data-test="breadcrumb-form"]', {
127
149
  hasText: formName,
@@ -143,17 +165,41 @@ class ModelerHomePage {
143
165
  .waitForLoadState('networkidle', { timeout: 15000 })
144
166
  .catch(() => false);
145
167
  for (let attempt = 0; attempt < 3; attempt++) {
146
- const isOpen = await this.closeButton
147
- .waitFor({ state: 'visible', timeout: 10000 })
148
- .then(() => true)
149
- .catch(() => false);
168
+ const isOpen = await this.becameVisible(this.closeButton, 10000);
169
+ // `break`, not `return`: a modal without a "Got it" control is exactly
170
+ // the case the generic pass below exists for, and returning here is what
171
+ // let it through.
150
172
  if (!isOpen)
151
- return;
173
+ break;
152
174
  await this.closeButton.click({ timeout: 5000 }).catch(() => { });
153
175
  await this.closeButton
154
176
  .waitFor({ state: 'hidden', timeout: 5000 })
155
177
  .catch(() => false);
156
178
  }
179
+ await this.dismissUncataloguedDialog();
180
+ }
181
+ // Second pass for a modal the label-based probe above cannot see. Kept cheap
182
+ // because `dismissBlockingDialog` is called from a dozen places on the happy
183
+ // path: when nothing is open this costs one 2s probe.
184
+ async dismissUncataloguedDialog() {
185
+ for (let attempt = 0; attempt < 2; attempt++) {
186
+ if (!(await this.becameVisible(this.blockingDialog, 2000)))
187
+ return;
188
+ if (!(await this.becameVisible(this.blockingDialogDismiss, 2000))) {
189
+ // Nothing safe to click. Leave it rather than guess: pressing Escape
190
+ // blindly does not close a Carbon dialog (focus sits on the body after
191
+ // a load), and clicking an unrecognised footer button could confirm
192
+ // something. The caller's own assertion then reports the real blocker.
193
+ return;
194
+ }
195
+ await this.blockingDialogDismiss.click({ timeout: 5000 }).catch(() => { });
196
+ const closed = await this.blockingDialog
197
+ .waitFor({ state: 'hidden', timeout: 5000 })
198
+ .then(() => true)
199
+ .catch(() => false);
200
+ if (closed)
201
+ return;
202
+ }
157
203
  }
158
204
  async dismissOverlays() {
159
205
  // Before the generic pass: the loop below closes whichever overlay it finds
@@ -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
@@ -154,7 +154,32 @@ _8_10_1.test.describe('User Roles User Flow', () => {
154
154
  username: process.env.C8_USERNAME_TEST_9,
155
155
  password: process.env.C8_PASSWORD_TEST_9,
156
156
  });
157
- await (0, sleep_1.sleep)(600000); //wait for Optimize cache to refresh
157
+ });
158
+ await _8_10_1.test.step('Wait for the role change to reach Optimize', async () => {
159
+ // Replaces a blind `sleep(600000)`. The role change only takes effect in
160
+ // Optimize once its permission cache refreshes, and reaching Optimize is
161
+ // the single thing the rest of this test depends on -- so retry the
162
+ // hand-off until it succeeds rather than waiting out a fixed ten minutes.
163
+ // Sized against the helper's REAL per-attempt cost, not just the banner
164
+ // timeout: each attempt also pays clickCamundaApps() and clickOptimize()
165
+ // before the banner is ever asserted. Driving the real method under a
166
+ // virtual clock, (40000, 15) -- which looks like "15 x 40s = 600s" --
167
+ // actually runs 986s in the ordinary bad case and 4581s when
168
+ // clickCamundaApps has to hunt for its trigger, the latter alone
169
+ // exceeding this test's 36-minute test.slow() budget. 4 x 120s measures
170
+ // at 581s, which is the honest equivalent of the 600s sleep, and four
171
+ // attempts is ample for the page.goto('/') recovery below (the helper's
172
+ // own default is 3). It still rethrows once exhausted, so a cache that
173
+ // never refreshes fails here rather than further downstream.
174
+ //
175
+ // openOptimizeWithRetry rather than a hand-rolled poll because it carries
176
+ // the recovery this needs: a failed hand-off can leave the page on
177
+ // /login?error, where clickCamundaApps() silently returns (it exits its
178
+ // loop without throwing when no app-switcher button is visible), so
179
+ // re-clicking would retry the same dead page until the budget ran out.
180
+ // openOptimizeWithRetry does page.goto('/') between attempts, which is
181
+ // the only thing that gets back to a usable state.
182
+ await appsPage.openOptimizeWithRetry(optimizeHomePage.optimizeBanner, clusterName, 120000, 4);
158
183
  });
159
184
  await _8_10_1.test.step('Navigate to Console and Ensure Apps are Available in App Switcher', async () => {
160
185
  await appsPage.clickCamundaApps();
@@ -245,7 +270,32 @@ _8_10_1.test.describe('User Roles User Flow', () => {
245
270
  username: process.env.C8_USERNAME_TEST_10,
246
271
  password: process.env.C8_PASSWORD_TEST_10,
247
272
  });
248
- await (0, sleep_1.sleep)(600000); //wait for Optimize cache to refresh
273
+ });
274
+ await _8_10_1.test.step('Wait for the role change to reach Optimize', async () => {
275
+ // Replaces a blind `sleep(600000)`. The role change only takes effect in
276
+ // Optimize once its permission cache refreshes, and reaching Optimize is
277
+ // the single thing the rest of this test depends on -- so retry the
278
+ // hand-off until it succeeds rather than waiting out a fixed ten minutes.
279
+ // Sized against the helper's REAL per-attempt cost, not just the banner
280
+ // timeout: each attempt also pays clickCamundaApps() and clickOptimize()
281
+ // before the banner is ever asserted. Driving the real method under a
282
+ // virtual clock, (40000, 15) -- which looks like "15 x 40s = 600s" --
283
+ // actually runs 986s in the ordinary bad case and 4581s when
284
+ // clickCamundaApps has to hunt for its trigger, the latter alone
285
+ // exceeding this test's 36-minute test.slow() budget. 4 x 120s measures
286
+ // at 581s, which is the honest equivalent of the 600s sleep, and four
287
+ // attempts is ample for the page.goto('/') recovery below (the helper's
288
+ // own default is 3). It still rethrows once exhausted, so a cache that
289
+ // never refreshes fails here rather than further downstream.
290
+ //
291
+ // openOptimizeWithRetry rather than a hand-rolled poll because it carries
292
+ // the recovery this needs: a failed hand-off can leave the page on
293
+ // /login?error, where clickCamundaApps() silently returns (it exits its
294
+ // loop without throwing when no app-switcher button is visible), so
295
+ // re-clicking would retry the same dead page until the budget ran out.
296
+ // openOptimizeWithRetry does page.goto('/') between attempts, which is
297
+ // the only thing that gets back to a usable state.
298
+ await appsPage.openOptimizeWithRetry(optimizeHomePage.optimizeBanner, clusterName, 120000, 4);
249
299
  });
250
300
  await _8_10_1.test.step('Navigate to Console and Ensure Apps are Available in App Switcher', async () => {
251
301
  await appsPage.clickCamundaApps();
@@ -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();
@@ -154,7 +154,32 @@ _8_9_1.test.describe('User Roles User Flow', () => {
154
154
  username: process.env.C8_USERNAME_TEST_9,
155
155
  password: process.env.C8_PASSWORD_TEST_9,
156
156
  });
157
- await (0, sleep_1.sleep)(600000); //wait for Optimize cache to refresh
157
+ });
158
+ await _8_9_1.test.step('Wait for the role change to reach Optimize', async () => {
159
+ // Replaces a blind `sleep(600000)`. The role change only takes effect in
160
+ // Optimize once its permission cache refreshes, and reaching Optimize is
161
+ // the single thing the rest of this test depends on -- so retry the
162
+ // hand-off until it succeeds rather than waiting out a fixed ten minutes.
163
+ // Sized against the helper's REAL per-attempt cost, not just the banner
164
+ // timeout: each attempt also pays clickCamundaApps() and clickOptimize()
165
+ // before the banner is ever asserted. Driving the real method under a
166
+ // virtual clock, (40000, 15) -- which looks like "15 x 40s = 600s" --
167
+ // actually runs 986s in the ordinary bad case and 4581s when
168
+ // clickCamundaApps has to hunt for its trigger, the latter alone
169
+ // exceeding this test's 36-minute test.slow() budget. 4 x 120s measures
170
+ // at 581s, which is the honest equivalent of the 600s sleep, and four
171
+ // attempts is ample for the page.goto('/') recovery below (the helper's
172
+ // own default is 3). It still rethrows once exhausted, so a cache that
173
+ // never refreshes fails here rather than further downstream.
174
+ //
175
+ // openOptimizeWithRetry rather than a hand-rolled poll because it carries
176
+ // the recovery this needs: a failed hand-off can leave the page on
177
+ // /login?error, where clickCamundaApps() silently returns (it exits its
178
+ // loop without throwing when no app-switcher button is visible), so
179
+ // re-clicking would retry the same dead page until the budget ran out.
180
+ // openOptimizeWithRetry does page.goto('/') between attempts, which is
181
+ // the only thing that gets back to a usable state.
182
+ await appsPage.openOptimizeWithRetry(optimizeHomePage.optimizeBanner, clusterName, 120000, 4);
158
183
  });
159
184
  await _8_9_1.test.step('Navigate to Console and Ensure Apps are Available in App Switcher', async () => {
160
185
  await appsPage.clickCamundaApps();
@@ -245,7 +270,32 @@ _8_9_1.test.describe('User Roles User Flow', () => {
245
270
  username: process.env.C8_USERNAME_TEST_10,
246
271
  password: process.env.C8_PASSWORD_TEST_10,
247
272
  });
248
- await (0, sleep_1.sleep)(600000); //wait for Optimize cache to refresh
273
+ });
274
+ await _8_9_1.test.step('Wait for the role change to reach Optimize', async () => {
275
+ // Replaces a blind `sleep(600000)`. The role change only takes effect in
276
+ // Optimize once its permission cache refreshes, and reaching Optimize is
277
+ // the single thing the rest of this test depends on -- so retry the
278
+ // hand-off until it succeeds rather than waiting out a fixed ten minutes.
279
+ // Sized against the helper's REAL per-attempt cost, not just the banner
280
+ // timeout: each attempt also pays clickCamundaApps() and clickOptimize()
281
+ // before the banner is ever asserted. Driving the real method under a
282
+ // virtual clock, (40000, 15) -- which looks like "15 x 40s = 600s" --
283
+ // actually runs 986s in the ordinary bad case and 4581s when
284
+ // clickCamundaApps has to hunt for its trigger, the latter alone
285
+ // exceeding this test's 36-minute test.slow() budget. 4 x 120s measures
286
+ // at 581s, which is the honest equivalent of the 600s sleep, and four
287
+ // attempts is ample for the page.goto('/') recovery below (the helper's
288
+ // own default is 3). It still rethrows once exhausted, so a cache that
289
+ // never refreshes fails here rather than further downstream.
290
+ //
291
+ // openOptimizeWithRetry rather than a hand-rolled poll because it carries
292
+ // the recovery this needs: a failed hand-off can leave the page on
293
+ // /login?error, where clickCamundaApps() silently returns (it exits its
294
+ // loop without throwing when no app-switcher button is visible), so
295
+ // re-clicking would retry the same dead page until the budget ran out.
296
+ // openOptimizeWithRetry does page.goto('/') between attempts, which is
297
+ // the only thing that gets back to a usable state.
298
+ await appsPage.openOptimizeWithRetry(optimizeHomePage.optimizeBanner, clusterName, 120000, 4);
249
299
  });
250
300
  await _8_9_1.test.step('Navigate to Console and Ensure Apps are Available in App Switcher', async () => {
251
301
  await appsPage.clickCamundaApps();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camunda/e2e-test-suite",
3
- "version": "0.0.1181",
3
+ "version": "0.0.1183",
4
4
  "description": "End-to-end test helpers for Camunda 8",
5
5
  "repository": {
6
6
  "type": "git",