@camunda/e2e-test-suite 0.0.1231 → 0.0.1232

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.
@@ -4,6 +4,7 @@ exports.FormJsPage = void 0;
4
4
  const test_1 = require("@playwright/test");
5
5
  const sleep_1 = require("../../utils/sleep");
6
6
  const ProcessApplicationPage_1 = require("./ProcessApplicationPage");
7
+ const sessionRecovery_1 = require("../../utils/sessionRecovery");
7
8
  class FormJsPage {
8
9
  page;
9
10
  processApplicationPage;
@@ -171,6 +172,10 @@ class FormJsPage {
171
172
  });
172
173
  }
173
174
  async clickDeployButton() {
175
+ // ensureStageCluster navigates away and back before every deploy, so the
176
+ // action bar this button lives in may belong to a page that never
177
+ // rendered. Recover before spending the click budget on it.
178
+ await (0, sessionRecovery_1.recoverStuckModelerLogin)(this.page);
174
179
  await this.deployButton.click({ timeout: 60000 });
175
180
  }
176
181
  async clickDeploySubButton() {
@@ -6,6 +6,7 @@ const sleep_1 = require("../../utils/sleep");
6
6
  const clickLocatorWithRetry_1 = require("../../utils/assertionHelpers/clickLocatorWithRetry");
7
7
  const ConnectorMarketplacePage_1 = require("./ConnectorMarketplacePage");
8
8
  const ProcessApplicationPage_1 = require("./ProcessApplicationPage");
9
+ const sessionRecovery_1 = require("../../utils/sessionRecovery");
9
10
  class ModelerCreatePage {
10
11
  page;
11
12
  processApplicationPage;
@@ -583,6 +584,12 @@ class ModelerCreatePage {
583
584
  await (0, test_1.expect)(this.viewProcessLink).toBeHidden({ timeout: 30000 });
584
585
  }
585
586
  async clickStartInstanceMainButton() {
587
+ // ensureStageCluster runs immediately before this on every runProcessInstance
588
+ // attempt and navigates twice, so the diagram's action bar may belong to a
589
+ // page Modeler never rendered. Without this, each of the 10 retries burned
590
+ // 60s of probes here and the run surfaced as "View process instance" never
591
+ // appearing -- which reads as a failed deploy rather than a dead page.
592
+ await (0, sessionRecovery_1.recoverStuckModelerLogin)(this.page);
586
593
  if (await this.startInstanceMainButton.isVisible({ timeout: 30000 })) {
587
594
  await this.startInstanceMainButton.click({ timeout: 30000 });
588
595
  }
@@ -754,6 +761,11 @@ class ModelerCreatePage {
754
761
  await this.nameInput.fill(name, { timeout: 60000 });
755
762
  }
756
763
  async clickDiagramBreadcrumb() {
764
+ // Reached straight after opening a diagram, so the navigation that got us
765
+ // here is the one Modeler can answer with its hung /login. The breadcrumb
766
+ // never renders there, and at 10s this was the first thing to notice --
767
+ // as "breadcrumb not found", which says nothing about the real cause.
768
+ await (0, sessionRecovery_1.recoverStuckModelerLogin)(this.page);
757
769
  await this.diagramBreadcrumb.click({ timeout: 10000 });
758
770
  }
759
771
  // Opening the breadcrumb menu leaves the pointer on the breadcrumb button,
@@ -1631,6 +1643,7 @@ class ModelerCreatePage {
1631
1643
  }
1632
1644
  async fillStartEventVariablesForDocHandling(zeebeUrl, zeebeClientId, zeebeClientSecret) {
1633
1645
  console.log(`URL: ${zeebeUrl}, ClientId: ${zeebeClientId}, Secret: ${zeebeClientSecret}`);
1646
+ await (0, sessionRecovery_1.recoverStuckModelerLogin)(this.page);
1634
1647
  await (0, test_1.expect)(this.connectorsDocHandlingStartEventElement).toBeVisible({
1635
1648
  timeout: 60000,
1636
1649
  });
@@ -282,11 +282,27 @@ class ModelerHomePage {
282
282
  // lands mid-outage used to lose the rest of its budget), and when the backend
283
283
  // stays down the wait is spent on the thing the test is actually waiting for.
284
284
  async recoverModelerSession() {
285
- if (!(await (0, sessionRecovery_1.restoreSessionIfLoggedOut)(this.page))) {
285
+ // Two distinct ways Modeler loses its session, and only one of them shows
286
+ // the identity provider. When POST /api/internal/login 5xx's during the
287
+ // SPA's own sign-in it never logs out at all -- it parks on Modeler's
288
+ // /login route behind a spinner that nothing retries -- so the
289
+ // credentials path below cannot see it. Un-stick that first.
290
+ const unstuck = await (0, sessionRecovery_1.recoverStuckModelerLogin)(this.page);
291
+ if (!(await (0, sessionRecovery_1.restoreSessionIfLoggedOut)(this.page)) && !unstuck) {
286
292
  this.consecutiveSessionRecoveries = 0;
287
293
  return false;
288
294
  }
289
- const shellVisible = await this.becameVisible(this.modelerPageBanner, 120000);
295
+ // Counted, not just reset: sessionDropError reports this number, and
296
+ // without the increment the counter read 0 forever, so the `> 0` guard at
297
+ // every call site was unreachable and the dropped-session diagnosis it
298
+ // exists to surface never once printed.
299
+ this.consecutiveSessionRecoveries++;
300
+ // The banner alone does not prove the session came back: Modeler renders
301
+ // its whole app shell, banner included, over the spinner on the hung
302
+ // /login route. Requiring the browser to have LEFT that route is what
303
+ // makes "recovered" mean recovered.
304
+ const shellVisible = !(0, sessionRecovery_1.isAtModelerLoginRoute)(this.page) &&
305
+ (await this.becameVisible(this.modelerPageBanner, 120000));
290
306
  if (shellVisible) {
291
307
  this.consecutiveSessionRecoveries = 0;
292
308
  }
@@ -386,6 +402,10 @@ class ModelerHomePage {
386
402
  // Reload so the check reflects the PERSISTED server state, not the
387
403
  // optimistic client render that can silently revert to "New project".
388
404
  await this.page.reload();
405
+ // A reload is also how the browser ends up on Modeler's hung /login, and
406
+ // the committed name can never render there -- which would read as "the
407
+ // rename did not persist" rather than "the page never loaded".
408
+ await (0, sessionRecovery_1.recoverStuckModelerLogin)(this.page);
389
409
  await (0, test_1.expect)(committedProjectName.first()).toBeVisible({ timeout: 15000 });
390
410
  }).toPass({ timeout: 120000 });
391
411
  }
@@ -413,6 +433,7 @@ class ModelerHomePage {
413
433
  // resource is one level down, so re-enter the container first.
414
434
  async clickProcessDiagram(name) {
415
435
  await this.ensureInResourceContainer();
436
+ await (0, sessionRecovery_1.recoverStuckModelerLogin)(this.page);
416
437
  const process = this.rows.filter({ hasText: name }).getByTitle(name).first();
417
438
  await (0, test_1.expect)(process).toBeVisible({ timeout: 90000 });
418
439
  await process.click();
@@ -426,6 +447,7 @@ class ModelerHomePage {
426
447
  const dropdown = this.processApplicationPage.createNewDropdown;
427
448
  for (let attempt = 0; attempt < 4; attempt++) {
428
449
  try {
450
+ await (0, sessionRecovery_1.recoverStuckModelerLogin)(this.page);
429
451
  await this.dismissOverlays();
430
452
  await (0, test_1.expect)(dropdown).toBeVisible({
431
453
  timeout: 45000,
@@ -452,6 +474,7 @@ class ModelerHomePage {
452
474
  async clickWorkspaceDiagramTypeDropdown() {
453
475
  for (let attempt = 0; attempt < 4; attempt++) {
454
476
  try {
477
+ await (0, sessionRecovery_1.recoverStuckModelerLogin)(this.page);
455
478
  await this.dismissOverlays();
456
479
  await (0, test_1.expect)(this.diagramTypeDropdown).toBeVisible({
457
480
  timeout: 45000,
@@ -483,7 +506,21 @@ class ModelerHomePage {
483
506
  if (await this.becameVisible(this.diagramPropertiesPanel, 1000)) {
484
507
  return;
485
508
  }
486
- if (!(await this.becameVisible(this.bpmnTemplateOption, 5000))) {
509
+ await (0, sessionRecovery_1.recoverStuckModelerLogin)(this.page);
510
+ const menuOpen = await this.becameVisible(this.bpmnTemplateOption, 5000);
511
+ // Hub disables this item while its own create-file request is in flight
512
+ // (`disabled={creatingType === BPMN}` in CreateActionDs). A request that
513
+ // never answers leaves it disabled for good -- yet still visible, so the
514
+ // probe above passed and the click below then spent its whole 30s on
515
+ // "element is not enabled" on every single attempt until the test timed
516
+ // out. Only a reload clears that React state; the menu is closed by it,
517
+ // which is why clickDiagramTypeDropdown has to reopen it.
518
+ const stuckDisabled = menuOpen &&
519
+ !(await this.bpmnTemplateOption.isEnabled().catch(() => false));
520
+ if (stuckDisabled) {
521
+ await this.page.reload({ waitUntil: 'domcontentloaded' });
522
+ }
523
+ if (!menuOpen || stuckDisabled) {
487
524
  await this.clickDiagramTypeDropdown();
488
525
  }
489
526
  // 30s rather than the old 120s: the option is proven visible above, so a
@@ -493,9 +530,15 @@ class ModelerHomePage {
493
530
  }).toPass({ timeout: 240000, intervals: [1000, 2000, 5000] });
494
531
  }
495
532
  async clickFormOption() {
533
+ // The create menu only exists on a rendered container page. When the
534
+ // navigation that got us here bounced to Modeler's hung /login, this
535
+ // option is not merely late -- it is on a page that never renders -- so
536
+ // recover before spending the click budget looking for it.
537
+ await (0, sessionRecovery_1.recoverStuckModelerLogin)(this.page);
496
538
  await this.formTemplateOption.click();
497
539
  }
498
540
  async enterFormName(name) {
541
+ await (0, sessionRecovery_1.recoverStuckModelerLogin)(this.page);
499
542
  await (0, test_1.expect)(this.formNameInput).toBeVisible({ timeout: 120000 });
500
543
  await this.formNameInput.click({ timeout: 60000, force: true });
501
544
  await this.formNameInput.fill(name, { timeout: 60000 });
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ProcessApplicationPage = void 0;
4
4
  const test_1 = require("@playwright/test");
5
5
  const submitModalWithRetry_1 = require("../../utils/assertionHelpers/submitModalWithRetry");
6
+ const sessionRecovery_1 = require("../../utils/sessionRecovery");
6
7
  // Which process application holds the current test's resources, and which
7
8
  // cluster its development stage currently points at. Keyed by the Playwright
8
9
  // Page because the object that creates the container (ModelerHomePage) and the
@@ -92,6 +93,12 @@ class ProcessApplicationPage {
92
93
  return this.folderExplorer.or(this.breadcrumb).first();
93
94
  }
94
95
  async waitForProcessApplication(timeout = 90000) {
96
+ // Every route into this page is a navigation, and a navigation is exactly
97
+ // when Modeler can bounce the browser onto its own hung /login. Neither
98
+ // the folder explorer nor the breadcrumb can ever render there, so waiting
99
+ // the full timeout out first would only delay the recovery that has to
100
+ // happen anyway. No-ops when the page is where it should be.
101
+ await (0, sessionRecovery_1.recoverStuckModelerLogin)(this.page);
95
102
  await (0, test_1.expect)(this.pageReady()).toBeVisible({ timeout });
96
103
  }
97
104
  async clickCreateProcessApplicationButton() {
@@ -208,11 +215,16 @@ class ProcessApplicationPage {
208
215
  await this.page.goto(`${paUrl}/connected-clusters`, {
209
216
  waitUntil: 'domcontentloaded',
210
217
  });
218
+ await (0, sessionRecovery_1.recoverStuckModelerLogin)(this.page);
211
219
  await this.openStageDefinition();
212
220
  await this.defineDevelopmentStage(clusterName);
213
221
  }).toPass({ timeout: 240000, intervals: [1000, 2000, 5000] });
214
222
  container.developmentCluster = clusterName;
215
223
  await this.page.goto(returnTo, { waitUntil: 'domcontentloaded' });
224
+ // Hand the caller back a page it can act on: this navigation can bounce to
225
+ // the hung /login just as easily as the one above, and the caller goes
226
+ // straight into a deploy dialog that would never open there.
227
+ await (0, sessionRecovery_1.recoverStuckModelerLogin)(this.page);
216
228
  return true;
217
229
  }
218
230
  // The /connected-clusters deep link opens the Define stages dialog by itself
@@ -31,3 +31,43 @@ export declare function isAtIdentityProvider(page: Page): boolean;
31
31
  * needed here.
32
32
  */
33
33
  export declare function restoreSessionIfLoggedOut(page: Page): Promise<boolean>;
34
+ /**
35
+ * Whether the browser is parked on Web Modeler's OWN `/login` route, as
36
+ * opposed to the identity provider's login form.
37
+ *
38
+ * Two different failure states that look alike in a screenshot and need
39
+ * opposite recoveries, so they are kept apart deliberately. The identity
40
+ * provider is a separate HOST (`weblogin.`/`login.`) and wants credentials
41
+ * typed into it -- that is {@link restoreSessionIfLoggedOut}. This one is a
42
+ * PATH on the application's own host, and wants a re-navigation.
43
+ */
44
+ export declare function isAtModelerLoginRoute(page: Page): boolean;
45
+ /**
46
+ * Un-sticks Web Modeler when its own `/login` route has hung, and reports
47
+ * whether it had to.
48
+ *
49
+ * Modeler establishes its session by POSTing `/api/internal/login` from
50
+ * `prepareSession()`. When that call 5xx's the promise rejects and nothing
51
+ * catches it: `history.push(returnTo)` never runs, the app never becomes
52
+ * ready, and the SPA sits on `/login?returnUrl=...` rendering its shell over
53
+ * an infinite spinner. There is no client-side retry and no error state, so
54
+ * the page cannot recover on its own -- every Modeler locator the test waits
55
+ * for simply never appears, and the test spends its entire budget on a page
56
+ * that will never render. The 2026-09-22 nightly lost 22 tests across nine
57
+ * specs to one intermittent backend this way, each reported as an unrelated
58
+ * "locator not found".
59
+ *
60
+ * Reloading re-runs `prepareSession()`, so it re-issues the POST; when that
61
+ * one succeeds the SPA pushes the `returnUrl` it kept and the browser lands on
62
+ * the page the caller was navigating to. Retried because the backend fails
63
+ * intermittently rather than continuously -- on that nightly roughly half the
64
+ * calls answered 200, so a few spaced attempts are what get through. The
65
+ * spacing is the `waitForURL` budget itself rather than a sleep: it returns
66
+ * the moment the session is accepted, and otherwise spends the wait backing
67
+ * off.
68
+ *
69
+ * Cheap to call speculatively -- a page that is not on `/login` costs one URL
70
+ * comparison and no DOM access, which is what lets the Modeler page objects
71
+ * guard their navigations with it.
72
+ */
73
+ export declare function recoverStuckModelerLogin(page: Page, attempts?: number, settleTimeout?: number): Promise<boolean>;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.restoreSessionIfLoggedOut = exports.isAtIdentityProvider = exports.trackSignedInUser = void 0;
3
+ exports.recoverStuckModelerLogin = exports.isAtModelerLoginRoute = exports.restoreSessionIfLoggedOut = exports.isAtIdentityProvider = exports.trackSignedInUser = void 0;
4
4
  // Only the username is kept -- no password, no cookie, no token. Keyed by the
5
5
  // Playwright Page because the object that logs in (LoginPage) and the objects
6
6
  // that later have to recover a dropped session (ModelerHomePage) are separate
@@ -142,3 +142,85 @@ async function restoreSessionIfLoggedOut(page) {
142
142
  return true;
143
143
  }
144
144
  exports.restoreSessionIfLoggedOut = restoreSessionIfLoggedOut;
145
+ const LOGIN_ROUTE = /\/login\/?$/;
146
+ /**
147
+ * Whether the browser is parked on Web Modeler's OWN `/login` route, as
148
+ * opposed to the identity provider's login form.
149
+ *
150
+ * Two different failure states that look alike in a screenshot and need
151
+ * opposite recoveries, so they are kept apart deliberately. The identity
152
+ * provider is a separate HOST (`weblogin.`/`login.`) and wants credentials
153
+ * typed into it -- that is {@link restoreSessionIfLoggedOut}. This one is a
154
+ * PATH on the application's own host, and wants a re-navigation.
155
+ */
156
+ function isAtModelerLoginRoute(page) {
157
+ if (isAtIdentityProvider(page)) {
158
+ return false;
159
+ }
160
+ try {
161
+ return LOGIN_ROUTE.test(new URL(page.url()).pathname);
162
+ }
163
+ catch {
164
+ return false;
165
+ }
166
+ }
167
+ exports.isAtModelerLoginRoute = isAtModelerLoginRoute;
168
+ /**
169
+ * Un-sticks Web Modeler when its own `/login` route has hung, and reports
170
+ * whether it had to.
171
+ *
172
+ * Modeler establishes its session by POSTing `/api/internal/login` from
173
+ * `prepareSession()`. When that call 5xx's the promise rejects and nothing
174
+ * catches it: `history.push(returnTo)` never runs, the app never becomes
175
+ * ready, and the SPA sits on `/login?returnUrl=...` rendering its shell over
176
+ * an infinite spinner. There is no client-side retry and no error state, so
177
+ * the page cannot recover on its own -- every Modeler locator the test waits
178
+ * for simply never appears, and the test spends its entire budget on a page
179
+ * that will never render. The 2026-09-22 nightly lost 22 tests across nine
180
+ * specs to one intermittent backend this way, each reported as an unrelated
181
+ * "locator not found".
182
+ *
183
+ * Reloading re-runs `prepareSession()`, so it re-issues the POST; when that
184
+ * one succeeds the SPA pushes the `returnUrl` it kept and the browser lands on
185
+ * the page the caller was navigating to. Retried because the backend fails
186
+ * intermittently rather than continuously -- on that nightly roughly half the
187
+ * calls answered 200, so a few spaced attempts are what get through. The
188
+ * spacing is the `waitForURL` budget itself rather than a sleep: it returns
189
+ * the moment the session is accepted, and otherwise spends the wait backing
190
+ * off.
191
+ *
192
+ * Cheap to call speculatively -- a page that is not on `/login` costs one URL
193
+ * comparison and no DOM access, which is what lets the Modeler page objects
194
+ * guard their navigations with it.
195
+ */
196
+ async function recoverStuckModelerLogin(page, attempts = 4, settleTimeout = 25000) {
197
+ if (!isAtModelerLoginRoute(page)) {
198
+ return false;
199
+ }
200
+ for (let attempt = 1; attempt <= attempts; attempt++) {
201
+ console.warn(`recoverStuckModelerLogin: Modeler is stuck on ${page.url()} -- its ` +
202
+ `POST /api/internal/login is failing. Reload ${attempt}/${attempts}.`);
203
+ await page
204
+ .reload({ waitUntil: 'domcontentloaded', timeout: 60000 })
205
+ .catch(() => undefined);
206
+ // The reload can land on the identity provider instead -- Modeler calls
207
+ // Auth0 logout on some of these failures -- and there credentials, not
208
+ // another reload, are what gets us back.
209
+ await restoreSessionIfLoggedOut(page);
210
+ // Leaving /login is the signal that the session was accepted: the SPA only
211
+ // pushes the returnUrl once prepareSession() resolved.
212
+ const recovered = await page
213
+ .waitForURL((url) => !LOGIN_ROUTE.test(url.pathname), {
214
+ timeout: settleTimeout,
215
+ })
216
+ .then(() => true)
217
+ .catch(() => false);
218
+ if (recovered) {
219
+ return true;
220
+ }
221
+ }
222
+ console.warn('recoverStuckModelerLogin: Modeler never left /login -- its backend is ' +
223
+ 'still failing the session call.');
224
+ return true;
225
+ }
226
+ exports.recoverStuckModelerLogin = recoverStuckModelerLogin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camunda/e2e-test-suite",
3
- "version": "0.0.1231",
3
+ "version": "0.0.1232",
4
4
  "description": "End-to-end test helpers for Camunda 8",
5
5
  "repository": {
6
6
  "type": "git",