@camunda/e2e-test-suite 0.0.1278 → 0.0.1280

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.
@@ -83,8 +83,14 @@ class AppsPage {
83
83
  // (see clickHubClusterApp and isHubClusterAppPresent below) gets at most
84
84
  // one match, scoped to this cluster.
85
85
  async expandHubClusterGroup(clusterName) {
86
+ // Anchored at the start: an unanchored substring match on "Test Cluster"
87
+ // also matches "UI Test Cluster" (created by console-user-flows in the
88
+ // same org), and .first() then opened that cluster's popover instead --
89
+ // a nightly trace shows the "UI Test Cluster" group open while the
90
+ // "Tasklist"/"Operate" lookup for "Test Cluster" timed out.
91
+ const clusterNamePattern = new RegExp(`^${escapeRegExp(clusterName)}\\b`);
86
92
  const toggle = this.hubSidebar
87
- .getByRole('button', { name: clusterName })
93
+ .getByRole('button', { name: clusterNamePattern })
88
94
  .first();
89
95
  // The diagram editor (and other full-screen views reached from it, e.g.
90
96
  // right after runProcessInstance/deployDiagram) has no "Main navigation"
@@ -165,14 +171,16 @@ class AppsPage {
165
171
  // of its own, in which case hasNotText would fail to exclude the toggle
166
172
  // we just expanded and this loop would immediately collapse it again.
167
173
  // Match by accessible name (same algorithm getByRole's own name option
168
- // uses) instead, excluding any name CONTAINING clusterName -- not just
169
- // one exactly equal to it, since (per the toggle locator above) the
170
- // current cluster's own toggle name is not exactly clusterName either,
171
- // and an exact-equality exclusion would wrongly treat it as "other" and
172
- // immediately re-collapse the group this method just expanded.
174
+ // uses) instead, excluding any name STARTING WITH clusterName (the same
175
+ // anchoring as the toggle locator, so "UI Test Cluster" still counts as
176
+ // another group) -- not just one exactly equal to it, since (per the
177
+ // toggle locator above) the current cluster's own toggle name is not
178
+ // exactly clusterName either, and an exact-equality exclusion would
179
+ // wrongly treat it as "other" and immediately re-collapse the group this
180
+ // method just expanded.
173
181
  const otherExpandedToggles = this.hubSidebar.getByRole('button', {
174
182
  expanded: true,
175
- name: new RegExp(`^(?!.*${escapeRegExp(clusterName)}).+$`),
183
+ name: new RegExp(`^(?!${escapeRegExp(clusterName)}\\b).+$`),
176
184
  });
177
185
  for (let i = 0; i < 5 && (await otherExpandedToggles.count()) > 0; i++) {
178
186
  // Let a failed collapse throw rather than swallowing it: the app link
@@ -62,6 +62,7 @@ declare class ClusterDetailsPage {
62
62
  clickCreateButton(): Promise<void>;
63
63
  clickCloseModalButton(): Promise<void>;
64
64
  clickSettingsTab(): Promise<void>;
65
+ private isRBAToggleInState;
65
66
  enableRBA(): Promise<void>;
66
67
  disableRBA(): Promise<void>;
67
68
  searchAndClickClientCredentialsLink(name: string): Promise<ClientCredentialsDetailsPage>;
@@ -234,9 +234,21 @@ class ClusterDetailsPage {
234
234
  async clickSettingsTab() {
235
235
  await this.settingsTab.click({ timeout: 60000 });
236
236
  }
237
+ // The switch reports its state through aria-checked. Clicking it
238
+ // unconditionally opened the opposite confirmation dialog whenever the
239
+ // cluster was already in the requested state (e.g. "Ensure RBA is
240
+ // Disabled" on a cluster that never had it on), so the Enable/Disable
241
+ // dialog wait below timed out. Returns false when the state is unknown.
242
+ async isRBAToggleInState(enabled) {
243
+ const checked = await this.rbaToggle.getAttribute('aria-checked');
244
+ return checked !== null && (checked === 'true') === enabled;
245
+ }
237
246
  async enableRBA() {
238
247
  await (0, test_1.expect)(this.rbaHeading).toBeVisible({ timeout: 60000 });
239
248
  await (0, test_1.expect)(this.rbaToggle).toBeVisible({ timeout: 30000 });
249
+ if (await this.isRBAToggleInState(true)) {
250
+ return;
251
+ }
240
252
  await this.rbaToggle.click();
241
253
  await (0, test_1.expect)(this.enableRBADialog).toBeVisible({ timeout: 60000 });
242
254
  await this.rbaEnableButton.click();
@@ -247,6 +259,9 @@ class ClusterDetailsPage {
247
259
  async disableRBA() {
248
260
  await (0, test_1.expect)(this.rbaHeading).toBeVisible({ timeout: 60000 });
249
261
  await (0, test_1.expect)(this.rbaToggle).toBeVisible({ timeout: 30000 });
262
+ if (await this.isRBAToggleInState(false)) {
263
+ return;
264
+ }
250
265
  await this.rbaToggle.click();
251
266
  await (0, test_1.expect)(this.disableRBADialog).toBeVisible({ timeout: 60000 });
252
267
  await this.rbaDisableButton.click();
@@ -31,9 +31,16 @@ class ClusterSecretsPage {
31
31
  this.connectorSecretsTable = page.getByRole('table', {
32
32
  name: 'Connector secrets',
33
33
  });
34
+ // Legacy Carbon table: an "Options" cell holding the kebab. Hub's
35
+ // design-system data table instead labels each row's kebab
36
+ // "Row actions" (data-slot="data-table-actions-trigger"), with no
37
+ // "Options" cell -- so the old locator found nothing, the pre-clean
38
+ // returned early, and re-creating an existing secret failed with
39
+ // "Secret names must be unique".
34
40
  this.optionsButton = page
35
41
  .getByRole('cell', { name: 'Options' })
36
- .getByRole('button');
42
+ .getByRole('button')
43
+ .or(page.getByRole('button', { name: 'Row actions', exact: true }));
37
44
  this.deleteConnectorSecretButton = page.getByRole('menuitem', {
38
45
  name: 'Delete',
39
46
  exact: true,
@@ -124,7 +131,7 @@ class ClusterSecretsPage {
124
131
  // that nothing *deletable* is left behind.
125
132
  const deletableSecretRows = this.page
126
133
  .getByRole('row')
127
- .filter({ has: this.page.getByRole('cell', { name: 'Options' }) })
134
+ .filter({ has: this.optionsButton })
128
135
  .filter({ hasNotText: 'CAMUNDA_PROVIDED_' });
129
136
  await (0, test_1.expect)(deletableSecretRows).toHaveCount(0, { timeout: 30000 });
130
137
  }
@@ -7,6 +7,7 @@ declare class ConnectorMarketplacePage {
7
7
  readonly replaceResourceButton: Locator;
8
8
  readonly addToProjectButton: Locator;
9
9
  readonly cancelButton: Locator;
10
+ readonly tryAgainButton: Locator;
10
11
  readonly closeButton: Locator;
11
12
  readonly browseAllMarketplaceDetailsButton: Locator;
12
13
  constructor(page: Page);
@@ -10,6 +10,7 @@ class ConnectorMarketplacePage {
10
10
  replaceResourceButton;
11
11
  addToProjectButton;
12
12
  cancelButton;
13
+ tryAgainButton;
13
14
  closeButton;
14
15
  browseAllMarketplaceDetailsButton;
15
16
  constructor(page) {
@@ -38,6 +39,7 @@ class ConnectorMarketplacePage {
38
39
  name: 'Add to project',
39
40
  });
40
41
  this.cancelButton = page.getByRole('button', { name: 'Cancel' });
42
+ this.tryAgainButton = page.getByRole('button', { name: 'Try again' });
41
43
  this.closeButton = page.getByRole('button', { name: 'Close' });
42
44
  }
43
45
  async clickSearchForConnectorTextbox() {
@@ -72,11 +74,33 @@ class ConnectorMarketplacePage {
72
74
  // genuine timeout here throw rather than silently falling through to the
73
75
  // immediate isVisible() probes below, which would just repeat the same
74
76
  // race this wait exists to close.
75
- await this.addToProjectButton
77
+ //
78
+ // The Browse all review step first "checks this project" (camunda-hub
79
+ // marketplace-connector-detail-state.ts), which waits on the project's
80
+ // element-template metadata request -- nightly and live traces show that
81
+ // request still pending 30s in. When the check fails the footer offers
82
+ // "Try again" (RETRY_INSPECTION) instead of "Add to project", so give the
83
+ // check longer and re-run it on failure rather than timing out.
84
+ const outcome = this.addToProjectButton
76
85
  .or(this.replaceResourceButton)
77
86
  .or(this.cancelButton)
78
- .first()
79
- .waitFor({ state: 'visible', timeout: 30000 });
87
+ .first();
88
+ for (let attempt = 0; attempt < 3; attempt++) {
89
+ await outcome
90
+ .or(this.tryAgainButton)
91
+ .first()
92
+ .waitFor({ state: 'visible', timeout: 120000 });
93
+ if (await outcome.isVisible()) {
94
+ break;
95
+ }
96
+ // The retrying check keeps "Try again" on screen (as a loading button),
97
+ // so wait for its result here instead of re-polling the union above.
98
+ await this.tryAgainButton.click({ timeout: 10000 });
99
+ await outcome
100
+ .waitFor({ state: 'visible', timeout: 120000 })
101
+ .catch(() => { });
102
+ }
103
+ await outcome.waitFor({ state: 'visible', timeout: 10000 });
80
104
  let imported = false;
81
105
  // A fresh download shows "Add to project"; an already-present resource
82
106
  // shows "Replace resource". Pick whichever the ImportModal rendered —
@@ -77,8 +77,20 @@ class LoginPage {
77
77
  // before) must clear it too, or getUserToken() can return the previous
78
78
  // user's cached token.
79
79
  (0, consoleApiHelpers_1.resetHubBearerToken)(this.page);
80
- // Navigate to app root to start the auth flow.
81
- await this.page.goto('/');
80
+ // Navigate to app root to start the auth flow. Callers usually arrive
81
+ // here straight from a "Log out" click, whose own redirect chain (Hub's
82
+ // /login?returnUrl=/logout -> Auth0 logout) can still be in flight and
83
+ // interrupt this goto. Let that navigation land, then start over once.
84
+ try {
85
+ await this.page.goto('/');
86
+ }
87
+ catch (error) {
88
+ if (!String(error).includes('is interrupted by another navigation')) {
89
+ throw error;
90
+ }
91
+ await this.page.waitForLoadState('domcontentloaded', { timeout: 60000 });
92
+ await this.page.goto('/');
93
+ }
82
94
  await this.page
83
95
  .waitForURL((url) => !url.hostname.includes('console'), { timeout: 60000 })
84
96
  .catch(() => { });
@@ -86,8 +98,18 @@ class LoginPage {
86
98
  // logout/session-reset, keeping the page on Console instead of redirecting
87
99
  // to the Auth0 login form. Detect Console (settings button visible) and
88
100
  // force logout until the Auth0 email input appears.
89
- const settingsBtn = this.page.getByLabel('Open Settings');
90
- const logoutBtn = this.page.getByRole('button', { name: 'Log out' });
101
+ // Hub's design-system shell has no "Open Settings" button: its user menu
102
+ // is a "User menu, <full name>" button whose "Log out" is a menuitem (see
103
+ // SettingsPage). Without these the loop never noticed it had been
104
+ // silently re-authenticated on Hub and waited out the username input.
105
+ const settingsBtn = this.page
106
+ .getByLabel('Open Settings')
107
+ .or(this.page.getByRole('button', { name: /^User menu,/ }))
108
+ .first();
109
+ const logoutBtn = this.page
110
+ .getByRole('button', { name: 'Log out' })
111
+ .or(this.page.getByRole('menuitem', { name: 'Log out' }))
112
+ .first();
91
113
  for (let i = 0; i < 3; i++) {
92
114
  const onAuth0 = await this.usernameInput
93
115
  .waitFor({ state: 'visible', timeout: 15000 })
@@ -8,6 +8,7 @@ const sleep_1 = require("../../utils/sleep");
8
8
  const fileUpload_1 = require("../../utils/fileUpload");
9
9
  const mailSlurpClient_1 = require("../../utils/mailSlurpClient");
10
10
  const consoleApiHelpers_1 = require("../../utils/consoleApiHelpers");
11
+ const sessionRecovery_1 = require("../../utils/sessionRecovery");
11
12
  // Tracks which worker-derived `timeout` values have already paid the
12
13
  // pre-flight stagger below, so a worker only pays it once per process
13
14
  // lifetime rather than on every call -- see the comment at the call sites.
@@ -601,7 +602,11 @@ async function modelAndRunConnectorsDocHandlingDiagram(modelerCreatePage, modele
601
602
  });
602
603
  await connectorMarketplacePage.downloadConnectorToProject();
603
604
  await (0, sleep_1.sleep)(5000);
604
- await page.reload();
605
+ // A live run showed this reload bounce through Hub's /login?returnUrl=...
606
+ // and never reach "load" -- the stuck-session case recoverStuckModelerLogin
607
+ // exists for. Wait for the document only, then recover if it is stuck.
608
+ await page.reload({ waitUntil: 'domcontentloaded' });
609
+ await (0, sessionRecovery_1.recoverStuckModelerLogin)(page);
605
610
  await (0, sleep_1.sleep)(5000);
606
611
  await modelerCreatePage.fillStartEventVariablesForDocHandling(zeebeUrl, zeebeClientId, zeebeClientSecret);
607
612
  await modelerCreatePage.runProcessInstance(clusterName);
@@ -227,7 +227,9 @@ declare class ModelerCreatePage {
227
227
  retries?: number;
228
228
  settleMs?: number;
229
229
  openPanel?: boolean;
230
+ elementId?: string;
230
231
  }): Promise<void>;
232
+ private ensureElementSelected;
231
233
  clickCloseDetailsPanel(): Promise<void>;
232
234
  clickOpenDetailsPanel(): Promise<void>;
233
235
  }
@@ -1843,6 +1843,9 @@ class ModelerCreatePage {
1843
1843
  if (openPanel) {
1844
1844
  await this.clickOpenDetailsPanel();
1845
1845
  }
1846
+ if (options?.elementId) {
1847
+ await this.ensureElementSelected(elementToClick, options.elementId);
1848
+ }
1846
1849
  await this.openAuthenticationPanel();
1847
1850
  let lastError;
1848
1851
  for (let attempt = 1; attempt <= retries; attempt++) {
@@ -1856,12 +1859,37 @@ class ModelerCreatePage {
1856
1859
  }
1857
1860
  catch (err) {
1858
1861
  lastError = err;
1862
+ // The stale-selection restore can land after ensureElementSelected
1863
+ // has already confirmed the target, detaching the field mid-fill.
1864
+ if (options?.elementId) {
1865
+ await this.ensureElementSelected(elementToClick, options.elementId);
1866
+ }
1859
1867
  await this.openAuthenticationPanel();
1860
1868
  await (0, sleep_1.sleep)(settleMs);
1861
1869
  }
1862
1870
  }
1863
1871
  throw new Error(`Failed to set OAuth 2.0 token endpoint after ${retries} attempt(s). Last error: ${String(lastError)}`);
1864
1872
  }
1873
+ // After a page reload, reopening the Details panel can restore the
1874
+ // selection from before the reload (and scroll the canvas to it), so the
1875
+ // panel shows a different element than the one just clicked. The target may
1876
+ // then sit under the panel, so re-select it with a DOM click that the panel
1877
+ // cannot intercept.
1878
+ async ensureElementSelected(element, elementId) {
1879
+ for (let attempt = 1; attempt <= 3; attempt++) {
1880
+ await (0, sleep_1.sleep)(1000);
1881
+ const selectedId = await this.elemendIdInput
1882
+ .inputValue({ timeout: 10000 })
1883
+ .catch(() => '');
1884
+ if (selectedId === elementId) {
1885
+ return;
1886
+ }
1887
+ await element.dispatchEvent('click');
1888
+ }
1889
+ await (0, test_1.expect)(this.elemendIdInput).toHaveValue(elementId, {
1890
+ timeout: 10000,
1891
+ });
1892
+ }
1865
1893
  async clickCloseDetailsPanel() {
1866
1894
  await this.closeDetailsPanel.click({ timeout: 30000 });
1867
1895
  }
@@ -566,10 +566,10 @@ async function modelAndRunConnectorsDocHandlingDiagram(modelerCreatePage, modele
566
566
  // closed before their shape can be clicked and reopened afterwards.
567
567
  await (0, sleep_1.sleep)(2000);
568
568
  await modelerCreatePage.clickCloseDetailsPanel();
569
- await modelerCreatePage.setOAuthTokenEndpoint(restTaskHit('Activity_0iyt4gv'), endpoint, { openPanel: true });
569
+ await modelerCreatePage.setOAuthTokenEndpoint(restTaskHit('Activity_0iyt4gv'), endpoint, { openPanel: true, elementId: 'Activity_0iyt4gv' });
570
570
  await (0, sleep_1.sleep)(2000);
571
571
  await modelerCreatePage.clickCloseDetailsPanel();
572
- await modelerCreatePage.setOAuthTokenEndpoint(restTaskHit('Activity_1oiegam'), endpoint, { openPanel: true });
572
+ await modelerCreatePage.setOAuthTokenEndpoint(restTaskHit('Activity_1oiegam'), endpoint, { openPanel: true, elementId: 'Activity_1oiegam' });
573
573
  await (0, sleep_1.sleep)(2000);
574
574
  await modelerCreatePage.runProcessInstance('', tenant);
575
575
  }
@@ -182,7 +182,9 @@ _8_7_1.test.describe('Connectors User Flow Tests', () => {
182
182
  timeout: 20000,
183
183
  });
184
184
  await appsPage.clickCamundaApps();
185
- await (0, test_1.expect)(appsPage.modelerLink).toBeVisible({ timeout: 20000 });
185
+ // No separate modelerLink check: Hub has no app-switcher "Modeler" link
186
+ // (clickModeler() goes through its home link instead), and on the
187
+ // legacy shell clickModeler() asserts modelerLink itself.
186
188
  await appsPage.clickModeler();
187
189
  await (0, test_1.expect)(modelerHomePage.modelerPageBanner).toBeVisible({
188
190
  timeout: 120000,
@@ -49,6 +49,31 @@ async function createCancellationBatch(api, processId) {
49
49
  (0, assertions_1.assertRequiredFields)(json, ['batchOperationKey']);
50
50
  return String(json.batchOperationKey);
51
51
  }
52
+ /**
53
+ * Suspends a batch that may already have run to completion.
54
+ *
55
+ * The engine drops a finished batch from its state, so suspending one is
56
+ * rejected with 404 NOT_FOUND rather than 409. A 404 is accepted only after
57
+ * the search store confirms the batch finished, so a batch that never existed
58
+ * still fails.
59
+ */
60
+ async function suspendBatch(api, batchKey) {
61
+ const suspend = await api.post('/batch-operations/{batchOperationKey}/suspension', { params: { batchOperationKey: batchKey }, body: {}, tenant: 'none' });
62
+ await (0, assertions_1.assertStatusIn)(suspend, [204, 404, 409], 'a batch that has already finished cannot be suspended');
63
+ if (suspend.status() === 404) {
64
+ const body = (await suspend.json());
65
+ (0, test_1.expect)(String(body.detail)).toContain(`Expected to suspend a batch operation with key '${batchKey}', but no such batch operation was found`);
66
+ await (0, eventually_1.eventually)(async () => {
67
+ const lookup = await api.get('/batch-operations/{batchOperationKey}', {
68
+ params: { batchOperationKey: batchKey },
69
+ tenant: 'none',
70
+ });
71
+ const batch = (await (0, assertions_1.assertOk)(lookup));
72
+ (0, test_1.expect)(['COMPLETED', 'PARTIALLY_COMPLETED']).toContain(batch.state);
73
+ });
74
+ }
75
+ return suspend;
76
+ }
52
77
  api_1.test.describe('Batch operations', () => {
53
78
  (0, api_1.test)('creates a cancellation batch and cancels its instances', async ({ api, resources, }) => {
54
79
  const { processId, instanceKeys } = await seedInstances(api, resources, 3);
@@ -129,8 +154,7 @@ api_1.test.describe('Batch operations', () => {
129
154
  // A larger batch keeps the operation in flight long enough to suspend.
130
155
  const { processId } = await seedInstances(api, resources, 5);
131
156
  const batchKey = await createCancellationBatch(api, processId);
132
- const suspend = await api.post('/batch-operations/{batchOperationKey}/suspension', { params: { batchOperationKey: batchKey }, body: {}, tenant: 'none' });
133
- await (0, assertions_1.assertStatusIn)(suspend, [204, 409], 'a batch that has already finished cannot be suspended');
157
+ const suspend = await suspendBatch(api, batchKey);
134
158
  if (suspend.status() === 204) {
135
159
  await (0, eventually_1.eventually)(async () => {
136
160
  const lookup = await api.get('/batch-operations/{batchOperationKey}', {
@@ -155,8 +179,7 @@ api_1.test.describe('Batch operations', () => {
155
179
  (0, api_1.test)('cancels a suspended batch', async ({ api, resources }) => {
156
180
  const { processId } = await seedInstances(api, resources, 5);
157
181
  const batchKey = await createCancellationBatch(api, processId);
158
- const suspend = await api.post('/batch-operations/{batchOperationKey}/suspension', { params: { batchOperationKey: batchKey }, body: {}, tenant: 'none' });
159
- await (0, assertions_1.assertStatusIn)(suspend, [204, 409], 'a batch that has already finished cannot be suspended');
182
+ const suspend = await suspendBatch(api, batchKey);
160
183
  if (suspend.status() === 204) {
161
184
  const cancel = await api.post('/batch-operations/{batchOperationKey}/cancellation', { params: { batchOperationKey: batchKey }, body: {}, tenant: 'none' });
162
185
  await (0, assertions_1.assertStatusIn)(cancel, [204], 'cancellation is acknowledged');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camunda/e2e-test-suite",
3
- "version": "0.0.1278",
3
+ "version": "0.0.1280",
4
4
  "description": "End-to-end test helpers for Camunda 8",
5
5
  "repository": {
6
6
  "type": "git",