@camunda/e2e-test-suite 0.0.1067 → 0.0.1069

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.
@@ -43,6 +43,7 @@ declare class OCIdentityRolesPage {
43
43
  }): Promise<void>;
44
44
  private isRolesListOpen;
45
45
  private openRolesList;
46
+ private recoverToRolesList;
46
47
  clickAdminRole(): Promise<void>;
47
48
  assignUserToRole(userId: string): Promise<void>;
48
49
  clickAssignUserButton(): Promise<void>;
@@ -6,7 +6,7 @@ const UtilitiesPage_1 = require("../SM-8.10/UtilitiesPage");
6
6
  const expectLocatorWithPagination_1 = require("../../utils/assertionHelpers/expectLocatorWithPagination");
7
7
  const findLocatorWithPagination_1 = require("../../utils/assertionHelpers/findLocatorWithPagination");
8
8
  const submitModalWithRetry_1 = require("../../utils/assertionHelpers/submitModalWithRetry");
9
- const sleep_1 = require("../../utils/sleep");
9
+ const SEARCH_MAPPING_RULE_LABEL = /^(Search by mapping rule ID|searchByMappingRuleId)$/;
10
10
  class OCIdentityRolesPage {
11
11
  page;
12
12
  selectResourceTypeTab;
@@ -53,11 +53,14 @@ class OCIdentityRolesPage {
53
53
  // (role="combobox"), not the searchbox the pre design-system modal
54
54
  // rendered; the Mapping rules list page keeps a page-level searchbox with
55
55
  // the same accessible name, so scope this to the dialog and accept either
56
- // role.
56
+ // role. The modal's field resolves its placeholder through the
57
+ // `entitySelection` i18n namespace, which carries no `searchByMappingRuleId`
58
+ // entry, so it is labelled with the raw key instead of the translated
59
+ // string the list page renders - match either form.
57
60
  this.mappingSearchBox = this.assignMappingModal
58
- .getByRole('combobox', { name: 'Search by mapping rule ID' })
61
+ .getByRole('combobox', { name: SEARCH_MAPPING_RULE_LABEL })
59
62
  .or(this.assignMappingModal.getByRole('searchbox', {
60
- name: 'Search by mapping rule ID',
63
+ name: SEARCH_MAPPING_RULE_LABEL,
61
64
  }))
62
65
  .first();
63
66
  this.createRoleButton = page.getByRole('button', {
@@ -145,8 +148,7 @@ class OCIdentityRolesPage {
145
148
  .catch(() => false);
146
149
  if (gotNotFound) {
147
150
  console.warn(`Attempt ${attempt + 1}: Role detail page returned 404 for "${roleId}", retrying...`);
148
- await this.page.goBack();
149
- await (0, sleep_1.sleep)(3000);
151
+ await this.recoverToRolesList();
150
152
  continue;
151
153
  }
152
154
  await this.clickMappingRulesTab();
@@ -157,9 +159,13 @@ class OCIdentityRolesPage {
157
159
  await (0, test_1.expect)(this.assignMappingModal).toBeVisible({ timeout: 60000 });
158
160
  // The design-system search input is a cmdk combobox that only issues
159
161
  // its server-side query on real keystrokes, so type the id in rather
160
- // than setting the value in one shot.
161
- await this.mappingSearchBox.click();
162
- await this.mappingSearchBox.pressSequentially(mappingId);
162
+ // than setting the value in one shot. Budgeted like every other step
163
+ // here rather than inheriting the 10s actionTimeout: the field mounts
164
+ // with the dialog it was just asserted visible in.
165
+ await this.mappingSearchBox.click({ timeout: 60000 });
166
+ await this.mappingSearchBox.pressSequentially(mappingId, {
167
+ timeout: 60000,
168
+ });
163
169
  await this.assignMappingRuleOption(mappingId).click({ timeout: 30000 });
164
170
  await this.assignMappingModalAssignRuleButton.click();
165
171
  return;
@@ -167,8 +173,7 @@ class OCIdentityRolesPage {
167
173
  catch (error) {
168
174
  if (attempt < maxRetries - 1) {
169
175
  console.warn(`Attempt ${attempt + 1} failed to assign mapping rule to role "${roleId}". Retrying...`);
170
- await this.page.goBack();
171
- await (0, sleep_1.sleep)(2000);
176
+ await this.recoverToRolesList();
172
177
  }
173
178
  else {
174
179
  throw error;
@@ -249,6 +254,28 @@ class OCIdentityRolesPage {
249
254
  await this.rolesNavTab.click({ timeout });
250
255
  await (0, test_1.expect)(this.createRoleButton.first()).toBeVisible({ timeout });
251
256
  }
257
+ // `page.goBack()` is one history entry, and the role detail page's tab routes
258
+ // are entries of their own, so backing out of a failed assign-mapping step
259
+ // landed on the same role's Users tab - where the next attempt's roles-table
260
+ // lookup could only time out. Re-enter the list through the nav instead, and
261
+ // dismiss the dialog first: an open design-system dialog sets
262
+ // `pointer-events: none` on the body, which makes the nav link unclickable.
263
+ async recoverToRolesList() {
264
+ const modalOpen = await this.assignMappingModal
265
+ .isVisible()
266
+ .catch(() => false);
267
+ if (modalOpen) {
268
+ await this.page.keyboard.press('Escape');
269
+ const closed = await this.assignMappingModal
270
+ .waitFor({ state: 'hidden', timeout: 15000 })
271
+ .then(() => true)
272
+ .catch(() => false);
273
+ if (!closed) {
274
+ await this.page.reload({ waitUntil: 'domcontentloaded' });
275
+ }
276
+ }
277
+ await this.openRolesList(60000);
278
+ }
252
279
  async clickAdminRole() {
253
280
  // The roles table is fetched after the Roles tab click, and other specs in
254
281
  // the nightly add roles that can push `admin` onto a later page, so a bare
@@ -32,8 +32,7 @@ _8_10_1.test.describe('Cluster Setup Tests', () => {
32
32
  await clusterPage.assertClusterHealthyStatusWithRetry(clusterName, 120000, 1500000);
33
33
  }
34
34
  else {
35
- await (0, consoleApiHelpers_1.deleteClusterViaApi)(page, clusterName);
36
- const clusterUuid = await (0, consoleApiHelpers_1.createClusterViaApi)(page, clusterName);
35
+ const clusterUuid = await (0, consoleApiHelpers_1.ensureClusterViaApi)(page, clusterName);
37
36
  await (0, consoleApiHelpers_1.waitForClusterHealthyViaApi)(page, clusterUuid);
38
37
  await homePage.clickClusters();
39
38
  }
@@ -63,8 +62,7 @@ _8_10_1.test.describe('Cluster Setup Tests', () => {
63
62
  const apiClientName = 'Test_API_Client' + (await (0, _setup_1.generateRandomStringAsync)(3));
64
63
  const mcpClientName = 'MCP_Test_API_Client' + (await (0, _setup_1.generateRandomStringAsync)(3));
65
64
  await (0, UtilitiesPage_1.loginWithRetry)(page, loginPage, users_1.testUsers.mainUser, (testInfo.workerIndex + 1) * 1000);
66
- await (0, consoleApiHelpers_1.deleteClusterViaApi)(page, clusterName);
67
- const clusterUuid = await (0, consoleApiHelpers_1.createClusterViaApi)(page, clusterName);
65
+ const clusterUuid = await (0, consoleApiHelpers_1.ensureClusterViaApi)(page, clusterName);
68
66
  await (0, consoleApiHelpers_1.waitForClusterHealthyViaApi)(page, clusterUuid, 1500000);
69
67
  await homePage.clickClusters();
70
68
  await clusterPage.clickClusterLink(clusterName);
@@ -99,8 +97,7 @@ _8_10_1.test.describe('Cluster Setup Tests', () => {
99
97
  await clusterPage.assertClusterHealthyStatusWithRetry(clusterName, 120000, 1500000);
100
98
  }
101
99
  else {
102
- await (0, consoleApiHelpers_1.deleteClusterViaApi)(page, clusterName);
103
- const clusterUuid = await (0, consoleApiHelpers_1.createClusterViaApi)(page, clusterName, 'AWS');
100
+ const clusterUuid = await (0, consoleApiHelpers_1.ensureClusterViaApi)(page, clusterName, 'AWS');
104
101
  await (0, consoleApiHelpers_1.waitForClusterHealthyViaApi)(page, clusterUuid, 1500000);
105
102
  await homePage.clickClusters();
106
103
  }
@@ -64,18 +64,42 @@ async function getUserToken(page) {
64
64
  const TRANSIENT_API_STATUSES = [429, 500, 502, 503, 504];
65
65
  const API_MAX_ATTEMPTS = 5;
66
66
  // Overrides the global actionTimeout (10s) for Console API calls only: cluster
67
- // creation regularly needs longer than that to answer.
68
- const API_TIMEOUT_MS = 15000;
67
+ // listings answer in tens of milliseconds, so 15s is generous for a read.
68
+ const API_READ_TIMEOUT_MS = 15000;
69
+ // Cluster mutations are an order of magnitude slower than reads on Console:
70
+ // POST /clusters was measured at 8-12s and DELETE at 7-9s against INT. A 15s
71
+ // ceiling sat inside that band, so the client aborted requests the server went
72
+ // on to complete — leaving a cluster nobody was tracking.
73
+ const API_WRITE_TIMEOUT_MS = 45000;
74
+ // POST is the only non-idempotent verb used here: repeating it creates a second
75
+ // cluster under the same name. Let its timeout surface instead, so the caller
76
+ // can recover by looking the cluster up by name (see createClusterViaApi).
77
+ const TIMEOUT_RETRYABLE_METHODS = ['GET', 'PATCH', 'DELETE'];
69
78
  async function clusterApiRequest(page, method, url, data) {
70
79
  let response;
71
80
  for (let attempt = 1; attempt <= API_MAX_ATTEMPTS; attempt++) {
72
81
  const token = await getUserToken(page);
73
- response = await page.request.fetch(url, {
74
- method,
75
- headers: { Authorization: `Bearer ${token}` },
76
- timeout: API_TIMEOUT_MS,
77
- ...(data === undefined ? {} : { data }),
78
- });
82
+ try {
83
+ response = await page.request.fetch(url, {
84
+ method,
85
+ headers: { Authorization: `Bearer ${token}` },
86
+ timeout: method === 'GET' ? API_READ_TIMEOUT_MS : API_WRITE_TIMEOUT_MS,
87
+ ...(data === undefined ? {} : { data }),
88
+ });
89
+ }
90
+ catch (error) {
91
+ // A timeout or transport error throws rather than returning a status, so
92
+ // the status-based retry below never covered it and API_MAX_ATTEMPTS did
93
+ // not apply to the one failure mode that actually happens.
94
+ console.log(`Console API ${method} ${url} threw ${error.message} ` +
95
+ `(attempt ${attempt}/${API_MAX_ATTEMPTS})`);
96
+ if (!TIMEOUT_RETRYABLE_METHODS.includes(method) ||
97
+ attempt === API_MAX_ATTEMPTS) {
98
+ throw error;
99
+ }
100
+ await (0, sleep_1.sleep)(attempt * 10000);
101
+ continue;
102
+ }
79
103
  if (!TRANSIENT_API_STATUSES.includes(response.status()))
80
104
  return response;
81
105
  console.log(`Console API ${method} ${url} returned HTTP ${response.status()} ` +
@@ -113,8 +137,22 @@ async function findClusterByName(page, clusterName) {
113
137
  : data.clusters ?? [];
114
138
  return clusters.find((c) => c.name === clusterName);
115
139
  }
140
+ // A cluster is not always listable the instant its create lands, so poll a few
141
+ // times before concluding a timed-out create really did not happen.
142
+ const CLUSTER_RECOVERY_ATTEMPTS = 6;
143
+ const CLUSTER_RECOVERY_INTERVAL_MS = 5000;
144
+ async function findClusterByNameWithRetry(page, clusterName) {
145
+ for (let attempt = 1; attempt <= CLUSTER_RECOVERY_ATTEMPTS; attempt++) {
146
+ const cluster = await findClusterByName(page, clusterName);
147
+ if (cluster)
148
+ return cluster;
149
+ if (attempt < CLUSTER_RECOVERY_ATTEMPTS) {
150
+ await (0, sleep_1.sleep)(CLUSTER_RECOVERY_INTERVAL_MS);
151
+ }
152
+ }
153
+ return undefined;
154
+ }
116
155
  async function deleteClusterViaApi(page, clusterName) {
117
- const token = await getUserToken(page);
118
156
  const orgId = getOrgId();
119
157
  const deadline = Date.now() + 180000;
120
158
  // Re-deleting a cluster that is already terminating can answer 409/400.
@@ -127,8 +165,14 @@ async function deleteClusterViaApi(page, clusterName) {
127
165
  if (!cluster)
128
166
  return;
129
167
  if (!deleteIssued.has(cluster.uuid)) {
130
- const response = await page.request.delete(`${getConsoleBaseUrl()}/api/orgs/${orgId}/clusters/${cluster.uuid}`, { headers: { Authorization: `Bearer ${token}` } });
131
- if (!response.ok() && response.status() !== 404) {
168
+ // Routed through clusterApiRequest for the write timeout and retries: a
169
+ // bare page.request.delete inherits the 10s actionTimeout, which DELETE
170
+ // /clusters (7-9s at INT) can exceed.
171
+ const response = await clusterApiRequest(page, 'DELETE', `${getConsoleBaseUrl()}/api/orgs/${orgId}/clusters/${cluster.uuid}`);
172
+ // 409 is the retry landing on a cluster the first attempt already put
173
+ // into termination -- the outcome this asked for, not a failure.
174
+ const alreadyTerminating = [404, 409].includes(response.status());
175
+ if (!response.ok() && !alreadyTerminating) {
132
176
  throw new Error(`Failed to delete cluster "${clusterName}" (${cluster.uuid}): ` +
133
177
  `HTTP ${response.status()} - ${await response.text()}`);
134
178
  }
@@ -315,23 +359,40 @@ async function createClusterViaApi(page, clusterName, region = 'GCP', tasklistV2
315
359
  generationId = resolveGenerationId();
316
360
  }
317
361
  const k8sContextId = region.toUpperCase() === 'AWS' ? AWS_REGION_ID : GCP_REGION_ID;
318
- const createResponse = await clusterApiRequest(page, 'POST', `${getConsoleBaseUrl()}/api/orgs/${orgId}/clusters`, {
319
- name: clusterName,
320
- planTypeId: PLAN_TYPE_ID,
321
- channelId,
322
- generationId,
323
- k8sContextId,
324
- autoUpdate: true,
325
- stageLabel: 'dev',
326
- // Always sent explicitly rather than omitted: Console defaults an unset
327
- // field to v2-enabled for any generation >= 8.8, and the switch to v2 is
328
- // one-way -- a cluster that ends up v2-enabled can never be brought back
329
- // to v1 for the rest of its life. v2 cannot run job-worker-based user
330
- // tasks, draft variables, or public start forms, so the v1 leg
331
- // (@tasklistV1-tagged suites) depends on this being false, and the v2 leg
332
- // equally depends on it being true.
333
- tasklistV2Enabled,
334
- });
362
+ let createResponse;
363
+ try {
364
+ createResponse = await clusterApiRequest(page, 'POST', `${getConsoleBaseUrl()}/api/orgs/${orgId}/clusters`, {
365
+ name: clusterName,
366
+ planTypeId: PLAN_TYPE_ID,
367
+ channelId,
368
+ generationId,
369
+ k8sContextId,
370
+ autoUpdate: true,
371
+ stageLabel: 'dev',
372
+ // Always sent explicitly rather than omitted: Console defaults an unset
373
+ // field to v2-enabled for any generation >= 8.8, and the switch to v2 is
374
+ // one-way -- a cluster that ends up v2-enabled can never be brought back
375
+ // to v1 for the rest of its life. v2 cannot run job-worker-based user
376
+ // tasks, draft variables, or public start forms, so the v1 leg
377
+ // (@tasklistV1-tagged suites) depends on this being false, and the v2 leg
378
+ // equally depends on it being true.
379
+ tasklistV2Enabled,
380
+ });
381
+ }
382
+ catch (error) {
383
+ // Console does not cancel the create when the client stops waiting: it
384
+ // answers /clusters slower than the request timeout while still creating
385
+ // the cluster. Adopt what the timed-out POST made instead of re-POSTing,
386
+ // which would leave two clusters under the same name and break
387
+ // clickClusterLink in strict mode.
388
+ const recovered = await findClusterByNameWithRetry(page, clusterName);
389
+ if (!recovered)
390
+ throw error;
391
+ console.log(`Cluster "${clusterName}" create timed out but the cluster exists: ` +
392
+ `clusterId=${recovered.uuid} namespace=${recovered.uuid}-zeebe ` +
393
+ `orgId=${orgId} tasklistMode=${tasklistV2Enabled ? 'v2' : 'v1'}`);
394
+ return recovered.uuid;
395
+ }
335
396
  if (createResponse.status() !== 200 && createResponse.status() !== 201) {
336
397
  throw new Error(`Failed to create cluster "${clusterName}": ` +
337
398
  `HTTP ${createResponse.status()} - ${await createResponse.text()}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camunda/e2e-test-suite",
3
- "version": "0.0.1067",
3
+ "version": "0.0.1069",
4
4
  "description": "End-to-end test helpers for Camunda 8",
5
5
  "repository": {
6
6
  "type": "git",