@camunda/e2e-test-suite 0.0.1478 → 0.0.1480

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.
@@ -209,7 +209,21 @@ class FormJsPage {
209
209
  throw new Error(`The deploy dialog does not offer "${clusterName}": its stage ` +
210
210
  'binding did not take. It will be rebound on the next attempt.');
211
211
  }
212
- return;
212
+ // The probe above runs the instant the dialog opens, before its cluster
213
+ // list has rendered, so a selectable list can be mistaken for the
214
+ // auto-selecting one. The dialog then deploys to whichever cluster it
215
+ // pre-selected: a nightly trace shows the form going to "Test Cluster"
216
+ // while "AWS Cluster" was requested, so the user task linking it never
217
+ // reached Tasklist. Re-probe now that the name has rendered and select
218
+ // the cluster explicitly when it turns out to be a list row.
219
+ const isSelectableRow = await legacyClusterToggle
220
+ .first()
221
+ .waitFor({ state: 'visible', timeout: 5000 })
222
+ .then(() => true)
223
+ .catch(() => false);
224
+ if (!isSelectableRow) {
225
+ return;
226
+ }
213
227
  }
214
228
  // "HealthydevManage" was the Carbon row's chrome concatenated together.
215
229
  // The design-system dialog renders the cluster row as
@@ -879,7 +879,20 @@ class ModelerCreatePage {
879
879
  throw new Error(`The deploy dialog does not offer "${clusterName}": its stage ` +
880
880
  'binding did not take. It will be rebound on the next attempt.');
881
881
  }
882
- return;
882
+ // The probe above runs the instant the dialog opens, before its cluster
883
+ // list has rendered, so a selectable list can be mistaken for the
884
+ // auto-selecting one and the run goes to whichever cluster the dialog
885
+ // pre-selected (seen in FormJsPage.selectCluster's nightly trace).
886
+ // Re-probe now that the name has rendered and select the cluster
887
+ // explicitly when it turns out to be a list row.
888
+ const isSelectableRow = await legacyClusterToggle
889
+ .first()
890
+ .waitFor({ state: 'visible', timeout: 5000 })
891
+ .then(() => true)
892
+ .catch(() => false);
893
+ if (!isSelectableRow) {
894
+ return;
895
+ }
883
896
  }
884
897
  const healthyRegex = new RegExp(`${clusterName}.*?Healthy`);
885
898
  const clusterLabel = this.dialog
@@ -1169,7 +1169,19 @@ class ModelerHomePage {
1169
1169
  // rows. Fall back to clicking the row itself (it is `cursor-pointer` and
1170
1170
  // handles the row activation) when the id is not exposed.
1171
1171
  const rowMenuTrigger = projectEntryRow.locator('[data-test="project-row-menu-trigger"]');
1172
- if (await this.becameVisible(rowMenuTrigger, 10000)) {
1172
+ let rowVisible = await this.becameVisible(rowMenuTrigger, 10000);
1173
+ // Hub can drop the session while the list loads (its projects call answers
1174
+ // 230 and the SPA bounces to /login), so the probe above misses a project
1175
+ // that exists and the create branch below adds an empty duplicate -- in
1176
+ // which the caller's diagram can never be found. Re-sign-in (often a
1177
+ // silent SSO round trip that has already finished by now, so do not gate
1178
+ // on recoverModelerSession's result) returns the browser to this list;
1179
+ // look again before creating anything.
1180
+ if (!rowVisible) {
1181
+ await this.recoverModelerSession();
1182
+ rowVisible = await this.becameVisible(rowMenuTrigger, 30000);
1183
+ }
1184
+ if (rowVisible) {
1173
1185
  const projectId = await rowMenuTrigger.getAttribute('data-project-id');
1174
1186
  const workspaceId = new URL(this.page.url()).pathname.match(/\/workspaces\/([^/]+)/)?.[1];
1175
1187
  if (workspaceId) {
@@ -6,6 +6,7 @@ declare class ConnectorTemplatePage {
6
6
  constructor(page: Page);
7
7
  clickPublishToOrgButton(): Promise<void>;
8
8
  clickPublishButton(): Promise<void>;
9
+ publishImportedTemplateToWorkspace(version: number): Promise<void>;
9
10
  publishConnector(): Promise<void>;
10
11
  }
11
12
  export { ConnectorTemplatePage };
@@ -20,6 +20,21 @@ class ConnectorTemplatePage {
20
20
  async clickPublishButton() {
21
21
  await this.publishButton.click({ timeout: 90000 });
22
22
  }
23
+ async publishImportedTemplateToWorkspace(version) {
24
+ await this.page.getByRole('button', { name: 'Publish', exact: true }).click();
25
+ await this.page
26
+ .getByRole('menuitem', { name: 'Publish to workspace', exact: true })
27
+ .click();
28
+ const dialog = this.page.getByRole('dialog').filter({
29
+ has: this.page.getByRole('heading', {
30
+ name: 'Publish version to workspace',
31
+ exact: true,
32
+ }),
33
+ });
34
+ await (0, test_1.expect)(dialog.getByRole('spinbutton', { name: 'Version number', exact: true })).toHaveValue(String(version));
35
+ await dialog.getByRole('button', { name: 'Publish', exact: true }).click();
36
+ await (0, test_1.expect)(dialog).toBeHidden({ timeout: 60000 });
37
+ }
23
38
  async publishConnector() {
24
39
  try {
25
40
  await (0, test_1.expect)(this.publishToOrgButton).toBeVisible({ timeout: 15000 });
@@ -175,6 +175,17 @@ declare class ModelerCreatePage {
175
175
  openChangeElementPopupForElement(elementId: string): Promise<void>;
176
176
  private clickChangeElementTemplateOption;
177
177
  assertChangeElementTemplateListed(option: Locator, searchTerm: string, elementId: string): Promise<void>;
178
+ appendTemplateTarget(previousElementId: string, type: 'task' | 'receiveTask' | 'intermediateCatchEvent'): Promise<string>;
179
+ applyImportedTemplate(elementId: string, template: {
180
+ id: string;
181
+ name: string;
182
+ }): Promise<void>;
183
+ assertExportedTemplateBindings(bindings: {
184
+ elementId: string;
185
+ id: string;
186
+ version: number;
187
+ elementType: string;
188
+ }[]): Promise<void>;
178
189
  changeElementToAdHocSubProcess(elementId: string): Promise<void>;
179
190
  applyAiAgentSubProcessTemplate(): Promise<void>;
180
191
  clickWebhookMessageStartEventConnectorOption(): Promise<void>;
@@ -1424,6 +1424,84 @@ class ModelerCreatePage {
1424
1424
  await (0, test_1.expect)(option).toBeVisible({ timeout: 30000 });
1425
1425
  await (0, test_1.expect)(option).toBeEnabled({ timeout: 30000 });
1426
1426
  }
1427
+ async appendTemplateTarget(previousElementId, type) {
1428
+ const previous = this.page.locator(`.djs-shape[data-element-id="${previousElementId}"]`);
1429
+ await this.page.keyboard.press('Escape');
1430
+ if (!(await previous.evaluate((shape) => shape.classList.contains('selected')))) {
1431
+ await previous.click();
1432
+ }
1433
+ await this.page
1434
+ .getByRole('button', {
1435
+ name: type === 'intermediateCatchEvent'
1436
+ ? 'Append intermediate/boundary event'
1437
+ : 'Append task',
1438
+ exact: true,
1439
+ })
1440
+ .click();
1441
+ const selected = this.page.locator('.djs-shape.selected');
1442
+ await (0, test_1.expect)(selected).not.toHaveAttribute('data-element-id', previousElementId);
1443
+ const elementId = await selected.getAttribute('data-element-id');
1444
+ if (!elementId)
1445
+ throw new Error('The appended template target has no ID.');
1446
+ if (type !== 'task') {
1447
+ await this.openChangeElementPopupForElement(elementId);
1448
+ await this.changeElementSearchInput.fill('');
1449
+ await this.changeElementSearchInput.pressSequentially(type === 'receiveTask'
1450
+ ? 'Receive task'
1451
+ : 'Message intermediate catch event');
1452
+ const entry = type === 'receiveTask'
1453
+ ? 'replace-with-receive-task'
1454
+ : 'replace-with-message-intermediate-catch';
1455
+ await this.page.locator(`.djs-popup [data-id="${entry}"]`).click();
1456
+ }
1457
+ return elementId;
1458
+ }
1459
+ async applyImportedTemplate(elementId, template) {
1460
+ const option = this.page.locator(`.djs-popup [data-id="replace.template-${template.id}"]`);
1461
+ await this.assertChangeElementTemplateListed(option, template.name, elementId);
1462
+ await option.click();
1463
+ await (0, test_1.expect)(this.changeElementSearchInput).toBeHidden();
1464
+ const selected = this.page.locator(`.djs-shape[data-element-id="${elementId}"]`);
1465
+ await (0, test_1.expect)(selected).toHaveClass(/(?:^|\s)selected(?:\s|$)/);
1466
+ const details = this.page.getByRole('region', { name: 'Details panel' });
1467
+ await (0, test_1.expect)(details
1468
+ .locator('[data-test="properties-panel-header-container"]')
1469
+ .getByText(template.name, { exact: true })).toBeVisible();
1470
+ await (0, test_1.expect)(details.getByText('Applied', { exact: true })).toBeVisible();
1471
+ await (0, test_1.expect)(this.templateNotFoundTag).toBeHidden();
1472
+ }
1473
+ async assertExportedTemplateBindings(bindings) {
1474
+ await this.page
1475
+ .getByRole('button', { name: 'Browse more actions', exact: true })
1476
+ .click();
1477
+ const downloadPromise = this.page.waitForEvent('download');
1478
+ await this.page
1479
+ .getByRole('menuitem', { name: 'Download as BPMN 2.0 XML', exact: true })
1480
+ .click();
1481
+ const download = await downloadPromise;
1482
+ const stream = await download.createReadStream();
1483
+ if (!stream)
1484
+ throw new Error('The BPMN download has no readable content.');
1485
+ const chunks = [];
1486
+ for await (const chunk of stream)
1487
+ chunks.push(Buffer.from(chunk));
1488
+ const xml = Buffer.concat(chunks).toString('utf8');
1489
+ const actual = await this.page.evaluate(({ xml, bindings }) => {
1490
+ const document = new DOMParser().parseFromString(xml, 'application/xml');
1491
+ if (document.querySelector('parsererror'))
1492
+ throw new Error('Invalid BPMN XML.');
1493
+ return bindings.map(({ elementId }) => {
1494
+ const element = Array.from(document.getElementsByTagName('*')).find((element) => element.getAttribute('id') === elementId);
1495
+ return {
1496
+ elementId,
1497
+ id: element?.getAttribute('zeebe:modelerTemplate'),
1498
+ version: Number(element?.getAttribute('zeebe:modelerTemplateVersion')),
1499
+ elementType: element?.localName,
1500
+ };
1501
+ });
1502
+ }, { xml, bindings });
1503
+ (0, test_1.expect)(actual).toEqual(bindings);
1504
+ }
1427
1505
  // Turns the appended task into an ad-hoc sub-process through the
1428
1506
  // change-element popup. The popup is filtered first: it virtualizes its list.
1429
1507
  async changeElementToAdHocSubProcess(elementId) {
@@ -1,3 +1,4 @@
1
+ /// <reference types="node" />
1
2
  import { Browser, Page, Locator } from '@playwright/test';
2
3
  declare class ModelerHomePage {
3
4
  private page;
@@ -75,7 +76,7 @@ declare class ModelerHomePage {
75
76
  private openWorkspaceTile;
76
77
  private openOrCreateLegacyFolder;
77
78
  private dismissModals;
78
- clickProcessDiagram(name: string): Promise<void>;
79
+ clickProcessDiagram(name: string, exact?: boolean): Promise<void>;
79
80
  clickHomeBreadcrumb(): Promise<void>;
80
81
  clickChooseBpmnTemplateButton(): Promise<void>;
81
82
  clickDiagramTypeDropdown(): Promise<void>;
@@ -86,7 +87,15 @@ declare class ModelerHomePage {
86
87
  private waitForEntityCrumb;
87
88
  clickProjectBreadcrumb(): Promise<void>;
88
89
  clickWorkspaceBreadcrumb(): Promise<void>;
89
- enterResourceContainer(name?: string): Promise<void>;
90
+ enterResourceContainer(name?: string, options?: {
91
+ configureEnvironment?: boolean;
92
+ }): Promise<void>;
93
+ uploadTemplateFiles(files: {
94
+ name: string;
95
+ buffer: Buffer;
96
+ }[]): Promise<void>;
97
+ private importedTemplateRow;
98
+ openImportedTemplate(resourceName: string): Promise<void>;
90
99
  private clickCreateProcessApplication;
91
100
  private ensureInResourceContainer;
92
101
  returnToResourceContainer(): Promise<void>;
@@ -669,16 +669,20 @@ class ModelerHomePage {
669
669
  // entirely (to Optimize, to Operate) and come back via
670
670
  // clickCrossComponentProjectFolder(), which lands at workspace root -- the
671
671
  // resource is one level down, so re-enter the container first.
672
- async clickProcessDiagram(name) {
672
+ async clickProcessDiagram(name, exact = false) {
673
673
  await this.ensureInResourceContainer();
674
674
  // A Design System table row opens through a link laid over the row, which
675
675
  // sits on top of the title text and takes its clicks -- so click the link.
676
676
  // The link comes before the title in DOM order, so `first()` picks it
677
677
  // whenever it exists; the title stays as the fallback for the classic table.
678
- const row = this.rows.filter({ hasText: name });
678
+ const row = exact
679
+ ? this.importedTemplateRow(name)
680
+ : this.rows.filter({ hasText: name });
681
+ if (exact)
682
+ await (0, test_1.expect)(row).toHaveCount(1);
679
683
  const process = row
680
- .getByRole('link', { name })
681
- .or(row.getByTitle(name))
684
+ .getByRole('link', { name, exact })
685
+ .or(row.getByTitle(name, { exact }))
682
686
  .first();
683
687
  await (0, test_1.expect)(process).toBeVisible({ timeout: 90000 });
684
688
  await process.click();
@@ -877,7 +881,7 @@ class ModelerHomePage {
877
881
  // One container per test rather than one shared with the seeded workspace:
878
882
  // deploying is process-application-wide, so a shared container would let one
879
883
  // spec's deploy pick up every other spec's half-modelled diagram.
880
- async enterResourceContainer(name) {
884
+ async enterResourceContainer(name, options = {}) {
881
885
  if (this.processApplicationUrl) {
882
886
  const stillInside = await this.becameVisible(this.processApplicationPage.breadcrumb, 5000);
883
887
  if (!stillInside) {
@@ -902,7 +906,41 @@ class ModelerHomePage {
902
906
  // this connection step (or failing on the warning). ensureStageCluster()
903
907
  // memoizes per test, so runProcessInstance()'s own call is a no-op after
904
908
  // this.
905
- await this.processApplicationPage.ensureStageCluster(process.env.MODELER_CLUSTER_NAME);
909
+ if (options.configureEnvironment !== false) {
910
+ await this.processApplicationPage.ensureStageCluster(process.env.MODELER_CLUSTER_NAME);
911
+ }
912
+ }
913
+ async uploadTemplateFiles(files) {
914
+ await (0, test_1.expect)(this.processApplicationPage.createNewDropdown).toBeVisible({
915
+ timeout: 30000,
916
+ });
917
+ for (const file of files) {
918
+ await (0, test_1.expect)(this.importedTemplateRow(file.name.replace(/\.json$/, ''))).toHaveCount(0);
919
+ }
920
+ await this.processApplicationPage.createNewDropdown.click();
921
+ const chooserPromise = this.page.waitForEvent('filechooser');
922
+ await this.page
923
+ .getByRole('menuitem', { name: 'Upload files', exact: true })
924
+ .click();
925
+ await (await chooserPromise).setFiles(files.map((file) => ({ ...file, mimeType: 'application/json' })));
926
+ // The batch handler can report success for a partial upload.
927
+ for (const file of files) {
928
+ const row = this.importedTemplateRow(file.name.replace(/\.json$/, ''));
929
+ await (0, test_1.expect)(row).toHaveCount(1, { timeout: 60000 });
930
+ await (0, test_1.expect)(row).toBeVisible({ timeout: 60000 });
931
+ }
932
+ }
933
+ importedTemplateRow(name) {
934
+ return this.rows.filter({
935
+ has: this.page
936
+ .getByRole('link', { name, exact: true })
937
+ .or(this.page.getByTitle(name, { exact: true }))
938
+ .or(this.page.getByRole('cell', { name, exact: true })),
939
+ });
940
+ }
941
+ async openImportedTemplate(resourceName) {
942
+ await this.clickProcessDiagram(resourceName, true);
943
+ await this.page.waitForURL(/\/element-templates\//, { timeout: 30000 });
906
944
  }
907
945
  // Which control creates a process application depends on the hub version
908
946
  // under test. Since camunda/camunda-hub#25831 it is a plain primary button on
@@ -351,6 +351,108 @@ test.describe('Connectors User Flow Tests @tasklistV2', () => {
351
351
  await modelerCreatePage.applyAiAgentSubProcessTemplate();
352
352
  });
353
353
  });
354
+ test('Agentic Connector Template File Import User Flow', async ({ request, modelerHomePage, modelerCreatePage, connectorTemplatePage, }) => {
355
+ const suffix = await (0, _setup_1.generateRandomStringAsync)(8);
356
+ const source = 'https://raw.githubusercontent.com/camunda/connectors/' +
357
+ '44784a99762663e60b5224578645ecbff4a1065f/' +
358
+ 'connectors/agentic-ai/connector-agentic-ai/element-templates/';
359
+ const templates = [
360
+ {
361
+ file: 'agenticai-mcp-client-outbound-connector.json',
362
+ id: 'io.camunda.connectors.agenticai.mcp.client.v0',
363
+ name: 'MCP Client',
364
+ version: 3,
365
+ engine: '^8.9',
366
+ target: 'task',
367
+ },
368
+ {
369
+ file: 'agenticai-mcp-remote-client-outbound-connector.json',
370
+ id: 'io.camunda.connectors.agenticai.mcp.remoteclient.v0',
371
+ name: 'MCP Remote Client',
372
+ version: 3,
373
+ engine: '^8.9',
374
+ target: 'task',
375
+ },
376
+ {
377
+ file: 'agenticai-adhoctoolsschema-outbound-connector.json',
378
+ id: 'io.camunda.connectors.agenticai.adhoctoolsschema.v1',
379
+ name: 'Ad-hoc tools schema',
380
+ version: 2,
381
+ engine: '^8.8',
382
+ target: 'task',
383
+ },
384
+ {
385
+ file: 'agenticai-a2a-client-webhook-inbound-connector-receive.json',
386
+ id: 'io.camunda.connectors.agenticai.a2a.client.webhook.receive.v0',
387
+ name: 'A2A Client Webhook Receive Task Connector (early access)',
388
+ version: 1,
389
+ engine: '^8.9',
390
+ target: 'receiveTask',
391
+ },
392
+ {
393
+ file: 'agenticai-a2a-client-webhook-inbound-connector-intermediate.json',
394
+ id: 'io.camunda.connectors.agenticai.a2a.client.webhook.intermediate.v0',
395
+ name: 'A2A Client Webhook Intermediate Catch Event Connector (early access)',
396
+ version: 1,
397
+ engine: '^8.9',
398
+ target: 'intermediateCatchEvent',
399
+ },
400
+ ];
401
+ const files = await test.step('Download pinned compatible template files', async () => {
402
+ const files = [];
403
+ for (const template of templates) {
404
+ const response = await request.get(source + template.file);
405
+ (0, test_1.expect)(response.ok(), `Download ${template.file}: ${response.status()}`).toBe(true);
406
+ const buffer = await response.body();
407
+ const content = JSON.parse(buffer.toString('utf8'));
408
+ (0, test_1.expect)(content).toMatchObject({
409
+ id: template.id,
410
+ name: template.name,
411
+ version: template.version,
412
+ engines: { camunda: template.engine },
413
+ });
414
+ (0, test_1.expect)(content).not.toHaveProperty('deprecated');
415
+ files.push({ name: `${template.id}_${suffix}.json`, buffer });
416
+ }
417
+ return files;
418
+ });
419
+ await test.step('Import files into a fresh Workspace project', async () => {
420
+ await modelerHomePage.createDisposableWorkspace(`Template import ${suffix}`);
421
+ await modelerHomePage.enterResourceContainer(`Template import ${suffix}`, {
422
+ configureEnvironment: false,
423
+ });
424
+ await modelerHomePage.uploadTemplateFiles(files);
425
+ });
426
+ await test.step('Publish each imported draft to this Workspace only', async () => {
427
+ for (const template of templates) {
428
+ await modelerHomePage.openImportedTemplate(`${template.id}_${suffix}`);
429
+ await connectorTemplatePage.publishImportedTemplateToWorkspace(template.version);
430
+ await modelerHomePage.returnToResourceContainer();
431
+ }
432
+ });
433
+ await modelerHomePage.clickDiagramTypeDropdown();
434
+ await modelerHomePage.clickBpmnTemplateOption();
435
+ await modelerCreatePage.enterDiagramName(`Imported templates ${suffix}`);
436
+ await modelerCreatePage.setEngineVersion('8.10');
437
+ let previousElementId = 'StartEvent_1';
438
+ const bindings = [];
439
+ for (const template of templates) {
440
+ await test.step(`Apply ${template.name} to its supported element`, async () => {
441
+ const elementId = await modelerCreatePage.appendTemplateTarget(previousElementId, template.target);
442
+ await modelerCreatePage.applyImportedTemplate(elementId, template);
443
+ bindings.push({
444
+ elementId,
445
+ id: template.id,
446
+ version: template.version,
447
+ elementType: template.target === 'task' ? 'serviceTask' : template.target,
448
+ });
449
+ previousElementId = elementId;
450
+ });
451
+ }
452
+ await test.step('Verify exact template IDs and versions in the exported diagram', async () => {
453
+ await modelerCreatePage.assertExportedTemplateBindings(bindings);
454
+ });
455
+ });
354
456
  //Skipped due to bug 1252: https://github.com/camunda/c8-cross-component-e2e-tests/issues/1252
355
457
  test.skip('Connectors Timer Event User Flow', async ({ page, modelerHomePage, modelerCreatePage, connectorMarketplacePage, connectorTemplatePage, navigationPage, operateProcessInstancePage, operateHomePage, operateProcessesPage, }) => {
356
458
  test.slow();
@@ -273,31 +273,46 @@ async function authSaasAPI(audience, clusterType) {
273
273
  // "You are being rate limited" page when many specs fetch a token at once
274
274
  // (each MCP spec authenticates in beforeAll). Retry with exponential
275
275
  // backoff so the rate-limit window clears instead of failing the suite.
276
- const maxAttempts = 5;
277
- const baseDelayMs = 3000;
276
+ // While throttled, Cloudflare may also hold the connection open, so a
277
+ // request timeout is retried like a 429 rather than aborting the loop.
278
+ const maxAttempts = 7;
279
+ const baseDelayMs = 5000;
280
+ const maxDelayMs = 60000;
281
+ const requestTimeoutMs = 30000;
278
282
  let lastErrorMessage = '';
279
283
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
280
- const tokenResponse = await apiRequestContext.post(creds.authUrl, {
281
- headers: {
282
- 'Content-Type': 'application/json',
283
- },
284
- data: {
285
- audience: tokenAudience,
286
- client_id: creds.clientId,
287
- client_secret: creds.clientSecret,
288
- grant_type: 'client_credentials',
289
- },
290
- });
291
- if (tokenResponse.status() === 200) {
292
- const tokenJson = await tokenResponse.json();
293
- return `Bearer ${tokenJson.access_token}`;
284
+ let retryAfterMs = 0;
285
+ try {
286
+ const tokenResponse = await apiRequestContext.post(creds.authUrl, {
287
+ headers: {
288
+ 'Content-Type': 'application/json',
289
+ },
290
+ data: {
291
+ audience: tokenAudience,
292
+ client_id: creds.clientId,
293
+ client_secret: creds.clientSecret,
294
+ grant_type: 'client_credentials',
295
+ },
296
+ timeout: requestTimeoutMs,
297
+ });
298
+ if (tokenResponse.status() === 200) {
299
+ const tokenJson = await tokenResponse.json();
300
+ return `Bearer ${tokenJson.access_token}`;
301
+ }
302
+ const retryAfter = Number(tokenResponse.headers()['retry-after']);
303
+ if (Number.isFinite(retryAfter) && retryAfter > 0) {
304
+ retryAfterMs = Math.min(retryAfter * 1000, maxDelayMs);
305
+ }
306
+ const errorBody = await tokenResponse.text();
307
+ lastErrorMessage = `HTTP ${tokenResponse.status()} - ${errorBody.slice(0, 200)}`;
308
+ }
309
+ catch (error) {
310
+ lastErrorMessage = error.message.split('\n')[0];
294
311
  }
295
- const errorBody = await tokenResponse.text();
296
- lastErrorMessage = `HTTP ${tokenResponse.status()} - ${errorBody.slice(0, 200)}`;
297
312
  if (attempt < maxAttempts) {
313
+ const backoffMs = Math.min(baseDelayMs * 2 ** (attempt - 1), maxDelayMs);
298
314
  console.warn(`authSaasAPI attempt ${attempt}/${maxAttempts} failed to fetch access token (${lastErrorMessage}); retrying after backoff...`);
299
- await new Promise((r) => setTimeout(r, baseDelayMs * 2 ** (attempt - 1)));
300
- continue;
315
+ await new Promise((r) => setTimeout(r, Math.max(backoffMs, retryAfterMs)));
301
316
  }
302
317
  }
303
318
  throw new Error(`Failed to fetch access token. Response: ${lastErrorMessage}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camunda/e2e-test-suite",
3
- "version": "0.0.1478",
3
+ "version": "0.0.1480",
4
4
  "description": "End-to-end test helpers for Camunda 8",
5
5
  "repository": {
6
6
  "type": "git",