@camunda/e2e-test-suite 0.0.1203 → 0.0.1205
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.
- package/dist/pages/8.10/AppsPage.d.ts +1 -1
- package/dist/pages/8.10/AppsPage.js +25 -3
- package/dist/pages/8.10/FormJsPage.js +28 -12
- package/dist/pages/8.10/ModelerCreatePage.d.ts +3 -0
- package/dist/pages/8.10/ModelerCreatePage.js +81 -3
- package/dist/pages/8.10/ModelerHomePage.d.ts +1 -0
- package/dist/pages/8.10/ModelerHomePage.js +100 -8
- package/dist/pages/8.10/ProcessApplicationPage.d.ts +2 -1
- package/dist/pages/8.10/ProcessApplicationPage.js +44 -5
- package/dist/pages/8.10/UtilitiesPage.js +29 -0
- package/dist/tests/8.10/web-modeler-user-flows.spec.js +8 -5
- package/dist/utils/apiHelpers.js +14 -3
- package/dist/utils/assertionHelpers/submitModalWithRetry.d.ts +1 -0
- package/dist/utils/assertionHelpers/submitModalWithRetry.js +21 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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 (
|
|
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 (
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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();
|
package/dist/utils/apiHelpers.js
CHANGED
|
@@ -772,6 +772,16 @@ async function performOptimizeInteractiveLogin(page, callerLabel, verifySession)
|
|
|
772
772
|
if (loginAttempt < maxLoginAttempts - 1) {
|
|
773
773
|
const rateLimited = String(error).includes(utils_1.AUTH0_RATE_LIMIT);
|
|
774
774
|
const unauthenticated = String(error).includes(OPTIMIZE_SESSION_UNAUTHENTICATED);
|
|
775
|
+
// loginToApp rethrows a bare Playwright TimeoutError when its own
|
|
776
|
+
// page.waitForURL never sees the post-login redirect land (run
|
|
777
|
+
// 35340608406, optimize-collections-api.spec.ts "Delete non-existent
|
|
778
|
+
// collection"). That is the same sso-callback rejection burst the
|
|
779
|
+
// "unauthenticated" branch below backs off for -- Auth0 answered the
|
|
780
|
+
// login but the redirect back to Optimize never completed -- so it
|
|
781
|
+
// needs the same growing backoff, not the flat 10-20s fallback below
|
|
782
|
+
// meant for genuinely unclassified errors. Without this, that attempt
|
|
783
|
+
// just retried straight back into the same burst.
|
|
784
|
+
const navigationTimeout = String(error).includes('waitForURL');
|
|
775
785
|
console.warn(`${callerLabel}: login attempt ${loginAttempt + 1} failed, retrying after backoff...`, error);
|
|
776
786
|
await page.goto('about:blank');
|
|
777
787
|
if (rateLimited) {
|
|
@@ -780,7 +790,7 @@ async function performOptimizeInteractiveLogin(page, callerLabel, verifySession)
|
|
|
780
790
|
// the same window and burns the attempt budget for nothing.
|
|
781
791
|
await (0, randomSleep_1.randomSleep)(60000, 90000);
|
|
782
792
|
}
|
|
783
|
-
else if (unauthenticated) {
|
|
793
|
+
else if (unauthenticated || navigationTimeout) {
|
|
784
794
|
// The rejected callbacks come in bursts lasting tens of seconds
|
|
785
795
|
// (every login inside the burst window fails, isolated ones all
|
|
786
796
|
// succeed), so back off past the burst rather than retrying inside
|
|
@@ -789,8 +799,9 @@ async function performOptimizeInteractiveLogin(page, callerLabel, verifySession)
|
|
|
789
799
|
// the same distribution at the same moment, so a fixed band keeps
|
|
790
800
|
// the herd synchronised and they re-collide on the next attempt.
|
|
791
801
|
// Growing the ceiling fans them out while the floor still lets a
|
|
792
|
-
// lucky worker recover quickly. Worst case
|
|
793
|
-
//
|
|
802
|
+
// lucky worker recover quickly. Worst case (3 retries at the
|
|
803
|
+
// ceiling, plus the initial stagger) stays under 6 minutes, well
|
|
804
|
+
// inside the 12-minute test timeout.
|
|
794
805
|
await (0, randomSleep_1.randomSleep)(20000, 45000 + loginAttempt * 45000);
|
|
795
806
|
}
|
|
796
807
|
else {
|
|
@@ -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 });
|