@camunda/e2e-test-suite 0.0.944 → 0.0.945

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.
Files changed (38) hide show
  1. package/dist/pages/8.10/AppsPage.d.ts +5 -0
  2. package/dist/pages/8.10/AppsPage.js +152 -0
  3. package/dist/pages/8.10/ModelerHomePage.d.ts +1 -0
  4. package/dist/pages/8.10/ModelerHomePage.js +52 -5
  5. package/dist/pages/8.10/OperateHomePage.js +23 -2
  6. package/dist/pages/8.10/OperateProcessInstancePage.d.ts +17 -3
  7. package/dist/pages/8.10/OperateProcessInstancePage.js +718 -16
  8. package/dist/pages/8.10/OperateProcessesPage.d.ts +1 -0
  9. package/dist/pages/8.10/OperateProcessesPage.js +38 -2
  10. package/dist/pages/8.10/OptimizeHomePage.d.ts +5 -0
  11. package/dist/pages/8.10/OptimizeHomePage.js +138 -0
  12. package/dist/pages/8.10/OptimizeReportPage.d.ts +1 -0
  13. package/dist/pages/8.10/OptimizeReportPage.js +25 -0
  14. package/dist/pages/8.10/TaskPanelPage.d.ts +2 -1
  15. package/dist/pages/8.10/TaskPanelPage.js +37 -9
  16. package/dist/pages/8.10/UtilitiesPage.d.ts +2 -1
  17. package/dist/pages/8.10/UtilitiesPage.js +155 -23
  18. package/dist/pages/SM-8.10/KeycloakAdminPage.js +1 -1
  19. package/dist/pages/SM-8.10/KeycloakLoginPage.d.ts +1 -0
  20. package/dist/pages/SM-8.10/KeycloakLoginPage.js +8 -1
  21. package/dist/pages/SM-8.10/LoginPage.d.ts +1 -1
  22. package/dist/pages/SM-8.10/LoginPage.js +11 -2
  23. package/dist/pages/SM-8.10/NavigationPage.d.ts +1 -0
  24. package/dist/pages/SM-8.10/NavigationPage.js +86 -8
  25. package/dist/pages/SM-8.10/OCIdentityHomePage.js +12 -2
  26. package/dist/pages/SM-8.10/OCIdentityRolesPage.d.ts +3 -0
  27. package/dist/pages/SM-8.10/OCIdentityRolesPage.js +110 -4
  28. package/dist/pages/SM-8.10/OperateHomePage.js +13 -1
  29. package/dist/pages/SM-8.10/OperateProcessesPage.d.ts +1 -0
  30. package/dist/pages/SM-8.10/OperateProcessesPage.js +37 -18
  31. package/dist/pages/SM-8.10/OptimizeReportPage.js +21 -1
  32. package/dist/pages/SM-8.10/TaskDetailsPage.js +7 -0
  33. package/dist/pages/SM-8.10/UtilitiesPage.js +15 -4
  34. package/dist/pages/SM-8.10/optimizeReportUtils.js +30 -4
  35. package/dist/tests/8.10/smoke-tests.spec.js +182 -44
  36. package/dist/utils/constants.d.ts +1 -0
  37. package/dist/utils/constants.js +4 -0
  38. package/package.json +1 -1
@@ -1,15 +1,156 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.OperateProcessInstancePage = void 0;
3
+ exports.watchOperateInstanceUrl = exports.OperateProcessInstancePage = void 0;
4
4
  const test_1 = require("@playwright/test");
5
5
  const sleep_1 = require("../../utils/sleep");
6
+ // Operate bounces a deep link to an instance it has not imported yet: when
7
+ // `GET /v2/process-instances/{key}` answers 404 it shows an
8
+ // "Instance <id> could not be found" notification and replaces the URL with
9
+ // the processes list (see `useEffect` on `error?.response?.status === 404` in
10
+ // camunda/camunda `operate/client/src/App/ProcessInstance/index.tsx`). From
11
+ // that moment on the tab sits on `/processes?active=true&incidents=true`, so
12
+ // every `page.reload()` in a retry loop reloads the LIST, and neither the
13
+ // instance header nor the "Instance History" panel can ever appear no matter
14
+ // how long we keep retrying. Remember the deep-link URL and re-navigate to it
15
+ // between attempts so each retry re-asks for the instance.
16
+ const INSTANCE_PATH = /\/processes\/[^/]+/;
17
+ // URLs the tab shows while the cluster SSO hand-off is still in flight. The
18
+ // Operate popup is opened without a cluster session, so its first navigation
19
+ // goes through that hand-off and `/<cluster>/sso-callback?...&code=...&state=...`
20
+ // commits before the app itself does.
21
+ //
22
+ // The callback is only the LAST leg. Before it the tab visits the IdP
23
+ // (`weblogin.<domain>/authorize?...&state=...`, plus its `/u/login/...` forms
24
+ // when the session has to be re-established) and the cluster proxy's own
25
+ // `/<cluster>/sso` entry point. Recognising only the callback classified every
26
+ // one of those legs as "Operate bounced us to the processes list", so the
27
+ // bounce recovery re-opened the deep link ~5s into a hand-off that was still
28
+ // at the IdP -- aborting an authorization request that was about to come back
29
+ // and leaving the cluster proxy holding a `state`/`code` pair that no longer
30
+ // matches the tab. Every subsequent round did the same, which is how a wait
31
+ // ended its whole 480000ms budget parked on
32
+ // `/<cluster>/sso-callback?uuid=...&code=...&state=...` (27 of the 29 recorded
33
+ // timeouts of this wait).
34
+ const AUTH_TRANSIT_PATH = /\/(sso|sso-callback|login|login-callback)\/?$/;
35
+ const AUTH_TRANSIT_HOST = /^(weblogin|login|auth)\./;
36
+ const IDP_FORM_PATH = /^\/(u|authorize|oauth)(\/|$)/;
37
+ // True while the tab is mid-authentication rather than on an Operate page.
38
+ // `waitForInstanceLocator` must not re-navigate over a hand-off that is still
39
+ // in flight: re-opening the deep link aborts the code-for-session exchange and
40
+ // starts a fresh one, so a hand-off that needs longer than the retry interval
41
+ // could never finish and the loop would re-trigger it until the whole budget is
42
+ // gone. It therefore gives each hand-off a grace window to settle — but only a
43
+ // bounded one, because an exchange the cluster has rejected never completes at
44
+ // all and waiting it out spends the entire budget on a tab that was never asked
45
+ // about the instance ("instance header not rendered,
46
+ // url=<cluster>/sso-callback?uuid=...&code=...&state=..." after 480000ms with
47
+ // the deep link captured).
48
+ function isAuthTransit(rawUrl) {
49
+ try {
50
+ const url = new URL(rawUrl);
51
+ if (AUTH_TRANSIT_HOST.test(url.hostname) ||
52
+ IDP_FORM_PATH.test(url.pathname) ||
53
+ AUTH_TRANSIT_PATH.test(url.pathname)) {
54
+ return true;
55
+ }
56
+ // The OAuth `state` parameter is carried by every leg of the hand-off and
57
+ // by nothing Operate itself serves (neither the instance deep link nor the
58
+ // `/processes?active=true&incidents=true` list it bounces to has one), so a
59
+ // URL carrying it is still in transit even when its path is not one of the
60
+ // shapes above. Requiring `code` as well missed the whole outbound half of
61
+ // the hand-off, where only `state` is present yet.
62
+ return url.searchParams.has('state');
63
+ }
64
+ catch {
65
+ // Not a parsable URL yet (e.g. the popup's initial about:blank).
66
+ return false;
67
+ }
68
+ }
69
+ // The cluster proxy appends a bare `?continue` marker to a deep link it served
70
+ // WITHOUT a session. A tab showing it is not waiting on the Zeebe -> Operate
71
+ // import at all — it is waiting on a hand-off that never happened, so plain
72
+ // re-opens of the same URL can only bring the same marker back. That is what
73
+ // reported "instance header not rendered,
74
+ // url=<cluster>/operate/processes/<key>?continue" after a full 480000ms budget.
75
+ function isSessionlessDeepLink(rawUrl) {
76
+ try {
77
+ return new URL(rawUrl).searchParams.has('continue');
78
+ }
79
+ catch {
80
+ // Not a parsable URL yet (e.g. the popup's initial about:blank).
81
+ return false;
82
+ }
83
+ }
84
+ // Returns the instance deep link out of a document request's redirect chain, or
85
+ // undefined when no hop of the chain points at an instance. Oldest hop first:
86
+ // prefer the URL the tab was opened with over whatever a redirect replaced it
87
+ // by.
88
+ function instanceUrlFromRequest(request) {
89
+ const chain = [];
90
+ for (let hop = request; hop !== null;) {
91
+ chain.push(hop.url());
92
+ hop = hop.redirectedFrom();
93
+ }
94
+ chain.reverse();
95
+ for (const url of chain) {
96
+ try {
97
+ if (INSTANCE_PATH.test(new URL(url).pathname)) {
98
+ return url;
99
+ }
100
+ }
101
+ catch {
102
+ // Not a parsable URL yet (e.g. the popup's initial about:blank).
103
+ }
104
+ }
105
+ return undefined;
106
+ }
107
+ // Starts latching Operate instance deep links across the whole browser context.
108
+ // Call this BEFORE the action that opens the Operate popup, and pass the result
109
+ // to `rememberInstanceUrl`.
110
+ //
111
+ // The popup's own events are always too late for its FIRST document request:
112
+ // Playwright emits `popup` only "when the network request to the initial url is
113
+ // done and its response has started loading", and its docs say outright that to
114
+ // listen to that request you must use `browserContext.on('request')` instead of
115
+ // the equivalent method on the Page. `rememberInstanceUrl` attaches its
116
+ // listeners after `waitForEvent('popup')` has resolved, so the deep-link request
117
+ // has already been and gone by then.
118
+ //
119
+ // That only matters because Modeler opens Operate in a fresh tab: while that tab
120
+ // still has no cluster session the auth redirect chain answers the deep link and
121
+ // drops its path, so the only navigation that ever COMMITS is the Operate app
122
+ // root and `framenavigated`/`waitForURL` never see an instance URL either. The
123
+ // deep link is then lost altogether ("instance deep link never captured"), which
124
+ // silently disables the recovery in `waitForInstanceLocator`: `hasBouncedToList`
125
+ // can no longer tell we are off the instance page and `reload` degrades to
126
+ // reloading the app root, where the instance header can never render no matter
127
+ // how long we poll. That is what spent the whole budget as "active/completed
128
+ // icon in Operate timed out after 480000ms (instance header not rendered,
129
+ // url=<cluster>/operate, instance deep link never captured)".
130
+ //
131
+ // The context-level `request` event is already live when the popup is created,
132
+ // so it observes the deep link at its origin. By the time the loop retries, the
133
+ // tab is authenticated and re-opening that URL lands on the instance page.
134
+ function watchOperateInstanceUrl(context) {
135
+ let instanceUrl;
136
+ context.on('request', (request) => {
137
+ if (instanceUrl !== undefined || request.resourceType() !== 'document') {
138
+ return;
139
+ }
140
+ instanceUrl = instanceUrlFromRequest(request);
141
+ });
142
+ return () => instanceUrl;
143
+ }
144
+ exports.watchOperateInstanceUrl = watchOperateInstanceUrl;
6
145
  class OperateProcessInstancePage {
7
146
  page;
147
+ instanceUrl;
8
148
  diagram;
9
149
  completedIcon;
10
150
  diagramSpinner;
11
151
  activeIcon;
12
152
  incidentIcon;
153
+ incidentsBanner;
13
154
  userTaskOpenTasklistLink;
14
155
  taskDetailsPopOver;
15
156
  connectorResultVariableName;
@@ -30,6 +171,9 @@ class OperateProcessInstancePage {
30
171
  this.incidentIcon = page
31
172
  .getByTestId('instance-header')
32
173
  .getByTestId('INCIDENT-icon');
174
+ // Same test id Operate's own e2e suite uses for the incident bar
175
+ // (camunda/camunda `operate/client/e2e-playwright/pages/ProcessInstance.ts`).
176
+ this.incidentsBanner = page.getByTestId('incidents-banner');
33
177
  this.userTaskOpenTasklistLink = page.getByRole('link', {
34
178
  name: 'Open Tasklist',
35
179
  });
@@ -45,33 +189,591 @@ class OperateProcessInstancePage {
45
189
  async connectorResultVariableValue(variableName) {
46
190
  return await this.page.getByTestId(variableName).locator('td').last();
47
191
  }
48
- async closePopOverIfVisible() {
49
- // The instance-detail page is opened via a deep link right after the
50
- // instance is started, so on freshly-provisioned SaaS clusters the
51
- // instance is sometimes not yet imported when the popup lands and the
52
- // page renders empty. A single fixed wait never recovers from that;
53
- // reload between attempts so the "Instance History" panel appears once
54
- // the import catches up.
55
- const maxRetries = 5;
56
- for (let attempt = 0; attempt < maxRetries; attempt++) {
192
+ // Records the instance deep link so the waits below can re-open it after
193
+ // Operate's 404 bounce to the processes list (see the note above the class).
194
+ // Called right after the Operate popup opens, before the query can fail.
195
+ //
196
+ // `waitForURL` alone loses this race: the bounce is a client-side navigation
197
+ // fired as soon as the 404 lands, so on a lagging import the deep link can be
198
+ // gone before the wait is evaluated. `waitForURL` then sits out its whole
199
+ // budget on the list page and leaves `instanceUrl` unset -- which silently
200
+ // disables the recovery: `hasBouncedToList` can no longer tell we bounced and
201
+ // `reload` degrades to reloading the LIST page, so every retry afterwards
202
+ // polls a page that can never render the instance header. That is what turns
203
+ // a slow import into "failed after N attempts". Latch the first
204
+ // instance-shaped URL from `framenavigated` instead, which fires on the deep
205
+ // link's own commit and therefore cannot be beaten by the bounce.
206
+ //
207
+ // `watchedUrl` is the getter returned by `watchOperateInstanceUrl`, primed on
208
+ // the browser context before the popup was opened. It is the ONLY observer
209
+ // that is already live when the popup issues its first document request, so
210
+ // for a Modeler-opened Operate tab it is the source that actually works; the
211
+ // listeners below stay as the in-tab path (a navigation that commits later,
212
+ // or a tab that is already on the instance page).
213
+ async rememberInstanceUrl(timeout = 60000, watchedUrl) {
214
+ const latch = (url) => {
215
+ if (this.instanceUrl !== undefined) {
216
+ return;
217
+ }
57
218
  try {
58
- await (0, test_1.expect)(this.page.getByText('Instance History').first()).toBeVisible({ timeout: 60000 });
219
+ if (INSTANCE_PATH.test(new URL(url).pathname)) {
220
+ this.instanceUrl = url;
221
+ }
222
+ }
223
+ catch {
224
+ // Not a parsable URL yet (e.g. the popup's initial about:blank).
225
+ }
226
+ };
227
+ // Latch what the context-level watcher already saw. This runs FIRST because
228
+ // for a Modeler-opened tab it is the only source that can have the deep
229
+ // link: `framenavigated` only ever reports a *committed* document, and the
230
+ // auth redirect chain drops the deep-link path before it commits (see
231
+ // `watchOperateInstanceUrl`), so neither the listeners below nor
232
+ // `waitForURL` ever see an instance URL in that tab.
233
+ const latchWatched = () => {
234
+ const seen = watchedUrl?.();
235
+ if (seen !== undefined) {
236
+ latch(seen);
237
+ }
238
+ return this.instanceUrl !== undefined;
239
+ };
240
+ if (latchWatched()) {
241
+ return;
242
+ }
243
+ // In-tab sources, for a navigation that commits later or a tab that is
244
+ // already on the instance page (e.g. after clicking through the processes
245
+ // list rather than opening a popup).
246
+ this.page.on('request', (request) => {
247
+ if (request.resourceType() !== 'document' ||
248
+ request.frame() !== this.page.mainFrame()) {
249
+ return;
250
+ }
251
+ const fromRequest = instanceUrlFromRequest(request);
252
+ if (fromRequest !== undefined) {
253
+ latch(fromRequest);
254
+ }
255
+ });
256
+ this.page.on('framenavigated', (frame) => {
257
+ if (frame === this.page.mainFrame()) {
258
+ latch(frame.url());
259
+ }
260
+ });
261
+ latch(this.page.url());
262
+ if (this.instanceUrl !== undefined) {
263
+ return;
264
+ }
265
+ try {
266
+ await this.page.waitForURL((url) => INSTANCE_PATH.test(url.pathname), {
267
+ timeout,
268
+ });
269
+ latch(this.page.url());
270
+ }
271
+ catch (error) {
272
+ if (!latchWatched()) {
273
+ // The tab never showed an instance URL (already bounced, or still on
274
+ // the auth redirect). Retries then behave as before rather than
275
+ // failing here.
276
+ console.warn(`Could not capture the Operate instance URL: ${error}`);
277
+ }
278
+ }
279
+ }
280
+ // True once Operate has replaced the deep link with the processes list.
281
+ // A tab that is still authenticating is NOT a bounce: it is on its way to the
282
+ // deep link, and re-opening it would restart the hand-off (see
283
+ // `isAuthTransit`).
284
+ hasBouncedToList() {
285
+ if (this.instanceUrl === undefined) {
286
+ return false;
287
+ }
288
+ if (isAuthTransit(this.page.url())) {
289
+ return false;
290
+ }
291
+ return !INSTANCE_PATH.test(new URL(this.page.url()).pathname);
292
+ }
293
+ // Lets the cluster SSO hand-off finish instead of re-navigating over it.
294
+ // Returns when the tab has left the auth URLs or when this slice runs out —
295
+ // the caller loops either way, so a hand-off that stalls for the whole
296
+ // budget still surfaces through `describeRenderedState`.
297
+ async waitForAuthToSettle(timeout) {
298
+ if (timeout <= 0) {
299
+ return;
300
+ }
301
+ try {
302
+ await this.page.waitForURL((url) => !isAuthTransit(url.toString()), {
303
+ timeout,
304
+ });
305
+ }
306
+ catch {
307
+ // Still authenticating — handled by the caller's loop.
308
+ }
309
+ }
310
+ // True when the awaited locator is one of the mutually exclusive instance
311
+ // state icons, which an incident makes unreachable. Everything else on the
312
+ // instance page (the "Instance History" panel, the variables list) renders
313
+ // regardless of the instance state, so those waits must NOT be cut short.
314
+ // Identity comparison against the locators this class exposes: a caller that
315
+ // builds its own equivalent locator simply keeps the plain timeout behaviour.
316
+ isStateIconWait(locator) {
317
+ return locator === this.activeIcon || locator === this.completedIcon;
318
+ }
319
+ // Reports the incident bar's text, so a terminal incident names itself
320
+ // instead of being reported as an import that never caught up.
321
+ async describeIncident() {
322
+ const banner = await this.incidentsBanner
323
+ .textContent({ timeout: 5000 })
324
+ .catch(() => null);
325
+ return banner === null || banner.trim() === ''
326
+ ? 'the instance header shows INCIDENT'
327
+ : `the instance raised an incident: ${banner.trim()}`;
328
+ }
329
+ // Reports what the instance-detail page actually rendered, so a timeout below
330
+ // distinguishes "the import never caught up" (no header at all, or the tab is
331
+ // parked on the processes list) from "the instance reached a different state"
332
+ // (e.g. the header shows INCIDENT while we wait for ACTIVE) -- the plain
333
+ // "element(s) not found" of the underlying assertion cannot tell those apart.
334
+ async describeRenderedState() {
335
+ const header = this.page.getByTestId('instance-header');
336
+ if (!(await header.isVisible().catch(() => false))) {
337
+ // Report whether the deep link was captured: without it the retries could
338
+ // only reload whatever page the tab was left on, which is a different
339
+ // failure from an import that never caught up.
340
+ const link = this.instanceUrl === undefined
341
+ ? 'instance deep link never captured'
342
+ : `instance deep link=${this.instanceUrl}`;
343
+ return `instance header not rendered, url=${this.page.url()}, ${link}`;
344
+ }
345
+ const states = ['ACTIVE', 'COMPLETED', 'INCIDENT', 'CANCELED'];
346
+ const rendered = [];
347
+ for (const state of states) {
348
+ const visible = await header
349
+ .getByTestId(`${state}-icon`)
350
+ .isVisible()
351
+ .catch(() => false);
352
+ if (visible) {
353
+ rendered.push(state);
354
+ }
355
+ }
356
+ return rendered.length > 0
357
+ ? `instance header shows ${rendered.join(', ')}`
358
+ : 'instance header rendered without a state icon';
359
+ }
360
+ // Waits for a locator on the instance-detail page, re-opening the deep link
361
+ // whenever Operate has bounced the tab to the processes list. Waiting a full
362
+ // per-attempt budget on the bounced list page can never succeed, so the time
363
+ // is spent re-asking Operate for the instance instead: the instance header
364
+ // and the "Instance History" panel only render once the Zeebe -> Operate
365
+ // import has caught up.
366
+ //
367
+ // The locator is checked in short slices so the bounce is noticed within
368
+ // seconds. Checking it once per `pollTimeout` meant the bounce -- which lands
369
+ // about a second after each re-navigation while the import lags -- was only
370
+ // seen at the END of that wait, so ~29 of every 30 seconds were spent idling
371
+ // on the list page and the whole 8 minute budget bought only ~16 re-asks.
372
+ // That is what ran out as "active/completed icon in Operate timed out after
373
+ // 480000ms" on a slow import. Same budget, but a bouncing page now re-asks
374
+ // every few seconds, while a page that stays on the instance keeps the
375
+ // original one-reload-per-`pollTimeout` cadence.
376
+ async waitForInstanceLocator(locator, description, totalTimeout = 300000, pollTimeout = 30000) {
377
+ const deadline = Date.now() + totalTimeout;
378
+ // Short enough that a bounce costs seconds rather than a full poll window,
379
+ // long enough not to fill the trace with hundreds of failed assertions.
380
+ const checkSlice = 5000;
381
+ // How long the FIRST cluster SSO hand-off is given to complete before it is
382
+ // treated as stalled. A healthy hand-off commits in a couple of seconds, so
383
+ // this is generous; it only has to be long enough that a merely slow
384
+ // exchange is never restarted underneath itself.
385
+ const authGrace = 60000;
386
+ // Every hand-off after that gets a much shorter window: once one has
387
+ // already stalled, the tab has proved this exchange does not just need a
388
+ // little longer, and repeating the generous wait is what limited the whole
389
+ // budget to ~7 restarts (60s idle + 5s backoff each).
390
+ const authRetryGrace = 15000;
391
+ let lastError;
392
+ let nextReload = Date.now() + pollTimeout;
393
+ // When the tab entered its current auth hand-off, or undefined while it is
394
+ // not on an auth URL.
395
+ let authTransitSince;
396
+ // How many hand-offs have already been restarted, and whether the last
397
+ // restart's navigation actually went through.
398
+ let authStalls = 0;
399
+ let authRestartFailed = false;
400
+ // Consecutive re-opens made while the instance header had not rendered at
401
+ // all. A tab that came back from a failed hand-off lands on the deep link
402
+ // itself carrying the cluster proxy's session-less `?continue` marker --
403
+ // neither an auth URL nor the processes list, so both recoveries above see
404
+ // nothing wrong and the loop only re-opens the same URL. Every round landed
405
+ // on the same session-less page, which is what reported "instance header
406
+ // not rendered, url=<cluster>/operate/processes/<key>?continue" after the
407
+ // full budget. Once a few re-opens have brought back no header at all, stop
408
+ // re-asking the cluster directly and re-enter through the Console.
409
+ let headerlessReopens = 0;
410
+ while (Date.now() < deadline) {
411
+ // Don't re-navigate over an in-flight cluster SSO hand-off: doing so
412
+ // aborts the code-for-session exchange and starts a fresh one, so a
413
+ // hand-off that needs longer than the retry interval could never finish
414
+ // (see `isAuthTransit`). But don't wait on it indefinitely either -- an
415
+ // exchange whose code was rejected never turns into a session, and the
416
+ // tab then sits on `/<cluster>/sso-callback?...&code=...&state=...`
417
+ // forever. Waiting that out consumed the ENTIRE budget without ever
418
+ // re-asking Operate anything, which is what reported "active icon in
419
+ // Operate timed out after 480000ms (instance header not rendered,
420
+ // url=<cluster>/sso-callback?uuid=...&code=...&state=...)" with the deep
421
+ // link captured. So: give each hand-off `authGrace` to settle, then
422
+ // re-open the deep link to start a genuinely fresh one.
423
+ if (isAuthTransit(this.page.url())) {
424
+ if (authTransitSince === undefined) {
425
+ authTransitSince = Date.now();
426
+ }
427
+ const grace = authStalls === 0 ? authGrace : authRetryGrace;
428
+ // A restart whose navigation never happened left the tab exactly where
429
+ // it was, so there is no fresh exchange to wait on: retry the
430
+ // navigation instead of idling another window on the stalled one.
431
+ const graceLeft = authRestartFailed
432
+ ? 0
433
+ : grace - (Date.now() - authTransitSince);
434
+ if (graceLeft > 0) {
435
+ await this.waitForAuthToSettle(Math.min(graceLeft, Math.max(0, deadline - Date.now())));
436
+ continue;
437
+ }
438
+ // Back off before re-opening so a cluster that is throttling or
439
+ // rejecting the exchange gets room to recover instead of being hammered
440
+ // with a tight restart loop. It grows with each stall, because a
441
+ // hand-off that has failed several times in a row is not going to be
442
+ // fixed by asking again immediately.
443
+ const backoff = Math.min(30000, 5000 * 2 ** authStalls);
444
+ await (0, sleep_1.sleep)(Math.min(backoff, Math.max(0, deadline - Date.now())));
445
+ // Only a re-navigation that actually happened starts a new hand-off.
446
+ // `reload` reports a `goto` that aborted against the callback's own
447
+ // in-flight navigation instead of hiding it, so such a round no longer
448
+ // buys the untouched tab another full grace window.
449
+ //
450
+ // The first restart re-opens the deep link directly, which is enough
451
+ // for an exchange that was merely slow. Every restart after that goes
452
+ // through the Console root: a hand-off that has already failed once is
453
+ // not recovered by re-entering it from its own dead callback page (see
454
+ // `reenterViaConsole`), which is why ~10 direct restarts in a row still
455
+ // ended the budget parked on `sso-callback`.
456
+ authRestartFailed = !(await (authStalls === 0
457
+ ? this.reload()
458
+ : this.reenterViaConsole(deadline)));
459
+ authStalls++;
460
+ if (!authRestartFailed) {
461
+ authTransitSince = undefined;
462
+ }
463
+ nextReload = Date.now() + pollTimeout;
464
+ continue;
465
+ }
466
+ authTransitSince = undefined;
467
+ authRestartFailed = false;
468
+ // The tab has left the callback, so the exchange that was in flight
469
+ // COMPLETED. `authStalls` shortens the grace window because "this
470
+ // exchange has proved it does not just need a little longer" -- that is
471
+ // a statement about one exchange, so it must count CONSECUTIVE stalls.
472
+ // Latching it for the whole wait meant that after a single early stall
473
+ // every later hand-off -- the ones the ordinary bounce recovery and the
474
+ // periodic re-opens below start, and the fresh one a Console re-entry
475
+ // mints -- was capped at `authRetryGrace` and torn down 15s in, then
476
+ // charged a longer backoff and another full Console re-entry. The loop
477
+ // thrashed instead of converging, which is the other half of how a wait
478
+ // ended its budget parked on
479
+ // `/<cluster>/sso-callback?...&code=...&state=...`.
480
+ authStalls = 0;
481
+ if (this.hasBouncedToList()) {
482
+ // Back off before re-opening so a still-importing instance is not
483
+ // hammered with a tight bounce/re-open loop.
484
+ await (0, sleep_1.sleep)(Math.min(5000, Math.max(0, deadline - Date.now())));
485
+ await this.reload();
486
+ nextReload = Date.now() + pollTimeout;
487
+ continue;
488
+ }
489
+ const remaining = deadline - Date.now();
490
+ if (remaining <= 0) {
59
491
  break;
60
492
  }
493
+ try {
494
+ await (0, test_1.expect)(locator).toBeVisible({
495
+ timeout: Math.min(checkSlice, remaining),
496
+ });
497
+ return;
498
+ }
61
499
  catch (error) {
62
- if (attempt === maxRetries - 1) {
63
- throw error;
500
+ lastError = error;
501
+ }
502
+ // An incident ends this wait: the header renders exactly one state icon,
503
+ // so neither ACTIVE nor COMPLETED can appear while the instance sits in
504
+ // an incident, and nothing in these flows resolves one. Waiting out the
505
+ // rest of the budget only delays the failure by minutes and then reports
506
+ // it as an import lag ("timed out after 480000ms (instance header shows
507
+ // INCIDENT)"), which hides the cause. Confirm it once after a short pause
508
+ // first, so a header rendered from partially imported records is not
509
+ // mistaken for a settled incident.
510
+ if (this.isStateIconWait(locator) &&
511
+ (await this.incidentIcon.isVisible().catch(() => false))) {
512
+ await (0, sleep_1.sleep)(Math.min(10000, Math.max(0, deadline - Date.now())));
513
+ if (await this.incidentIcon.isVisible().catch(() => false)) {
514
+ throw new Error(`Assertion of ${description} in Operate cannot succeed: ` +
515
+ `${await this.describeIncident()}. The awaited state is no ` +
516
+ `longer reachable, so failing now instead of waiting out the ` +
517
+ `remaining budget of ${totalTimeout}ms.`);
64
518
  }
65
- await this.page.reload();
66
- await (0, sleep_1.sleep)(5000);
519
+ }
520
+ if (Date.now() >= nextReload && Date.now() < deadline) {
521
+ // A rendered header means the app booted and we are only waiting on the
522
+ // instance state, so keep re-opening the deep link directly. No header
523
+ // at all points at the app never having booted -- escalate to the
524
+ // Console re-entry once a couple of plain re-opens have not changed it.
525
+ const headerRendered = await this.page
526
+ .getByTestId('instance-header')
527
+ .isVisible()
528
+ .catch(() => false);
529
+ headerlessReopens = headerRendered ? 0 : headerlessReopens + 1;
530
+ // A session-less deep link escalates straight away: the `?continue`
531
+ // marker is proof the cluster hand-off never happened, so the two
532
+ // plain re-opens the counter would otherwise spend first can only
533
+ // bring the same marker back (see `isSessionlessDeepLink`).
534
+ const sessionless = !headerRendered && isSessionlessDeepLink(this.page.url());
535
+ if (headerlessReopens > 2 || sessionless) {
536
+ await this.reenterViaConsole(deadline);
537
+ }
538
+ else {
539
+ await this.reload();
540
+ }
541
+ nextReload = Date.now() + pollTimeout;
67
542
  }
68
543
  }
544
+ throw new Error(`Assertion of ${description} in Operate timed out after ` +
545
+ `${totalTimeout}ms (${await this.describeRenderedState()}): ` +
546
+ `${lastError}`);
547
+ }
548
+ // Dismisses Operate's "what's new" popup without re-gating on the import.
549
+ // Use this when the caller has already waited for something on the instance
550
+ // page (the instance header, the COMPLETED icon): that wait is a strictly
551
+ // stronger gate than "Instance History" is visible, so repeating it here
552
+ // just spends a second full budget proving what is already known -- and on
553
+ // a lagging import it is the duplicate gate, not the real assertion, that
554
+ // reports the failure. `closePopOverIfVisible` keeps the combined behaviour
555
+ // for callers that have not waited for the instance page yet.
556
+ async dismissWhatsNewIfVisible() {
69
557
  if (await this.whatsNewPopUp.isVisible()) {
70
558
  await this.gotItButton.click();
71
559
  }
72
560
  }
561
+ async closePopOverIfVisible() {
562
+ // The instance-detail page is opened via a deep link right after the
563
+ // instance is started, so on a busy SaaS cluster the instance is often not
564
+ // imported yet when the popup lands and Operate bounces the tab to the
565
+ // processes list. Poll the deep link rather than the bounced page; the
566
+ // 8 minute budget matches the previous 8 x 60s attempts.
567
+ await this.waitForInstanceLocator(this.page.getByText('Instance History').first(), 'Instance History panel', 480000);
568
+ if (await this.whatsNewPopUp.isVisible()) {
569
+ await this.gotItButton.click();
570
+ }
571
+ }
572
+ // The host that serves the instance deep link, i.e. the origin whose cluster
573
+ // session the Operate app needs. Undefined only before any cluster URL has
574
+ // been seen at all.
575
+ clusterHost() {
576
+ for (const raw of [this.instanceUrl, this.page.url()]) {
577
+ if (raw === undefined) {
578
+ continue;
579
+ }
580
+ try {
581
+ return new URL(raw).hostname;
582
+ }
583
+ catch {
584
+ // Not a parsable URL yet (e.g. the popup's initial about:blank).
585
+ }
586
+ }
587
+ return undefined;
588
+ }
589
+ // Re-opens the instance by way of the Console's apps menu instead of going
590
+ // straight back at the cluster deep link.
591
+ //
592
+ // Once a tab's cluster SSO hand-off has actually failed, re-opening the deep
593
+ // link cannot recover it: that starts the exchange again from the dead
594
+ // callback page, whose `code` is single use, so the tab just lands back on
595
+ // `/<cluster>/sso-callback?...&code=...&state=...` (or on the deep link
596
+ // itself carrying the proxy's session-less `?continue` marker) and the
597
+ // Operate app never boots. Repeating it only buys more identical rounds --
598
+ // that is what spent whole 480000ms budgets as "instance header not
599
+ // rendered" with the deep link captured.
600
+ //
601
+ // The Console is the one origin whose session is known good in these flows
602
+ // (the test logged in there), so letting IT drive a fresh hand-off -- by
603
+ // clicking Operate in its apps menu -- hands the cluster a real session
604
+ // rather than chaining off the failed callback. Merely LOADING the Console
605
+ // root is not enough: that establishes a Console session only, and the
606
+ // deep-link re-open then puts the cluster proxy back in charge of the very
607
+ // exchange that is wedged (see the click below). This is the same recovery
608
+ // `AppsPage.openOperateWithRetry` / `openModelerWithRetry` already use for
609
+ // the identical Operate and Modeler `login-callback` stalls.
610
+ async reenterViaConsole(deadline) {
611
+ // Clamp every wait below to the time the caller's budget actually has
612
+ // left, so one escalation can never overrun the loop's own deadline (and
613
+ // through it the flow's reserve for the afterEach screenshot). Never 0 —
614
+ // Playwright reads that as "no timeout".
615
+ const left = (want) => deadline === undefined
616
+ ? want
617
+ : Math.max(1000, Math.min(want, deadline - Date.now()));
618
+ let booted = false;
619
+ try {
620
+ // Drop the CLUSTER origin's own cookies before re-entering. A hand-off
621
+ // that parked on `/<cluster>/sso-callback?...&code=...&state=...` left
622
+ // the cluster proxy holding that exchange's state/session cookie, and
623
+ // every restart re-sends it -- so the proxy keeps validating the fresh
624
+ // `code` against the dead flow and bounces the tab straight back to the
625
+ // callback. Neither recovery above touches those cookies (re-opening the
626
+ // deep link sends them again, and the Console root is a different
627
+ // origin), which is why ~8 restarts in a row all ended on that same URL
628
+ // and whole 480000ms budgets went by with the Operate app never booting.
629
+ // Scoped to the cluster host, so the Console and weblogin IdP sessions
630
+ // the test logged in with are untouched and no re-login is needed -- the
631
+ // same recovery `AppsPage.clickModeler` already uses for a wedged
632
+ // Modeler hand-off.
633
+ //
634
+ // `clearCookies` matches its `domain` filter by exact string equality,
635
+ // and Chromium reports a cookie that was set with an explicit `Domain=`
636
+ // attribute with a LEADING DOT. Filtering on the bare host therefore
637
+ // re-added every `.<host>` cookie -- the proxy's own session/CSRF pair,
638
+ // i.e. precisely the dead exchange's state this clear exists to drop --
639
+ // so each restart re-sent them and the proxy kept validating the fresh
640
+ // `code` against the wedged flow. Match both forms, still scoped to the
641
+ // cluster host itself so the Console and weblogin IdP sessions on the
642
+ // parent domain stay untouched and no re-login is needed.
643
+ const host = this.clusterHost();
644
+ if (host !== undefined) {
645
+ const escaped = host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
646
+ await this.page
647
+ .context()
648
+ .clearCookies({ domain: new RegExp(`^\\.?${escaped}$`) });
649
+ }
650
+ await this.page.goto('/', { waitUntil: 'domcontentloaded' });
651
+ // Wait for the Console APP, not just for its shell document.
652
+ // `domcontentloaded` resolves as soon as the root HTML is parsed, which
653
+ // is well before the Console has booted and run its own auth/token
654
+ // refresh. Gate on the app switcher -- the same marker the flows use to
655
+ // know the Console is ready -- so the re-entry below starts from a
656
+ // Console session that actually exists.
657
+ const appSwitcher = this.page
658
+ .getByLabel('Camunda components')
659
+ .or(this.page.getByLabel('Camunda apps'))
660
+ .or(this.page.getByLabel('App Switcher'))
661
+ .first();
662
+ await appSwitcher.waitFor({ state: 'visible', timeout: left(30000) });
663
+ // Now re-enter the CLUSTER through the apps menu, instead of going
664
+ // straight back at the deep link. Reaching the Console root only
665
+ // establishes a CONSOLE session; the CLUSTER session is minted by the
666
+ // hand-off, and re-requesting the deep link makes the cluster PROXY
667
+ // drive that hand-off -- which is precisely the exchange that is wedged
668
+ // (it is what emits `/<cluster>/sso-callback?...&code=...&state=...` and
669
+ // the session-less `?continue` marker). Without this click every
670
+ // "Console re-entry" degenerated into just another plain deep-link
671
+ // re-open, which is why ~8 of them in a row all ended on that same URL
672
+ // and whole 480000ms budgets went by with Operate never booting.
673
+ // Clicking Operate here makes the CONSOLE mint a fresh authorization
674
+ // request off its own known-good session -- the same hand-off
675
+ // `AppsPage.openOperateWithRetry` uses successfully elsewhere in these
676
+ // flows. The locators mirror `AppsPage.clickOperate`; they are inlined
677
+ // rather than imported because `AppsPage` -> `UtilitiesPage` imports
678
+ // this module back.
679
+ // Give the menu clicks the same tolerance `AppsPage` uses for the very
680
+ // same hand-off (30s), not the config-wide 10s `actionTimeout`: a Console
681
+ // that is still settling made this single click lapse at 10s and threw
682
+ // the WHOLE re-entry away -- 50 to 73 of the recorded re-entry failures
683
+ // in a nightly are exactly `locator.click: Timeout 10000ms exceeded`.
684
+ // Still clamped to the caller's budget through `left`.
685
+ await appSwitcher.click({ timeout: left(30000) });
686
+ const operateLink = this.page.getByRole('link', {
687
+ name: 'Operate',
688
+ exact: true,
689
+ });
690
+ const operateGroup = this.page.getByRole('button', {
691
+ name: 'Operate',
692
+ exact: true,
693
+ });
694
+ // The apps panel populates asynchronously after the switcher click, and
695
+ // `isVisible()` IGNORES its `timeout` option -- it reports the state as
696
+ // of right now and returns (see the `@deprecated This option is ignored`
697
+ // note on `locator.isVisible` in playwright-core's types). Probing
698
+ // immediately after the click therefore almost always answered "no
699
+ // link", fell through to the multi-cluster branch, and threw when that
700
+ // button was not rendered yet either -- so the whole re-entry was
701
+ // swallowed by the catch below and degenerated into exactly the plain
702
+ // deep-link re-open it exists to replace. That is why every "Console
703
+ // re-entry" left the tab back on
704
+ // `/<cluster>/sso-callback?...&code=...&state=...`. Wait for the panel
705
+ // to actually render first, the same way `AppsPage.clickTasklist` does.
706
+ await operateLink
707
+ .or(operateGroup)
708
+ .first()
709
+ .waitFor({ state: 'visible', timeout: left(30000) });
710
+ if (await operateLink.isVisible()) {
711
+ // Single cluster.
712
+ await operateLink.click({ timeout: left(30000) });
713
+ }
714
+ else {
715
+ // Multiple clusters: expand the Operate group, then pick the cluster
716
+ // serving this instance.
717
+ await operateGroup.click({ timeout: left(30000) });
718
+ await this.page
719
+ .getByRole('listitem')
720
+ .filter({ hasText: 'Operate' })
721
+ .getByRole('link')
722
+ .first()
723
+ .click({ timeout: left(30000) });
724
+ }
725
+ // Gate on Operate itself, not on the Console shell: only a booted
726
+ // Operate proves the cluster session now exists, which is exactly what
727
+ // the deep-link re-open below needs. Returning any earlier would hand
728
+ // the cluster the same missing session as the round before it.
729
+ await this.page
730
+ .getByRole('link', { name: 'Camunda logo Operate' })
731
+ .waitFor({ state: 'visible', timeout: left(60000) });
732
+ booted = true;
733
+ }
734
+ catch (error) {
735
+ // Left to the caller's loop, exactly as in `reload` below: the re-open
736
+ // that follows is what matters, and it is retried anyway.
737
+ console.warn(`Could not re-enter through the Console root: ${error}`);
738
+ }
739
+ // Report whether a cluster session was actually minted, not merely whether
740
+ // the deep-link re-open navigated. Only a re-entry that booted Operate has
741
+ // started a NEW hand-off; returning `reload()`'s result said "restarted"
742
+ // for every round whose re-entry had timed out, so the caller granted the
743
+ // untouched, still-wedged callback another full grace window (and a longer
744
+ // backoff) before trying again -- roughly a third of the budget spent
745
+ // idling on an exchange nothing had restarted.
746
+ const reopened = await this.reload();
747
+ return booted && reopened;
748
+ }
749
+ // Returns whether the re-navigation actually went through, so the auth
750
+ // recovery in `waitForInstanceLocator` can tell "a new hand-off was started"
751
+ // apart from "the tab never moved".
73
752
  async reload() {
74
- await this.page.reload();
753
+ // A failed re-navigation is logged and left to the caller's loop rather
754
+ // than thrown: this runs as the recovery step of `waitForInstanceLocator`,
755
+ // and a navigation that races the page's own in-flight one fails with
756
+ // `net::ERR_ABORTED; maybe frame was detached?` (a DNS blip with
757
+ // `net::ERR_NAME_NOT_RESOLVED`). Those used to escape the retry loop and
758
+ // fail the test outright, even though the very next poll would have
759
+ // re-opened the deep link anyway. A page that genuinely never comes back
760
+ // still fails, through the loop's own timeout and rendered-state report.
761
+ try {
762
+ // Always re-open the remembered instance deep link: once Operate has
763
+ // replaced the URL with the processes list, reloading that list can never
764
+ // bring the instance back (see the class note).
765
+ if (this.instanceUrl !== undefined) {
766
+ await this.page.goto(this.instanceUrl, { waitUntil: 'domcontentloaded' });
767
+ return true;
768
+ }
769
+ await this.page.reload();
770
+ await this.page.waitForLoadState('domcontentloaded');
771
+ return true;
772
+ }
773
+ catch (error) {
774
+ console.warn(`Could not re-open the Operate instance page: ${error}`);
775
+ return false;
776
+ }
75
777
  }
76
778
  async assertProcessCompleteStatusWithRetry(timeout = 60000, maxRetries = 10) {
77
779
  for (let attempt = 0; attempt < maxRetries; attempt++) {