@camunda/e2e-test-suite 0.0.1108 → 0.0.1109

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.
@@ -32,12 +32,15 @@ declare class ClusterPage {
32
32
  readonly tasklistV1Api: Locator;
33
33
  readonly tasklistV2Api: Locator;
34
34
  readonly cluster: (clusterName: string) => Locator;
35
+ readonly clusterRow: (clusterName: string) => Locator;
35
36
  readonly clusterHealthiness: (clusterName: string) => Locator;
36
37
  readonly clusterLink: (clusterName: string) => Locator;
37
38
  readonly dialog: Locator;
38
39
  readonly clusterVersion: (clusterName: string, versionText: string) => Locator;
39
40
  constructor(page: Page, testInfo?: TestInfo);
40
41
  clickClusterBanner(): Promise<void>;
42
+ private confirmClusterDeletion;
43
+ private dismissOpenDialog;
41
44
  deleteCluster(name: string): Promise<void>;
42
45
  clickClusterTab(): Promise<void>;
43
46
  clickCreateNewClusterButton(): Promise<void>;
@@ -54,6 +57,30 @@ declare class ClusterPage {
54
57
  clickConnectorSecretsTab(clusterName: string): Promise<void>;
55
58
  clickLatestStableForMinorVersion(): Promise<void>;
56
59
  clickClusterOption(): Promise<void>;
60
+ /**
61
+ * Counts the clusters carrying `clusterName`. The clusters table renders
62
+ * asynchronously after a navigation or reload, so a bare `count()` can read
63
+ * zero rows off a list that is merely still loading -- and the caller would
64
+ * then create a second cluster with the same name. Give the row a chance to
65
+ * appear before trusting a zero.
66
+ */
67
+ private countClusters;
68
+ /**
69
+ * Deletes the surplus rows when several clusters share `clusterName`, so the
70
+ * name identifies exactly one cluster again. Duplicates are created when
71
+ * concurrent workers run the same spec against a fixed cluster name: both
72
+ * see "no such cluster" and both create one (each in its own random region,
73
+ * which the Console accepts). Leaving them in place is not an option -- the
74
+ * cluster name is what every later step resolves the cluster by, in the
75
+ * Console list, in the apps menu, and in the Modeler deployment dialog, and
76
+ * those lists do not have to agree on ordering, so the flow could enable RBA
77
+ * on one cluster and then assert against the other.
78
+ *
79
+ * Always removes the trailing row so the surviving cluster stays the one
80
+ * `clusterRow()` resolves to, and a concurrent worker pruning the same list
81
+ * targets the same row rather than the survivor.
82
+ */
83
+ private pruneDuplicateClusters;
57
84
  createClusterIfNotExists(clusterName: string, region?: string): Promise<void>;
58
85
  assertClusterHealthyStatusWithRetry(name: string, visibilityTimeout?: number, totalTimeout?: number): Promise<void>;
59
86
  clickAlphaTab(): Promise<void>;
@@ -37,6 +37,7 @@ class ClusterPage {
37
37
  tasklistV1Api;
38
38
  tasklistV2Api;
39
39
  cluster;
40
+ clusterRow;
40
41
  clusterHealthiness;
41
42
  clusterLink;
42
43
  dialog;
@@ -109,15 +110,22 @@ class ClusterPage {
109
110
  this.cluster = (clusterName) => this.clustersList.filter({
110
111
  has: page.getByRole('link', { name: clusterName, exact: true }),
111
112
  });
112
- this.clusterHealthiness = (clusterName) => this.cluster(clusterName).getByText(`Healthy`, {
113
+ // The Console only enforces cluster-name uniqueness per region, so the
114
+ // org can hold several rows with the same name (parallel workers sharing
115
+ // a fixed cluster name do produce them). `cluster()` stays the full set --
116
+ // deleteCluster needs it -- while everything that means "the one cluster
117
+ // under test" goes through this single row, so a duplicate can no longer
118
+ // turn an ordinary visibility check into a strict-mode violation.
119
+ this.clusterRow = (clusterName) => this.cluster(clusterName).first();
120
+ this.clusterHealthiness = (clusterName) => this.clusterRow(clusterName).getByText(`Healthy`, {
113
121
  exact: true,
114
122
  });
115
- this.clusterLink = (clusterName) => this.cluster(clusterName).getByRole('link', {
123
+ this.clusterLink = (clusterName) => this.clusterRow(clusterName).getByRole('link', {
116
124
  name: clusterName,
117
125
  exact: true,
118
126
  });
119
127
  this.dialog = page.getByRole('dialog');
120
- this.clusterVersion = (clusterName, versionText) => this.cluster(clusterName).getByText(versionText, { exact: false });
128
+ this.clusterVersion = (clusterName, versionText) => this.clusterRow(clusterName).getByText(versionText, { exact: false });
121
129
  }
122
130
  async clickClusterBanner() {
123
131
  await this.page
@@ -125,6 +133,30 @@ class ClusterPage {
125
133
  .getByRole('link', { name: 'Clusters' })
126
134
  .click({ timeout: 60000 });
127
135
  }
136
+ async confirmClusterDeletion() {
137
+ await (0, test_1.expect)(this.dialog).toBeVisible({ timeout: 60000 });
138
+ await (0, test_1.expect)(this.confirmDeleteInput).toBeVisible({ timeout: 60000 });
139
+ await this.confirmDeleteInput.click({ force: true, timeout: 60000 });
140
+ await this.confirmDeleteInput.fill('DELETE', { timeout: 60000 });
141
+ const dangerDeleteButtons = await this.dangerDeleteButton.all();
142
+ const lastDangerDeleteButton = dangerDeleteButtons[dangerDeleteButtons.length - 1];
143
+ await lastDangerDeleteButton.click();
144
+ await (0, test_1.expect)(this.page.getByText('Deleting...')).not.toBeVisible({
145
+ timeout: 60000,
146
+ });
147
+ await (0, test_1.expect)(this.dialog).not.toBeVisible({ timeout: 60000 });
148
+ }
149
+ async dismissOpenDialog() {
150
+ const cancelButton = this.dialog
151
+ .first()
152
+ .getByRole('button', { name: 'Cancel' });
153
+ const cancelVisible = await cancelButton
154
+ .isVisible({ timeout: 2000 })
155
+ .catch(() => false);
156
+ if (cancelVisible) {
157
+ await cancelButton.click({ timeout: 10000 }).catch(() => false);
158
+ }
159
+ }
128
160
  async deleteCluster(name) {
129
161
  await this.page.waitForTimeout(1000);
130
162
  const clusterInstance = this.cluster(name).first();
@@ -147,17 +179,7 @@ class ClusterPage {
147
179
  await (0, test_1.expect)(deleteButton).toBeVisible({ timeout: 60000 });
148
180
  await deleteButton.click({ force: true, timeout: 60000 });
149
181
  }
150
- await (0, test_1.expect)(this.dialog).toBeVisible({ timeout: 60000 });
151
- await (0, test_1.expect)(this.confirmDeleteInput).toBeVisible({ timeout: 60000 });
152
- await this.confirmDeleteInput.click({ force: true, timeout: 60000 });
153
- await this.confirmDeleteInput.fill('DELETE', { timeout: 60000 });
154
- const dangerDeleteButtons = await this.dangerDeleteButton.all();
155
- const lastDangerDeleteButton = dangerDeleteButtons[dangerDeleteButtons.length - 1];
156
- await lastDangerDeleteButton.click();
157
- await (0, test_1.expect)(this.page.getByText('Deleting...')).not.toBeVisible({
158
- timeout: 60000,
159
- });
160
- await (0, test_1.expect)(this.dialog).not.toBeVisible({ timeout: 60000 });
182
+ await this.confirmClusterDeletion();
161
183
  // Refresh the list of delete buttons
162
184
  deleteButtons = this.cluster(name).getByRole('button', {
163
185
  name: 'Delete',
@@ -245,15 +267,21 @@ class ClusterPage {
245
267
  await this.createClusterButton.click();
246
268
  }
247
269
  async clickClustersBreadcrumb(clusterName) {
248
- const creatingCheckRegex = new RegExp(`${clusterName}.*?Creatingdev`);
249
- const healthyCheckRegex = new RegExp(`${clusterName}.*?Healthydev`);
250
- const clusterRowVisible = this.page
251
- .getByText(creatingCheckRegex)
252
- .or(this.page.getByText(healthyCheckRegex));
270
+ // Scope the status check to the cluster's own row instead of matching the
271
+ // status text anywhere on the page: a page-wide `getByText` regex also
272
+ // matches every ancestor whose combined text spans the name and a status
273
+ // (the whole app shell does, once a second row carries the same name),
274
+ // which turns this wait into a strict-mode violation.
275
+ const creatingRow = this.clusterRow(clusterName).filter({
276
+ hasText: 'Creating',
277
+ });
278
+ const clusterRowVisible = this.clusterRow(clusterName).filter({
279
+ hasText: /Creating|Healthy/,
280
+ });
253
281
  try {
254
282
  await (0, test_1.expect)(this.clustersBreadcrumb).toBeVisible({ timeout: 60000 });
255
283
  await this.clustersBreadcrumb.click({ timeout: 60000 });
256
- await (0, test_1.expect)(this.page.getByText(creatingCheckRegex)).toBeVisible({
284
+ await (0, test_1.expect)(creatingRow).toBeVisible({
257
285
  timeout: 30000,
258
286
  });
259
287
  return;
@@ -347,19 +375,84 @@ class ClusterPage {
347
375
  }
348
376
  throw new Error('Failed to find cluster generation');
349
377
  }
378
+ /**
379
+ * Counts the clusters carrying `clusterName`. The clusters table renders
380
+ * asynchronously after a navigation or reload, so a bare `count()` can read
381
+ * zero rows off a list that is merely still loading -- and the caller would
382
+ * then create a second cluster with the same name. Give the row a chance to
383
+ * appear before trusting a zero.
384
+ */
385
+ async countClusters(clusterName) {
386
+ await this.clusterRow(clusterName)
387
+ .waitFor({ state: 'visible', timeout: 30000 })
388
+ .catch(() => false);
389
+ return this.cluster(clusterName).count();
390
+ }
391
+ /**
392
+ * Deletes the surplus rows when several clusters share `clusterName`, so the
393
+ * name identifies exactly one cluster again. Duplicates are created when
394
+ * concurrent workers run the same spec against a fixed cluster name: both
395
+ * see "no such cluster" and both create one (each in its own random region,
396
+ * which the Console accepts). Leaving them in place is not an option -- the
397
+ * cluster name is what every later step resolves the cluster by, in the
398
+ * Console list, in the apps menu, and in the Modeler deployment dialog, and
399
+ * those lists do not have to agree on ordering, so the flow could enable RBA
400
+ * on one cluster and then assert against the other.
401
+ *
402
+ * Always removes the trailing row so the surviving cluster stays the one
403
+ * `clusterRow()` resolves to, and a concurrent worker pruning the same list
404
+ * targets the same row rather than the survivor.
405
+ */
406
+ async pruneDuplicateClusters(clusterName) {
407
+ const rows = this.cluster(clusterName);
408
+ const maxAttempts = 4;
409
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
410
+ const total = await this.countClusters(clusterName);
411
+ if (total <= 1)
412
+ return;
413
+ console.warn(`pruneDuplicateClusters: found ${total} clusters named '${clusterName}'; deleting the surplus row (attempt ${attempt}/${maxAttempts}).`);
414
+ try {
415
+ await rows
416
+ .nth(total - 1)
417
+ .getByRole('button', { name: 'Delete' })
418
+ .click({ force: true, timeout: 60000 });
419
+ await this.confirmClusterDeletion();
420
+ }
421
+ catch (error) {
422
+ console.warn(`pruneDuplicateClusters: could not delete the surplus '${clusterName}' row: ${error}`);
423
+ await this.dismissOpenDialog();
424
+ }
425
+ await this.page.reload({ waitUntil: 'domcontentloaded' });
426
+ }
427
+ const remaining = await this.countClusters(clusterName);
428
+ if (remaining > 1) {
429
+ throw new Error(`Could not reduce the ${remaining} clusters named '${clusterName}' to a single one after ${maxAttempts} attempts; the cluster name no longer identifies one cluster.`);
430
+ }
431
+ }
350
432
  async createClusterIfNotExists(clusterName, region = 'GCP') {
351
433
  await (0, sleep_1.sleep)(15000);
352
- if ((await this.cluster(clusterName).isVisible({ timeout: 20000 })) &&
353
- (await this.clusterHealthiness(clusterName).isVisible({ timeout: 20000 }))) {
354
- console.log(`${clusterName} exists and is healthy.`);
355
- }
356
- else {
434
+ await this.pruneDuplicateClusters(clusterName);
435
+ if ((await this.countClusters(clusterName)) === 0) {
357
436
  await this.createCluster(clusterName, region);
358
437
  await this.assertClusterHealthyStatusWithRetry(clusterName, 120000, 600000);
438
+ return;
359
439
  }
440
+ if (await this.clusterHealthiness(clusterName).isVisible({ timeout: 20000 })) {
441
+ console.log(`${clusterName} exists and is healthy.`);
442
+ return;
443
+ }
444
+ // The cluster exists but is not Healthy yet -- typically still Creating
445
+ // from a concurrent worker, or briefly status-less right after a reload.
446
+ // Wait for it to come up instead of deleting and recreating it: tearing
447
+ // down a cluster mid-provisioning is what leaves the "Delete cluster"
448
+ // dialog hanging open on a delete the backend rejects.
449
+ console.log(`${clusterName} exists but is not Healthy yet; waiting for it to come up.`);
450
+ await this.assertClusterHealthyStatusWithRetry(clusterName, 120000, 600000);
360
451
  }
361
452
  async assertClusterHealthyStatusWithRetry(name, visibilityTimeout = 100000, totalTimeout = 500000) {
362
- const unhealthy = this.cluster(name).getByText('Unhealthy', { exact: true });
453
+ const unhealthy = this.clusterRow(name).getByText('Unhealthy', {
454
+ exact: true,
455
+ });
363
456
  const start = Date.now();
364
457
  const maxRetries = 5;
365
458
  let attempt = 0;
@@ -369,7 +462,7 @@ class ClusterPage {
369
462
  attempt++;
370
463
  try {
371
464
  await this.page.reload();
372
- await (0, test_1.expect)(this.cluster(name)).toBeVisible({ timeout: 10000 });
465
+ await (0, test_1.expect)(this.clusterRow(name)).toBeVisible({ timeout: 10000 });
373
466
  // Only treat Unhealthy as terminal if it persists across two
374
467
  // consecutive polling attempts. A cluster mid-provisioning can flash
375
468
  // through Unhealthy briefly during the Unhealthy → Creating → Healthy
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camunda/e2e-test-suite",
3
- "version": "0.0.1108",
3
+ "version": "0.0.1109",
4
4
  "description": "End-to-end test helpers for Camunda 8",
5
5
  "repository": {
6
6
  "type": "git",