@camunda/e2e-test-suite 0.0.1276 → 0.0.1278

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.
@@ -0,0 +1,258 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WorkspaceMembersPage = void 0;
4
+ const test_1 = require("@playwright/test");
5
+ // Camunda Hub's workspace "Manage members" view: Members / Environments /
6
+ // General tabs over a member table of Name | Email | Workspace role | Joined,
7
+ // an "Add members" panel, and a role cell that becomes a select once clicked.
8
+ //
9
+ // This page matters to the role flows because an organization role alone no
10
+ // longer decides what a non-admin can reach. Since camunda-hub's environments
11
+ // work, an environment -- and so the cluster app behind it -- is visible only
12
+ // to a user who is Editor or Workspace Admin on a workspace it is assigned to
13
+ // (pdp-3715); organization admins and owners see every environment.
14
+ class WorkspaceMembersPage {
15
+ page;
16
+ membersTab;
17
+ manageMembersHeading;
18
+ addMembersButton;
19
+ addMemberSearchbox;
20
+ addMemberSubmitButton;
21
+ constructor(page) {
22
+ this.page = page;
23
+ this.membersTab = page.getByRole('tab', { name: 'Members' });
24
+ this.manageMembersHeading = page.getByRole('heading', {
25
+ name: 'Manage members',
26
+ });
27
+ this.addMembersButton = page
28
+ .locator('[data-test="add-members-button"]')
29
+ .or(page.getByRole('button', { name: 'Add members' }))
30
+ .first();
31
+ // A cmdk combobox: <input cmdk-input role="combobox"
32
+ // aria-label="Search members" placeholder="Search members or enter an
33
+ // email to invite">. Prefer the accessible name over the placeholder,
34
+ // which is the longer and more churn-prone of the two.
35
+ this.addMemberSearchbox = page
36
+ .getByRole('combobox', { name: 'Search members' })
37
+ .or(page.getByPlaceholder('Search members or enter an email to invite'))
38
+ .first();
39
+ // `exact` matters: the panel's own trigger is "Add members", which a
40
+ // substring match on "Add" would also resolve.
41
+ this.addMemberSubmitButton = page.getByRole('button', {
42
+ name: 'Add',
43
+ exact: true,
44
+ });
45
+ }
46
+ // Hub's Workspaces overview defaults to "My workspaces" -- only the ones the
47
+ // current user is an explicit member of -- and resets to it on every load,
48
+ // so a workspace created by another user is invisible until the scope is
49
+ // widened. The empty state offers an explicit button; the populated view
50
+ // offers the filter instead. A no-op where neither is rendered.
51
+ async showAllWorkspaces() {
52
+ const switchButton = this.page.getByRole('button', {
53
+ name: 'Switch to All workspaces',
54
+ });
55
+ const scopeFilter = this.page.getByRole('combobox', {
56
+ name: 'Filter workspaces',
57
+ });
58
+ const appeared = await switchButton
59
+ .or(scopeFilter)
60
+ .first()
61
+ .waitFor({ state: 'visible', timeout: 15000 })
62
+ .then(() => true)
63
+ .catch(() => false);
64
+ if (!appeared) {
65
+ return;
66
+ }
67
+ if (await switchButton.isVisible().catch(() => false)) {
68
+ await switchButton.click({ timeout: 10000 });
69
+ await (0, test_1.expect)(switchButton).toBeHidden({ timeout: 10000 });
70
+ return;
71
+ }
72
+ if ((await scopeFilter.innerText()).includes('All workspaces')) {
73
+ return;
74
+ }
75
+ await scopeFilter.click({ timeout: 10000 });
76
+ await this.page
77
+ .getByRole('option', { name: 'All workspaces' })
78
+ .click({ timeout: 10000 });
79
+ await (0, test_1.expect)(scopeFilter).toContainText('All workspaces', {
80
+ timeout: 10000,
81
+ });
82
+ }
83
+ async openManageMembers(workspaceName) {
84
+ // `.first()`: an org can hold several workspaces sharing a name -- the
85
+ // nightly's environments card lists "Cross Component Test Project" twice
86
+ // -- and an unqualified match is then a strict-mode violation.
87
+ const manageButton = this.page
88
+ .getByRole('button', { name: `Manage workspace ${workspaceName}` })
89
+ .first();
90
+ // Keep Hub's default "My workspaces" scope, which is what a real user
91
+ // sees, and widen only when the target is not in it -- e.g. it was
92
+ // created by another user and this one holds no explicit membership.
93
+ const visibleUnderDefaultScope = await manageButton
94
+ .waitFor({ state: 'visible', timeout: 15000 })
95
+ .then(() => true)
96
+ .catch(() => false);
97
+ if (!visibleUnderDefaultScope) {
98
+ await this.showAllWorkspaces();
99
+ }
100
+ await (0, test_1.expect)(manageButton).toBeVisible({ timeout: 60000 });
101
+ await manageButton.click({ timeout: 30000 });
102
+ await (0, test_1.expect)(this.manageMembersHeading).toBeVisible({ timeout: 60000 });
103
+ if ((await this.membersTab
104
+ .getAttribute('aria-selected')
105
+ .catch(() => null)) !== 'true') {
106
+ await this.membersTab.click({ timeout: 30000 });
107
+ }
108
+ }
109
+ memberRow(email) {
110
+ return this.page.getByRole('row').filter({ hasText: email }).first();
111
+ }
112
+ async isMember(email) {
113
+ return this.memberRow(email)
114
+ .waitFor({ state: 'visible', timeout: 15000 })
115
+ .then(() => true)
116
+ .catch(() => false);
117
+ }
118
+ // Adds a user who is not on the member list yet. The panel is collapsed
119
+ // until its trigger is clicked, and its own "Workspace role" select already
120
+ // defaults to Editor, so it is only driven when a different role is wanted.
121
+ async addMember(email, role) {
122
+ await this.addMembersButton.click({ timeout: 30000 });
123
+ await (0, test_1.expect)(this.addMemberSearchbox).toBeVisible({ timeout: 30000 });
124
+ await this.addMemberSearchbox.click({ timeout: 30000 });
125
+ await this.addMemberSearchbox.fill(email);
126
+ // An org member shows up as a pickable result; picking it is what enables
127
+ // the submit. Typing alone leaves an invite-by-email candidate that some
128
+ // builds require to be committed the same way.
129
+ // cmdk renders its results as [cmdk-item] nodes with role="option",
130
+ // filtered live against what was typed.
131
+ const result = this.page
132
+ .getByRole('option', { name: new RegExp(escapeRegExp(email), 'i') })
133
+ .or(this.page.locator('[cmdk-item]').filter({ hasText: email }))
134
+ .first();
135
+ // The result list is a multi-select, and its checkbox is NOT a checkbox:
136
+ // a trace of the live DOM shows each row as
137
+ // <div cmdk-item role="option" data-staged="false" aria-selected="true">
138
+ // <svg aria-hidden="true"><rect .../></svg> <div>name / email</div>
139
+ // so getByRole('checkbox') matches nothing, and clicking the row only
140
+ // sets cmdk's highlight -- aria-selected and data-selected both read
141
+ // "true" while data-staged stays "false" and "Add" stays disabled.
142
+ // Click the leading svg, which is the box itself, and gate on the one
143
+ // attribute that actually tracks the pick.
144
+ await (0, test_1.expect)(result).toBeVisible({ timeout: 30000 });
145
+ // Neither the row nor its box can be clicked into a staged state: the box
146
+ // is a decorative svg the design system gives `pointer-events: none`, so
147
+ // that click lands on the row, and a row click only moves cmdk's
148
+ // highlight -- across three live runs `data-staged` never left "false"
149
+ // while `aria-selected` read "true" and "Add" stayed disabled. The
150
+ // list's "Select all" control is what actually stages. It is a
151
+ // <button data-test="members-picker-select-all">, not a cmdk item, and
152
+ // its label is split across text nodes ("Select all (", "1", ")"), so it
153
+ // is addressed by its test id. The search box above has already been
154
+ // filled with this one address, so the guard below holds it to exactly
155
+ // the intended user rather than trusting the label's count.
156
+ const candidates = this.page
157
+ .locator('[cmdk-item]')
158
+ .filter({ hasText: email });
159
+ await (0, test_1.expect)(candidates).toHaveCount(1, { timeout: 15000 });
160
+ await this.page
161
+ .locator('[data-test="members-picker-select-all"]')
162
+ .click({ timeout: 15000 });
163
+ await (0, test_1.expect)(result).toHaveAttribute('data-staged', 'true', {
164
+ timeout: 15000,
165
+ });
166
+ if (role !== 'Editor') {
167
+ await this.selectRoleIn(this.page.getByRole('combobox', { name: 'Workspace role' }).first(), role);
168
+ }
169
+ await (0, test_1.expect)(this.addMemberSubmitButton).toBeEnabled({ timeout: 30000 });
170
+ await this.addMemberSubmitButton.click({ timeout: 30000 });
171
+ await (0, test_1.expect)(this.memberRow(email)).toBeVisible({ timeout: 60000 });
172
+ }
173
+ async selectRoleIn(control, role) {
174
+ await control.click({ timeout: 30000 });
175
+ await this.page
176
+ .getByRole('option', { name: role, exact: true })
177
+ .or(this.page.getByRole('menuitem', { name: role, exact: true }))
178
+ .first()
179
+ .click({ timeout: 30000 });
180
+ }
181
+ // Ensures the user holds `role` on this workspace, adding them first when
182
+ // they are not a member at all -- which is the usual case for a role flow,
183
+ // since the shared workspace is created by whichever setup user won the
184
+ // race and nobody else is added to it.
185
+ async setMemberRole(email, role) {
186
+ if (!(await this.isMember(email))) {
187
+ await this.addMember(email, role);
188
+ return;
189
+ }
190
+ const row = this.memberRow(email);
191
+ if ((await row.getByText(role, { exact: true }).count()) > 0) {
192
+ return;
193
+ }
194
+ // The role cell renders as a static pill until clicked, then becomes a
195
+ // select with its own confirm control; leaving it unconfirmed silently
196
+ // reverts the row to its previous role.
197
+ const roleControl = row
198
+ .getByRole('combobox')
199
+ .or(row.getByRole('button', {
200
+ name: /^(Workspace Admin|Editor|Commenter|Viewer)$/,
201
+ }))
202
+ .first();
203
+ await roleControl.click({ timeout: 30000 });
204
+ await this.selectRoleIn(roleControl, role);
205
+ // Clicked unconditionally, not behind an `isVisible()` probe: the
206
+ // confirmation is what persists the change, and the control can render a
207
+ // beat after the select. A probe would read `false` in that window, skip
208
+ // the click, and let the assertion below match the still-uncommitted
209
+ // value in the open editor just before the row reverts. Clicking outright
210
+ // makes Playwright wait for the control and fail here when it never
211
+ // arrives, rather than passing on an edit that was never saved.
212
+ await row
213
+ .getByRole('button', { name: /^(Confirm|Save|Apply|Update)$/i })
214
+ .or(row.locator('button[type="submit"]'))
215
+ .first()
216
+ .click({ timeout: 30000 });
217
+ await (0, test_1.expect)(row.getByText(role, { exact: true })).toBeVisible({
218
+ timeout: 60000,
219
+ });
220
+ }
221
+ // Undoes `setMemberRole`/`addMember`. Workspace membership is durable org
222
+ // state: left behind, it survives the test that granted it, so a Playwright
223
+ // retry starts with the user already an Editor and any assertion about the
224
+ // un-granted state fails on the second attempt. A no-op when the user is
225
+ // not on the list, so it is safe to call from teardown after a failure at
226
+ // any point in the flow.
227
+ async removeMember(email) {
228
+ if (!(await this.isMember(email))) {
229
+ return;
230
+ }
231
+ const row = this.memberRow(email);
232
+ // The destructive action lives behind the row's own overflow menu; its
233
+ // trigger is an icon button, so it is matched on its accessible name
234
+ // rather than on text.
235
+ await row
236
+ .getByRole('button', { name: /(more|actions|options|menu)/i })
237
+ .or(row.locator('[data-test="member-row-actions"]'))
238
+ .first()
239
+ .click({ timeout: 30000 });
240
+ await this.page
241
+ .getByRole('menuitem', { name: /^Remove( member)?$/i })
242
+ .or(this.page.getByRole('option', { name: /^Remove( member)?$/i }))
243
+ .first()
244
+ .click({ timeout: 30000 });
245
+ // Removal is confirmed in a dialog. Scoped to it so the match cannot
246
+ // resolve back to the menu item that opened it.
247
+ const dialog = this.page.getByRole('dialog').first();
248
+ await dialog
249
+ .getByRole('button', { name: /^(Remove|Confirm|Delete)$/i })
250
+ .first()
251
+ .click({ timeout: 30000 });
252
+ await (0, test_1.expect)(row).toBeHidden({ timeout: 60000 });
253
+ }
254
+ }
255
+ exports.WorkspaceMembersPage = WorkspaceMembersPage;
256
+ function escapeRegExp(value) {
257
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
258
+ }
@@ -6,9 +6,15 @@ const UtilitiesPage_1 = require("../../pages/8.8/UtilitiesPage");
6
6
  const sleep_1 = require("../../utils/sleep");
7
7
  const resetSession_1 = require("../../utils/resetSession");
8
8
  const LoginPage_1 = require("../../pages/8.8/LoginPage");
9
+ const WorkspaceMembersPage_1 = require("../../pages/8.8/WorkspaceMembersPage");
9
10
  const test_1 = require("@playwright/test");
10
11
  const users_1 = require("../../utils/users");
11
12
  const testUser = (0, users_1.getTestUser)('mainUser');
13
+ // The workspace the Analyst flow borrows Editor rights on, and whether that
14
+ // grant is currently outstanding. Read by the teardown below so it only
15
+ // reaches for Hub's Manage members view when there is something to undo.
16
+ const analystWorkspaceName = 'Cross Component Test Project';
17
+ let analystWorkspaceGrantMade = false;
12
18
  _8_8_1.test.describe.configure({ mode: 'parallel' });
13
19
  _8_8_1.test.describe('User Roles User Flow', () => {
14
20
  const clusterNames = {
@@ -35,6 +41,29 @@ _8_8_1.test.describe('User Roles User Flow', () => {
35
41
  await (0, _setup_1.captureScreenshot)(page, testInfo);
36
42
  await (0, _setup_1.captureFailureVideo)(page, testInfo);
37
43
  });
44
+ // Workspace membership is durable organization state, so the Editor rights
45
+ // the Analyst flow grants must be handed back whether the test passed or
46
+ // failed. Left behind, a retry would start with the analyst already an
47
+ // Editor and its own "not reachable without workspace access" assertion
48
+ // would fail on the second attempt; the grant would also leak into every
49
+ // later nightly run against the same org.
50
+ _8_8_1.test.afterEach(async ({ page, browser }, testInfo) => {
51
+ if (!testInfo.title.startsWith('Analyst Role User Flow')) {
52
+ return;
53
+ }
54
+ if (!analystWorkspaceGrantMade) {
55
+ return;
56
+ }
57
+ await (0, resetSession_1.resetSession)(browser, page);
58
+ await new LoginPage_1.LoginPage(page).loginWithTestUser({
59
+ username: process.env.C8_USERNAME,
60
+ password: process.env.C8_PASSWORD,
61
+ });
62
+ const workspaceMembersPage = new WorkspaceMembersPage_1.WorkspaceMembersPage(page);
63
+ await workspaceMembersPage.openManageMembers(analystWorkspaceName);
64
+ await workspaceMembersPage.removeMember(process.env.C8_USERNAME_TEST_9);
65
+ analystWorkspaceGrantMade = false;
66
+ });
38
67
  // Skipped due to bug #58514: https://github.com/camunda/camunda/issues/58514
39
68
  _8_8_1.test.skip('Modeler Role User Flow', async ({ page, modelerHomePage, appsPage, homePage, clusterPage, clusterDetailsPage, context, consoleOrganizationsPage, settingsPage, operateHomePage, ocIdentityHomePage, taskPanelPage, optimizeHomePage, modelerCreatePage, }) => {
40
69
  const randomString = await (0, _setup_1.generateRandomStringAsync)(3);
@@ -158,6 +187,72 @@ _8_8_1.test.describe('User Roles User Flow', () => {
158
187
  password: process.env.C8_PASSWORD_TEST_9,
159
188
  });
160
189
  });
190
+ // An organization role no longer decides on its own what a non-admin can
191
+ // reach. Since camunda-hub's environments work (pdp-3443, pdp-3715), a
192
+ // cluster's apps are surfaced through an ENVIRONMENT, and an environment
193
+ // is visible only to users who are Editor or Workspace Admin on a
194
+ // workspace it is assigned to -- organization admins and owners excepted,
195
+ // who see every environment unconditionally. An Analyst with no workspace
196
+ // membership therefore reaches nothing, which is the decided design
197
+ // rather than a defect: "Clusters are shown only to org admins.
198
+ // Non-admins do not have cluster visibility (they operate with
199
+ // environments instead)."
200
+ //
201
+ // Assert that gate before granting the membership the rest of this test
202
+ // depends on, so both halves are covered: the role alone grants no
203
+ // access, and the role plus workspace editorship does.
204
+ await _8_8_1.test.step('Ensure Optimize is not reachable without workspace access', async () => {
205
+ // Assert the cause, not just the consequence. Hub shows the analyst an
206
+ // empty Workspaces overview -- "You don't have explicit membership in
207
+ // any workspace in this organization" before the scope is widened, and
208
+ // "Your organization has no workspaces yet" after -- because the
209
+ // workspace belongs to whoever created it. With no workspace there is
210
+ // no environment, and with no environment no cluster app, so checking
211
+ // the Optimize banner alone would pass for unrelated reasons too.
212
+ await appsPage.clickCamundaApps();
213
+ await (0, test_1.expect)(page
214
+ .getByText('Your organization has no workspaces yet')
215
+ .or(page.getByText("You don't have explicit membership in any workspace"))
216
+ .first()).toBeVisible({ timeout: 60000 });
217
+ // Assert the absence of THIS cluster's Optimize target directly rather
218
+ // than calling clickOptimize() and swallowing whatever it throws: a
219
+ // swallowed error would let this step pass because the hand-off helper
220
+ // itself broke and the page simply stayed on Hub, where the Optimize
221
+ // banner is absent for reasons that have nothing to do with the gate.
222
+ // On Hub a cluster's apps hang off the sidebar group named for the
223
+ // cluster, so with no environment neither the group nor the Optimize
224
+ // link inside it is ever rendered.
225
+ const hubSidebar = page.getByRole('navigation', {
226
+ name: 'Main navigation',
227
+ });
228
+ await (0, test_1.expect)(hubSidebar.getByRole('button', { name: clusterName })).toHaveCount(0, { timeout: 30000 });
229
+ await (0, test_1.expect)(hubSidebar.getByRole('link', { name: /Optimize/ })).toHaveCount(0, { timeout: 30000 });
230
+ await (0, test_1.expect)(optimizeHomePage.optimizeBanner).not.toBeVisible({
231
+ timeout: 30000,
232
+ });
233
+ });
234
+ await _8_8_1.test.step('Grant the analyst Editor rights on the shared workspace', async () => {
235
+ // Only an organization admin may change workspace membership, so this
236
+ // switches to the main user and back rather than doing it as the
237
+ // analyst under test.
238
+ await (0, resetSession_1.resetSession)(browser, page);
239
+ await new LoginPage_1.LoginPage(page).loginWithTestUser({
240
+ username: process.env.C8_USERNAME,
241
+ password: process.env.C8_PASSWORD,
242
+ });
243
+ const workspaceMembersPage = new WorkspaceMembersPage_1.WorkspaceMembersPage(page);
244
+ await workspaceMembersPage.openManageMembers(analystWorkspaceName);
245
+ // Flagged before the grant, not after: a failure partway through
246
+ // setMemberRole can still have added the member, and teardown must
247
+ // clean that up too.
248
+ analystWorkspaceGrantMade = true;
249
+ await workspaceMembersPage.setMemberRole(process.env.C8_USERNAME_TEST_9, 'Editor');
250
+ await (0, resetSession_1.resetSession)(browser, page);
251
+ await new LoginPage_1.LoginPage(page).loginWithTestUser({
252
+ username: process.env.C8_USERNAME_TEST_9,
253
+ password: process.env.C8_PASSWORD_TEST_9,
254
+ });
255
+ });
161
256
  await _8_8_1.test.step('Wait for the role change to reach Optimize', async () => {
162
257
  // Replaces a blind `sleep(600000)`. The role change only takes effect in
163
258
  // Optimize once its permission cache refreshes, and reaching Optimize is
@@ -105,6 +105,10 @@ _8_8_1.test.describe('Console User Flow Tests @tasklistV2', () => {
105
105
  });
106
106
  await _8_8_1.test.step('Create First Email Alert', async () => {
107
107
  await clusterDetailsPage.createAlert(true);
108
+ // Hub's alerts table keeps the recipient, date and trigger count in a
109
+ // collapsed expansion row, so the detail assertions below only have a
110
+ // second row to read once it is expanded.
111
+ await clusterDetailsPage.expandAlerts();
108
112
  await test_1.expect
109
113
  .poll(async () => clusterDetailsPage.alertsList.count(), {
110
114
  timeout: 30000,
@@ -165,6 +169,7 @@ _8_8_1.test.describe('Console User Flow Tests @tasklistV2', () => {
165
169
  await (0, test_1.expect)(clusterDetailsPage.alertsList.first()).toBeVisible({
166
170
  timeout: 20000,
167
171
  });
172
+ await clusterDetailsPage.expandAlerts();
168
173
  (0, test_1.expect)(await clusterDetailsPage.alertsList.count()).toBeGreaterThan(1);
169
174
  await (0, test_1.expect)(clusterDetailsPage.alertsList.nth(0)).toContainText(`Email`);
170
175
  await clusterDetailsPage.assertAlertText('Amount triggered1');
@@ -205,6 +210,7 @@ _8_8_1.test.describe('Console User Flow Tests @tasklistV2', () => {
205
210
  await (0, test_1.expect)(clusterDetailsPage.alertsList.first()).toBeVisible({
206
211
  timeout: 20000,
207
212
  });
213
+ await clusterDetailsPage.expandAlerts();
208
214
  (0, test_1.expect)(await clusterDetailsPage.alertsList.count()).toBeGreaterThan(1);
209
215
  await clusterDetailsPage.assertAlertText('Amount triggered3');
210
216
  }).toPass({ timeout: 240000 });
@@ -31,7 +31,9 @@ _8_8_1.test.describe('Web Modeler User Flow Tests', () => {
31
31
  await (0, mailSlurpClient_1.deleteInbox)(inboxes[testInfo.title]);
32
32
  }
33
33
  });
34
- (0, _8_8_1.test)('Form.js Integration with User Task and AI Generated Form @ai-external', async ({ page, homePage, modelerHomePage, appsPage, modelerCreatePage, formJsPage, }) => {
34
+ // Skipped due to bug #29346: https://github.com/camunda/camunda-hub/issues/29346
35
+ // Also tracked in https://github.com/camunda/product-hub/issues/3655
36
+ _8_8_1.test.skip('Form.js Integration with User Task and AI Generated Form @ai-external', async ({ page, homePage, modelerHomePage, appsPage, modelerCreatePage, formJsPage, }) => {
35
37
  _8_8_1.test.slow();
36
38
  const randomString = await (0, _setup_1.generateRandomStringAsync)(3);
37
39
  const processName = 'User_Task_Process_With_AI_Form' + randomString;
@@ -88,7 +90,9 @@ _8_8_1.test.describe('Web Modeler User Flow Tests', () => {
88
90
  await operateTabTaskDetailsPage.clickCompleteTaskButton();
89
91
  });
90
92
  });
91
- (0, _8_8_1.test)('Form.js Integration with AI generated Start form @ai-external', async ({ page, homePage, modelerHomePage, appsPage, modelerCreatePage, formJsPage, }) => {
93
+ // Skipped due to bug #29346: https://github.com/camunda/camunda-hub/issues/29346
94
+ // Also tracked in https://github.com/camunda/product-hub/issues/3655
95
+ _8_8_1.test.skip('Form.js Integration with AI generated Start form @ai-external', async ({ page, homePage, modelerHomePage, appsPage, modelerCreatePage, formJsPage, }) => {
92
96
  _8_8_1.test.slow();
93
97
  const randomString = await (0, _setup_1.generateRandomStringAsync)(3);
94
98
  const processName = 'AI-generated-Start-form' + randomString;
@@ -810,24 +810,51 @@ async function listWorkspaceEnvironments(page, workspaceId) {
810
810
  // only the new id would silently unassign every other cluster the suite had
811
811
  // already linked (the AWS cluster, a sibling Tasklist-mode project's default).
812
812
  async function linkClusterToWorkspaceViaApi(page, workspaceId, clusterName) {
813
- const environments = await listOrganizationEnvironments(page);
813
+ // Hub discovers a cluster into an environment asynchronously, so a cluster
814
+ // this suite just created via the API is healthy well before its
815
+ // environment exists. A single probe therefore failed specs that create
816
+ // their own cluster: the nightly reported `No environment backs cluster
817
+ // "Second RBA V2 Cluster"` while three OTHER clusters were listed ACTIVE,
818
+ // and `"Fourth RBA V2 Cluster"; available: <none>` at a moment when the
819
+ // list came back empty entirely. Poll until this cluster's environment
820
+ // shows up. The throw below still stands once the deadline passes, so a
821
+ // genuinely absent cluster fails exactly as before -- only the race is
822
+ // absorbed.
823
+ const deadline = Date.now() + 120000;
824
+ let environments = await listOrganizationEnvironments(page);
814
825
  if (environments === null) {
815
826
  console.log('Environments feature is off for this org; leaving the workspace unlinked');
816
827
  return;
817
828
  }
818
- const matches = environments.filter((environment) => environment.clusterName === clusterName);
819
- // Prefer an ACTIVE environment over a stale NOT_REPORTED duplicate left by
820
- // an earlier cluster of the same name.
821
- const match = matches.find((environment) => environment.discoveryState === 'ACTIVE') ??
822
- matches[0];
829
+ const pickMatch = (candidates) => {
830
+ const matched = candidates.filter((environment) => environment.clusterName === clusterName);
831
+ // Prefer an ACTIVE environment over a stale NOT_REPORTED duplicate left by
832
+ // an earlier cluster of the same name.
833
+ return (matched.find((environment) => environment.discoveryState === 'ACTIVE') ??
834
+ matched[0]);
835
+ };
836
+ let match = pickMatch(environments);
837
+ while (!match && Date.now() < deadline) {
838
+ await page.waitForTimeout(5000);
839
+ const refreshed = await listOrganizationEnvironments(page);
840
+ if (refreshed === null) {
841
+ return;
842
+ }
843
+ environments = refreshed;
844
+ match = pickMatch(environments);
845
+ }
823
846
  if (!match) {
824
847
  throw new Error(`No environment backs cluster "${clusterName}"; available: ` +
825
848
  (environments
826
849
  .map((e) => `${e.name}@${e.clusterName} (${e.discoveryState})`)
827
850
  .join(', ') || '<none>'));
828
851
  }
852
+ // Bind to a const so the narrowing survives the closures below: `match` is
853
+ // a `let` reassigned by the discovery poll, so TypeScript widens it back to
854
+ // `HubEnvironment | undefined` inside callbacks.
855
+ const backing = match;
829
856
  const assigned = await listWorkspaceEnvironments(page, workspaceId);
830
- if (assigned.some((environment) => environment.id === match.id)) {
857
+ if (assigned.some((environment) => environment.id === backing.id)) {
831
858
  // Logged rather than returning silently: a quiet no-op here is
832
859
  // indistinguishable from the helper never having run at all, which is
833
860
  // exactly the ambiguity that hid an earlier bug.
@@ -845,10 +872,10 @@ async function linkClusterToWorkspaceViaApi(page, workspaceId, clusterName) {
845
872
  const current = attempt === 1
846
873
  ? assigned
847
874
  : await listWorkspaceEnvironments(page, workspaceId);
848
- if (attempt > 1 && current.some((e) => e.id === match.id)) {
875
+ if (attempt > 1 && current.some((e) => e.id === backing.id)) {
849
876
  return;
850
877
  }
851
- const ids = [...new Set([...current.map((e) => e.id), match.id])];
878
+ const ids = [...new Set([...current.map((e) => e.id), backing.id])];
852
879
  const response = await clusterApiRequest(page, 'PUT', url, {
853
880
  environmentIds: ids,
854
881
  });
@@ -860,8 +887,8 @@ async function linkClusterToWorkspaceViaApi(page, workspaceId, clusterName) {
860
887
  `${response.status()} ${response.statusText()}`);
861
888
  }
862
889
  const after = await listWorkspaceEnvironments(page, workspaceId);
863
- if (after.some((e) => e.id === match.id)) {
864
- console.log(`Linked cluster "${clusterName}" (environment ${match.id}) to workspace ${workspaceId}`);
890
+ if (after.some((e) => e.id === backing.id)) {
891
+ console.log(`Linked cluster "${clusterName}" (environment ${backing.id}) to workspace ${workspaceId}`);
865
892
  return;
866
893
  }
867
894
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camunda/e2e-test-suite",
3
- "version": "0.0.1276",
3
+ "version": "0.0.1278",
4
4
  "description": "End-to-end test helpers for Camunda 8",
5
5
  "repository": {
6
6
  "type": "git",