@camunda/e2e-test-suite 0.0.1203 → 0.0.1204

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
  clickCluster(component: Locator, name: string): Promise<void>;
32
32
  clickModeler(): Promise<void>;
33
33
  private isModelerAuthTransit;
34
- openModelerWithRetry(banner: Locator, timeout?: number, maxRetries?: number): Promise<void>;
34
+ openModelerWithRetry(banner: Locator, timeout?: number, maxRetries?: number, ready?: Locator): Promise<void>;
35
35
  openOperateWithRetry(banner: Locator, clusterName: string, timeout?: number, maxRetries?: number): Promise<void>;
36
36
  clickTasklist(clusterName: string): Promise<void>;
37
37
  openTasklistWithRetry(banner: Locator, clusterName: string, timeout?: number, maxRetries?: number): Promise<void>;
@@ -220,12 +220,34 @@ class AppsPage {
220
220
  // URL and the Modeler banner never renders. Reloading cannot recover it —
221
221
  // the `code` is single use — so re-enter through the Camunda apps menu,
222
222
  // which starts a fresh handshake.
223
- async openModelerWithRetry(banner, timeout = 60000, maxRetries = 3) {
223
+ //
224
+ // `ready` is an optional second gate for callers that need proof of a
225
+ // Modeler SESSION rather than proof of the Modeler shell. The banner is not
226
+ // that proof -- Modeler renders it on its own login page too -- and the
227
+ // hand-off can still fail after the shell is up: when
228
+ // `POST /api/internal/login` returns 500, hub's
229
+ // auth-service.handleRedirectCallback() falls into its catch and calls
230
+ // logout(), bouncing the tab through Auth0's /v2/logout back to
231
+ // `modeler.<host>/login` about half a minute AFTER the banner became
232
+ // visible. The banner assertion passes in that window, the spec then hunts
233
+ // for its project tile on a login screen, and the failure surfaces minutes
234
+ // later and nowhere near its cause ("Failed to click locator
235
+ // getByTitle('Cross Component Test Project') ... after N attempts"). Pass
236
+ // something only an authenticated Modeler renders -- the projects list's
237
+ // create button -- and a logged-out tab is caught here instead, where the
238
+ // retry re-runs the handshake and with it the internal login.
239
+ async openModelerWithRetry(banner, timeout = 60000, maxRetries = 3, ready) {
240
+ const modelerReady = async () => {
241
+ await (0, test_1.expect)(banner).toBeVisible({ timeout });
242
+ if (ready) {
243
+ await (0, test_1.expect)(ready).toBeVisible({ timeout });
244
+ }
245
+ };
224
246
  for (let attempt = 0; attempt < maxRetries; attempt++) {
225
247
  await this.clickCamundaApps();
226
248
  await this.clickModeler();
227
249
  try {
228
- await (0, test_1.expect)(banner).toBeVisible({ timeout });
250
+ await modelerReady();
229
251
  return;
230
252
  }
231
253
  catch (error) {
@@ -244,7 +266,7 @@ class AppsPage {
244
266
  // falls through to the re-entry below exactly as before.
245
267
  if (this.isModelerAuthTransit()) {
246
268
  try {
247
- await (0, test_1.expect)(banner).toBeVisible({ timeout });
269
+ await modelerReady();
248
270
  return;
249
271
  }
250
272
  catch (settleError) {
@@ -74,19 +74,27 @@ class FormJsPage {
74
74
  // toast never renders even though generation finished, leaving the old
75
75
  // single 5-minute wait stuck until the test timed out. Re-attempt on a
76
76
  // reloaded editor (fresh websocket) if the toast doesn't arrive.
77
+ //
78
+ // Submitting the request is retried on the same reloaded editor, not just
79
+ // the toast: the generator panel is mounted by the button click, and a
80
+ // click that does not open it leaves every following step acting on a
81
+ // surface that is not there -- reported as `locator.click: Timeout
82
+ // 10000ms exceeded ... waiting for getByPlaceholder('A form for an
83
+ // american standard loan request')`. That used to escape the loop on the
84
+ // first attempt because only the toast wait was guarded.
77
85
  const maxAttempts = 2;
78
86
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
79
- await (0, test_1.expect)(this.aiFormGeneratorButton).toBeVisible({
80
- timeout: 90000,
81
- });
82
- await this.clickAIFormGeneratorButton();
83
- await this.clickFormRequestInput();
84
- await this.fillFormRequestInput(request);
85
- await this.clickGenerateFormButton();
86
- await (0, test_1.expect)(this.page.getByText('Generating...'))
87
- .toBeVisible({ timeout: 60000 })
88
- .catch(() => { });
89
87
  try {
88
+ await (0, test_1.expect)(this.aiFormGeneratorButton).toBeVisible({
89
+ timeout: 90000,
90
+ });
91
+ await this.clickAIFormGeneratorButton();
92
+ await this.clickFormRequestInput();
93
+ await this.fillFormRequestInput(request);
94
+ await this.clickGenerateFormButton();
95
+ await (0, test_1.expect)(this.page.getByText('Generating...'))
96
+ .toBeVisible({ timeout: 60000 })
97
+ .catch(() => { });
90
98
  await (0, test_1.expect)(this.page.getByText('Successfully generated smart form.')).toBeVisible({ timeout: 180000 });
91
99
  await (0, sleep_1.sleep)(60000);
92
100
  return;
@@ -102,8 +110,12 @@ class FormJsPage {
102
110
  async clickAIFormGeneratorButton() {
103
111
  await this.aiFormGeneratorButton.click();
104
112
  }
113
+ // The generator panel slides in after its button click and renders this
114
+ // input with it, so the inherited 10s actionTimeout is raced against a panel
115
+ // that is still mounting -- every other control on this page allows 60s.
105
116
  async clickFormRequestInput() {
106
- await this.formRequestInput.click();
117
+ await (0, test_1.expect)(this.formRequestInput).toBeVisible({ timeout: 60000 });
118
+ await this.formRequestInput.click({ timeout: 60000 });
107
119
  }
108
120
  async fillFormRequestInput(formRequest) {
109
121
  await this.formRequestInput.fill(formRequest);
@@ -243,7 +255,11 @@ class FormJsPage {
243
255
  }
244
256
  await this.selectOnlyThisResource();
245
257
  await this.clickDeploySubButton();
246
- await (0, test_1.expect)(this.deploySuccessNotification).toBeVisible({ timeout: 30000 });
258
+ // Deploying the form is a round trip through the cluster, so the toast is
259
+ // gated on the cluster's response rather than on the modal closing. 30s is
260
+ // the shortest wait in this flow and the one the nightly ran out of; the
261
+ // rest of the page object budgets 60-90s for cluster-bound waits.
262
+ await (0, test_1.expect)(this.deploySuccessNotification).toBeVisible({ timeout: 90000 });
247
263
  }
248
264
  }
249
265
  exports.FormJsPage = FormJsPage;
@@ -114,6 +114,9 @@ declare class ModelerCreatePage {
114
114
  clickSecondDeployButton(): Promise<void>;
115
115
  modelJobWorkerDiagram(processName: string, embedFrom?: boolean, processId?: string): Promise<void>;
116
116
  private waitForDiagramEditorToLoad;
117
+ private onModelerLoginPage;
118
+ private waitForModelerSession;
119
+ private linkFormToUserTask;
117
120
  modelCamundaUserTaskDiagram(processName: string, processId?: string, formName?: string): Promise<void>;
118
121
  assignToCandidateGroup(taskName: string, candidateGroup: string, assignee?: string): Promise<void>;
119
122
  assignToCandidateUser(taskName: string, candidateUser: string, assignee?: string): Promise<void>;
@@ -409,11 +409,91 @@ class ModelerCreatePage {
409
409
  if (attempt === maxAttempts - 1) {
410
410
  throw error;
411
411
  }
412
+ // A reload cannot bring the editor back while the tab is mid-logout
413
+ // bounce -- it only restarts it -- so let hub finish returning to the
414
+ // diagram first, and reload only if it never does.
415
+ if (await this.waitForModelerSession()) {
416
+ continue;
417
+ }
412
418
  console.warn(`Diagram editor did not load on attempt ${attempt + 1}; reloading.`);
413
419
  await this.page.reload();
414
420
  }
415
421
  }
416
422
  }
423
+ // True while the tab is sitting on Modeler's own login surface rather than in
424
+ // the app. Hub can throw a modelling session out mid-flow: when a request
425
+ // comes back 401 -- or the late `POST /api/internal/login` 500 that
426
+ // `AppsPage.openModelerWithRetry` guards the hand-off against lands after
427
+ // entry has already been cleared -- auth-service calls logout() and the tab
428
+ // is bounced to `modeler.<host>/login?returnUrl=<the diagram>`.
429
+ onModelerLoginPage(url = this.page.url()) {
430
+ try {
431
+ const { hostname, pathname } = new URL(url);
432
+ return (hostname.startsWith('modeler.') &&
433
+ /^\/login(-callback)?\/?$/.test(pathname));
434
+ }
435
+ catch {
436
+ // Not a parsable URL yet (e.g. a tab still on about:blank).
437
+ return false;
438
+ }
439
+ }
440
+ // Waits out that bounce. The IdP session itself is still alive, so hub
441
+ // completes the round trip on its own and comes back to the diagram named in
442
+ // returnUrl -- but anything issued in the meantime waits out its whole
443
+ // timeout against a login screen instead, which is how linking a form
444
+ // reported `locator.click: Timeout 90000ms exceeded ... navigated to
445
+ // https://modeler.<host>/login?returnUrl=%2Fdiagrams%2F...`. Reports whether
446
+ // the tab made it back, so a caller can redo what the sign-out interrupted.
447
+ async waitForModelerSession(timeout = 120000) {
448
+ if (!this.onModelerLoginPage()) {
449
+ return false;
450
+ }
451
+ return await this.page
452
+ .waitForURL((url) => !this.onModelerLoginPage(url.toString()), { timeout })
453
+ .then(() => true)
454
+ .catch(() => false);
455
+ }
456
+ // Links `formName` to the selected user task, redoing the link when the tab
457
+ // is signed out while it is in flight. The picker is a modal on the diagram
458
+ // page, so the logout bounce takes it down with the page: once hub has
459
+ // returned to the diagram the whole link has to be driven again, not just
460
+ // the click that was interrupted. The element stays selected across the
461
+ // round trip -- its id travels in the returnUrl -- so the properties panel
462
+ // comes back with the "Link form" control in place. Watched via
463
+ // `framenavigated` rather than by re-reading the URL afterwards, because the
464
+ // bounce can finish inside the failing click's own timeout and leave nothing
465
+ // behind to detect.
466
+ async linkFormToUserTask(formName) {
467
+ let signedOutMidFlow = false;
468
+ const watchForSignOut = (frame) => {
469
+ if (frame === this.page.mainFrame() &&
470
+ this.onModelerLoginPage(frame.url())) {
471
+ signedOutMidFlow = true;
472
+ }
473
+ };
474
+ this.page.on('framenavigated', watchForSignOut);
475
+ try {
476
+ await this.clickEmbedFormButton();
477
+ await this.clickForm(formName);
478
+ await this.clickEmbedButton();
479
+ return;
480
+ }
481
+ catch (error) {
482
+ if (!signedOutMidFlow) {
483
+ throw error;
484
+ }
485
+ console.warn('Session was dropped while linking the form; waiting for Modeler to ' +
486
+ 'come back and linking again.');
487
+ await this.waitForModelerSession();
488
+ }
489
+ finally {
490
+ this.page.off('framenavigated', watchForSignOut);
491
+ }
492
+ await this.waitForDiagramEditorToLoad();
493
+ await this.clickEmbedFormButton();
494
+ await this.clickForm(formName);
495
+ await this.clickEmbedButton();
496
+ }
417
497
  async modelCamundaUserTaskDiagram(processName, processId = '', formName = '') {
418
498
  await this.waitForDiagramEditorToLoad();
419
499
  await this.enterDiagramName(processName);
@@ -445,9 +525,7 @@ class ModelerCreatePage {
445
525
  await this.clickUserTaskOption();
446
526
  await this.assertImplementationOption('zeebeUserTask');
447
527
  if (formName.length > 0) {
448
- await this.clickEmbedFormButton();
449
- await this.clickForm(formName);
450
- await this.clickEmbedButton();
528
+ await this.linkFormToUserTask(formName);
451
529
  }
452
530
  await this.clickAppendElementButton();
453
531
  await this.clickAppendEndEventButton();
@@ -56,6 +56,7 @@ declare class ModelerHomePage {
56
56
  clickProjectBreadcrumb(): Promise<void>;
57
57
  clickWorkspaceBreadcrumb(): Promise<void>;
58
58
  enterResourceContainer(name?: string): Promise<void>;
59
+ private createResourceContainer;
59
60
  private clickCreateProcessApplication;
60
61
  private ensureInResourceContainer;
61
62
  returnToResourceContainer(): Promise<void>;
@@ -319,11 +319,33 @@ class ModelerHomePage {
319
319
  });
320
320
  await this.diagramTypeDropdown.click();
321
321
  }
322
+ // The click landing is not proof the diagram was created: hub creates the
323
+ // resource and then navigates, so a create that fails server-side leaves the
324
+ // tab on the container with nothing but a toast. The caller goes straight on
325
+ // to model, and its wait for the editor can only expire -- reported as
326
+ // `locator('[data-group-id="group-general"]') ... element(s) not found`,
327
+ // three reloads later, on a page that was never a diagram. Reloading cannot
328
+ // conjure one, so confirm the editor URL here and re-issue the create
329
+ // instead. The caller's own editor wait stays the authority: this returns
330
+ // after the last attempt rather than throwing, so a genuinely broken create
331
+ // is still reported by the assertion that needs the editor.
322
332
  async clickBpmnTemplateOption() {
323
- await (0, test_1.expect)(this.bpmnTemplateOption).toBeVisible({
324
- timeout: 15000,
325
- });
326
- await this.bpmnTemplateOption.click();
333
+ const maxAttempts = 3;
334
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
335
+ await (0, test_1.expect)(this.bpmnTemplateOption).toBeVisible({
336
+ timeout: 15000,
337
+ });
338
+ await this.bpmnTemplateOption.click();
339
+ const opened = await this.page
340
+ .waitForURL(/\/diagrams\//, { timeout: 60000 })
341
+ .then(() => true)
342
+ .catch(() => false);
343
+ if (opened || attempt === maxAttempts) {
344
+ return;
345
+ }
346
+ console.warn(`No diagram opened on attempt ${attempt}; re-opening the create menu.`);
347
+ await this.clickDiagramTypeDropdown();
348
+ }
327
349
  }
328
350
  async clickFormOption() {
329
351
  await this.formTemplateOption.click();
@@ -392,14 +414,60 @@ class ModelerHomePage {
392
414
  name ??
393
415
  (await (0, randomName_1.randomNameAgregator)('Cross Component Process Application'));
394
416
  console.log(`Creating process application ${this.processApplicationName} inside ${this.defaultFolderName}`);
395
- await this.clickCreateProcessApplication();
396
- await this.processApplicationPage.nameNewProcessApplication(this.processApplicationName);
417
+ await this.createResourceContainer(this.processApplicationName);
397
418
  this.processApplicationUrl = this.page.url();
398
419
  // Remember the container; its stage cluster is bound on the first deploy,
399
420
  // by ProcessApplicationPage.ensureStageCluster, which -- unlike this point
400
421
  // in the flow -- knows which cluster the test is going to deploy to.
401
422
  this.processApplicationPage.rememberResourceContainer(this.processApplicationUrl);
402
423
  }
424
+ // Creates this test's process application, retrying when the creation does
425
+ // not land.
426
+ //
427
+ // A closed creation modal does NOT mean the application exists. Hub's
428
+ // processApplicationStore.finalizeCreation() catches its own failure: it
429
+ // toasts "Couldn't create your project. Please try again later." and never
430
+ // reaches the history.push() into the new application -- but
431
+ // CreationModal.performRequest() still calls closeModal(), so the submit
432
+ // looks like it worked while the browser is simply left on the workspace
433
+ // root. The single wait for the process-application breadcrumb that followed
434
+ // could then only ever time out
435
+ // ("breadcrumb-process-application-menu ... element(s) not found"), which was
436
+ // the largest single source of failures in this spec. The product itself
437
+ // asks for a retry in that toast, so retry.
438
+ //
439
+ // A creation that DID land but rendered slowly must not become a duplicate
440
+ // container, so every retry first adopts an application already carrying
441
+ // this name before creating another one.
442
+ async createResourceContainer(name) {
443
+ const workspaceUrl = this.page.url();
444
+ const maxAttempts = 3;
445
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
446
+ const lastAttempt = attempt === maxAttempts;
447
+ if (attempt > 1) {
448
+ await this.page.goto(workspaceUrl, { waitUntil: 'domcontentloaded' });
449
+ if (await this.processApplicationPage.openByNameIfPresent(name)) {
450
+ return;
451
+ }
452
+ // The adopt probe may have clicked into an application that then
453
+ // failed to render, so come back to the workspace root: the create
454
+ // controls this attempt needs only exist there.
455
+ await this.page.goto(workspaceUrl, { waitUntil: 'domcontentloaded' });
456
+ }
457
+ try {
458
+ await this.clickCreateProcessApplication();
459
+ await this.processApplicationPage.nameNewProcessApplication(name, lastAttempt ? 90000 : 45000);
460
+ return;
461
+ }
462
+ catch (error) {
463
+ if (lastAttempt) {
464
+ throw error;
465
+ }
466
+ console.warn(`Process application "${name}" did not open ` +
467
+ `on attempt ${attempt}/${maxAttempts}; retrying the creation.`);
468
+ }
469
+ }
470
+ }
403
471
  // Which control creates a process application depends on the hub version
404
472
  // under test. Since camunda/camunda-hub#25831 it is a plain primary button on
405
473
  // the workspace root, and the "Create new" menu explicitly hides the item
@@ -412,6 +480,15 @@ class ModelerHomePage {
412
480
  const rootLoaded = await this.becameVisible(button.or(this.diagramTypeDropdown).first(), 60000);
413
481
  if (!rootLoaded) {
414
482
  await this.page.reload();
483
+ await this.page.waitForLoadState('domcontentloaded');
484
+ // Re-run the joint probe after the reload. The short confirmation below
485
+ // assumes the workspace root has already rendered one of the two entry
486
+ // points, which straight after a reload it has not: probing the button
487
+ // alone for 5s there reported "absent" on a root that was merely still
488
+ // loading, sent the flow down the create-menu branch, and waited out its
489
+ // full 60s on a `[data-test="diagram-dropdown"]` that the empty-state
490
+ // root never renders.
491
+ await this.becameVisible(button.or(this.diagramTypeDropdown).first(), 60000);
415
492
  }
416
493
  // Short: the joint probe above already settled which branch rendered, and
417
494
  // hub renders exactly one of them (EntityTable renders the toolbar's create
@@ -445,6 +522,16 @@ class ModelerHomePage {
445
522
  await this.page.goto(this.processApplicationUrl, {
446
523
  waitUntil: 'domcontentloaded',
447
524
  });
525
+ // One reload before spending the whole budget: processApplicationStore
526
+ // .init() swallows a failed fetch (it only toasts "Yikes! Couldn't
527
+ // retrieve your project" and clears its loading flag), so a transient 5xx
528
+ // leaves a fully rendered page that simply has no process-application
529
+ // breadcrumb -- waiting the full 90s on it can only ever time out, and
530
+ // re-running the store init is the only thing that brings it back.
531
+ const backInside = await this.becameVisible(this.processApplicationPage.breadcrumb, 45000);
532
+ if (!backInside) {
533
+ await this.page.reload({ waitUntil: 'domcontentloaded' });
534
+ }
448
535
  await this.processApplicationPage.waitForProcessApplication();
449
536
  await this.dismissBlockingDialog();
450
537
  }
@@ -499,8 +586,13 @@ class ModelerHomePage {
499
586
  visibilityTimeout: 60000,
500
587
  // Widened from 180s: dismissing the announcement dialog costs up to ~25s
501
588
  // of settle-and-probe per attempt, which at 180s left room for barely one
502
- // retry after an intercepted click had already burnt its 60s.
503
- totalTimeout: 240000,
589
+ // retry after an intercepted click had already burnt its 60s. 240s was
590
+ // still short of what maxRetries asks for -- preAction (~40s) plus the
591
+ // 60s visibility budget makes an attempt cost ~100s, so the helper gave
592
+ // up "after 3 attempts" with two retries left unused. Budget for the
593
+ // retries it is configured for, while still leaving room inside the test
594
+ // timeout for the helper's own error to be the one reported.
595
+ totalTimeout: 540000,
504
596
  maxRetries: 5,
505
597
  preAction: async () => {
506
598
  // Dismiss the dialog BEFORE the banner: the Modeler home page can
@@ -19,12 +19,13 @@ declare class ProcessApplicationPage {
19
19
  waitForProcessApplication(timeout?: number): Promise<void>;
20
20
  clickCreateProcessApplicationButton(): Promise<void>;
21
21
  clickCreateProcessApplicationOption(): Promise<void>;
22
- nameNewProcessApplication(name: string): Promise<void>;
22
+ nameNewProcessApplication(name: string, readyTimeout?: number): Promise<void>;
23
23
  defineDevelopmentStage(clusterName?: string): Promise<void>;
24
24
  rememberResourceContainer(url: string): void;
25
25
  forgetStageCluster(): void;
26
26
  ensureStageCluster(clusterName: string): Promise<boolean>;
27
27
  private openStageDefinition;
28
+ openByNameIfPresent(name: string, timeout?: number): Promise<boolean>;
28
29
  openByName(name: string): Promise<void>;
29
30
  }
30
31
  export { ProcessApplicationPage };
@@ -117,7 +117,14 @@ class ProcessApplicationPage {
117
117
  // editable title before, and #27593 is still moving this surface, so the
118
118
  // inline path is kept as a fallback — this is the one method that has to
119
119
  // change if naming moves back inline.
120
- async nameNewProcessApplication(name) {
120
+ //
121
+ // readyTimeout is how long the caller is prepared to wait for the created
122
+ // application to actually open. It is a parameter because a closed modal
123
+ // does NOT mean the application exists (see
124
+ // ModelerHomePage.createResourceContainer): a caller that can retry the
125
+ // whole creation wants to find that out quickly rather than spend the full
126
+ // budget on a page that is never going to render.
127
+ async nameNewProcessApplication(name, readyTimeout = 90000) {
121
128
  const isModal = await this.creationDialog
122
129
  .waitFor({ state: 'visible', timeout: 15000 })
123
130
  .then(() => true)
@@ -127,14 +134,14 @@ class ProcessApplicationPage {
127
134
  await this.nameInput.fill(name);
128
135
  await (0, test_1.expect)(this.nameInput).toHaveValue(name, { timeout: 10000 });
129
136
  await (0, submitModalWithRetry_1.submitModalWithRetry)(this.submitButton, this.creationDialog);
130
- await this.waitForProcessApplication();
137
+ await this.waitForProcessApplication(readyTimeout);
131
138
  return;
132
139
  }
133
140
  await (0, test_1.expect)(this.inlineNameInput).toBeVisible({ timeout: 30000 });
134
141
  await this.inlineNameInput.click({ timeout: 60000 });
135
142
  await this.inlineNameInput.fill(name);
136
143
  await this.inlineNameInput.press('Enter');
137
- await this.waitForProcessApplication();
144
+ await this.waitForProcessApplication(readyTimeout);
138
145
  }
139
146
  // Assigns a cluster to this process application's development stage.
140
147
  //
@@ -190,8 +197,17 @@ class ProcessApplicationPage {
190
197
  await option.click({ timeout: 15000, force: true });
191
198
  await (0, test_1.expect)(option).toBeHidden({ timeout: 15000 });
192
199
  }).toPass({ timeout: 120000, intervals: [1000, 2000, 5000] });
193
- await this.defineStagesSaveButton.click({ timeout: 30000 });
194
- await (0, test_1.expect)(this.defineStagesDialog).toBeHidden({ timeout: 60000 });
200
+ // Saving is retried the same way the option click is, and for the same
201
+ // reason: the click resolving is not proof the save was taken. The nightly
202
+ // recorded a Save that reported success and left the dialog standing --
203
+ // `expect(getByRole('dialog').filter({hasText: 'Define stages'}))
204
+ // .toBeHidden()` re-resolving it 63 times over 60s -- which one long wait
205
+ // can only sit out. A save that did land closes the dialog, so the dialog
206
+ // still being there is itself the signal to press Save again.
207
+ await (0, test_1.expect)(async () => {
208
+ await this.defineStagesSaveButton.click({ timeout: 15000 });
209
+ await (0, test_1.expect)(this.defineStagesDialog).toBeHidden({ timeout: 20000 });
210
+ }).toPass({ timeout: 120000, intervals: [1000, 2000, 5000] });
195
211
  }
196
212
  rememberResourceContainer(url) {
197
213
  resourceContainers.set(this.page, { url });
@@ -289,6 +305,29 @@ class ProcessApplicationPage {
289
305
  await (0, test_1.expect)(this.defineStagesDialog).toBeVisible({ timeout: 15000 });
290
306
  }).toPass({ timeout: 180000 });
291
307
  }
308
+ // Adopts an application that already carries this name on the workspace
309
+ // root, reporting whether it opened. Used by the creation retry so a
310
+ // creation that did land but rendered slowly is re-entered instead of being
311
+ // duplicated by the next attempt.
312
+ async openByNameIfPresent(name, timeout = 15000) {
313
+ const processApplication = this.page
314
+ .getByRole('row')
315
+ .filter({ hasText: name })
316
+ .getByTitle(name)
317
+ .first();
318
+ const present = await processApplication
319
+ .waitFor({ state: 'visible', timeout })
320
+ .then(() => true)
321
+ .catch(() => false);
322
+ if (!present) {
323
+ return false;
324
+ }
325
+ await processApplication.click({ timeout: 60000 });
326
+ return await this.pageReady()
327
+ .waitFor({ state: 'visible', timeout: 60000 })
328
+ .then(() => true)
329
+ .catch(() => false);
330
+ }
292
331
  async openByName(name) {
293
332
  const processApplication = this.page
294
333
  .getByRole('row')
@@ -102,6 +102,35 @@ async function waitForLoginForm(page, loginPage, totalTimeout = 90000, roundTime
102
102
  // rather than on every call -- see the comment at the call site.
103
103
  const staggeredWorkerTimeouts = new Set();
104
104
  async function loginWithRetry(page, loginPage, testUser, timeout, maxRetries = 5) {
105
+ // A blank credential is not something retrying can fix, and from inside the
106
+ // browser it does not look like a credential problem at all: the form takes
107
+ // the empty password, auth0 keeps the tab on its own page, and the failure
108
+ // surfaces a minute later as the Console banner "element(s) not found" --
109
+ // five times over, plus the test-level retry.
110
+ //
111
+ // Run 35320545635 spent 22 minutes that way on one empty password, and since
112
+ // the caller is test-setup.spec.ts the 239 tests depending on that setup
113
+ // project never ran. The password was empty because GitHub refused to set
114
+ // the job output carrying it ("Skip output 'c8_org_1_admin_9_password' since
115
+ // it may contain secret" in the create-organization job): a job output whose
116
+ // value contains a masked secret is dropped, and a freshly generated Console
117
+ // password can contain one by chance -- this repo already has a masked value
118
+ // two characters long, see the counting trap in
119
+ // docs/saas-login-investigation.md. Which password it hits is luck, so the
120
+ // job cannot be trusted to have delivered them all. Say so here, in one
121
+ // second, instead of spending the suite's budget proving it through the UI.
122
+ if (!testUser?.username || !testUser?.password) {
123
+ const missing = [
124
+ testUser?.username ? null : 'username',
125
+ testUser?.password ? null : 'password',
126
+ ]
127
+ .filter(Boolean)
128
+ .join(' and ');
129
+ throw new Error(`Cannot log in: this test user has no ${missing}. The credential never ` +
130
+ 'reached the runner -- check the create-organization job for a ' +
131
+ '"Skip output ... since it may contain secret" warning on the ' +
132
+ 'matching C8_*_PASSWORD output.');
133
+ }
105
134
  let lastError;
106
135
  // `timeout` is a stable per-worker identifier (derived from workerIndex),
107
136
  // and a worker is a long-lived process across every test/repeat it runs, so
@@ -58,11 +58,14 @@ _8_10_1.test.describe('Web Modeler User Flow Tests', () => {
58
58
  await (0, test_1.expect)(homePage.camundaComponentsButton).toBeVisible({
59
59
  timeout: 120000,
60
60
  });
61
- await appsPage.clickCamundaApps();
62
- await appsPage.clickModeler();
63
- await (0, test_1.expect)(modelerHomePage.modelerPageBanner).toBeVisible({
64
- timeout: 180000,
65
- });
61
+ // Gate on the projects list, not on the Modeler banner alone: Modeler
62
+ // renders that banner on its own login page too, so a hand-off whose
63
+ // `POST /api/internal/login` 500s -- which makes hub log the user
64
+ // straight back out -- passed this step and failed two steps later as an
65
+ // unclickable project tile. openModelerWithRetry re-runs the handshake,
66
+ // and with it the internal login, when Modeler is not actually signed
67
+ // in.
68
+ await appsPage.openModelerWithRetry(modelerHomePage.modelerPageBanner, 90000, 3, modelerHomePage.createNewProjectButton);
66
69
  });
67
70
  await _8_10_1.test.step('Open Cross Component Test Project', async () => {
68
71
  await modelerHomePage.clickCrossComponentProjectFolder();
@@ -2,4 +2,5 @@ import { Locator } from '@playwright/test';
2
2
  export declare const submitModalWithRetry: (submitButton: Locator, modal: Locator, options?: {
3
3
  maxRetries?: number;
4
4
  closeTimeout?: number;
5
+ settleTimeout?: number;
5
6
  }) => Promise<void>;
@@ -9,9 +9,29 @@ const test_1 = require("@playwright/test");
9
9
  // the form fields remain valid, so re-submitting recovers from the transient
10
10
  // blip. A persistent failure exhausts the retries and is surfaced to the caller.
11
11
  const submitModalWithRetry = async (submitButton, modal, options) => {
12
- const { maxRetries = 3, closeTimeout = 20000 } = options || {};
12
+ const { maxRetries = 3, closeTimeout = 20000, settleTimeout = 30000, } = options || {};
13
13
  let lastError;
14
14
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
15
+ // A submit that is still in flight keeps the primary button disabled --
16
+ // hub's process-application creation modal renders
17
+ // `primaryButtonDisabled={!hasValidName || isSubmitting}` -- so a blind
18
+ // retry click waits out its whole action timeout on a button that was
19
+ // never going to accept it ("locator resolved to <button disabled ...>,
20
+ // element is not enabled"). Let the previous submit settle first: either
21
+ // it lands and closes the modal, or the button comes back enabled and the
22
+ // retry is a real retry.
23
+ if (attempt > 1) {
24
+ let enabled = true;
25
+ try {
26
+ await (0, test_1.expect)(submitButton).toBeEnabled({ timeout: settleTimeout });
27
+ }
28
+ catch {
29
+ enabled = false;
30
+ }
31
+ if (!enabled && !(await modal.isVisible())) {
32
+ return;
33
+ }
34
+ }
15
35
  await submitButton.click();
16
36
  try {
17
37
  await (0, test_1.expect)(modal).not.toBeVisible({ timeout: closeTimeout });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camunda/e2e-test-suite",
3
- "version": "0.0.1203",
3
+ "version": "0.0.1204",
4
4
  "description": "End-to-end test helpers for Camunda 8",
5
5
  "repository": {
6
6
  "type": "git",