@camunda/e2e-test-suite 0.0.1272 → 0.0.1274

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.
Files changed (37) hide show
  1. package/dist/pages/8.8/AppsPage.d.ts +2 -0
  2. package/dist/pages/8.8/AppsPage.js +122 -0
  3. package/dist/pages/8.8/ClusterDetailsPage.d.ts +1 -0
  4. package/dist/pages/8.8/ClusterDetailsPage.js +29 -10
  5. package/dist/pages/8.8/ClusterPage.js +35 -2
  6. package/dist/pages/8.8/ClusterSecretsPage.d.ts +1 -0
  7. package/dist/pages/8.8/ClusterSecretsPage.js +62 -2
  8. package/dist/pages/8.8/FormJsPage.js +55 -24
  9. package/dist/pages/8.8/HomePage.d.ts +3 -0
  10. package/dist/pages/8.8/HomePage.js +62 -7
  11. package/dist/pages/8.8/LoginPage.js +32 -1
  12. package/dist/pages/8.8/ModelerCreatePage.d.ts +1 -0
  13. package/dist/pages/8.8/ModelerCreatePage.js +78 -16
  14. package/dist/pages/8.8/ModelerHomePage.d.ts +10 -0
  15. package/dist/pages/8.8/ModelerHomePage.js +339 -14
  16. package/dist/pages/8.8/UtilitiesPage.js +47 -2
  17. package/dist/pages/8.9/AppsPage.js +10 -1
  18. package/dist/pages/8.9/ClusterDetailsPage.d.ts +5 -0
  19. package/dist/pages/8.9/ClusterDetailsPage.js +66 -1
  20. package/dist/pages/8.9/ConsoleOrganizationPage.js +50 -17
  21. package/dist/pages/8.9/FormJsPage.js +23 -0
  22. package/dist/pages/8.9/ModelerCreatePage.d.ts +3 -0
  23. package/dist/pages/8.9/ModelerCreatePage.js +108 -7
  24. package/dist/pages/8.9/ModelerHomePage.d.ts +8 -0
  25. package/dist/pages/8.9/ModelerHomePage.js +116 -7
  26. package/dist/pages/8.9/ProcessApplicationPage.d.ts +1 -0
  27. package/dist/pages/8.9/ProcessApplicationPage.js +96 -0
  28. package/dist/pages/8.9/SettingsPage.js +12 -2
  29. package/dist/tests/8.8/test-setup.spec.js +29 -4
  30. package/dist/tests/8.9/console-user-flows.spec.js +3 -0
  31. package/dist/utils/consoleApiHelpers.d.ts +5 -0
  32. package/dist/utils/consoleApiHelpers.js +177 -2
  33. package/dist/utils/hubDeployDialog.d.ts +3 -0
  34. package/dist/utils/hubDeployDialog.js +74 -0
  35. package/dist/utils/hubResourceRename.d.ts +4 -0
  36. package/dist/utils/hubResourceRename.js +74 -0
  37. package/package.json +1 -1
@@ -266,7 +266,19 @@ class ProcessApplicationPage {
266
266
  // suite is. Re-navigating is the recovery; waiting longer on a page that
267
267
  // has already gone wrong is not, which is why the inner timeouts stay
268
268
  // short. A genuinely unbindable cluster still fails, with the same error.
269
+ //
270
+ // Under Hub workspaces (/workspaces/<ws>/projects/<id>) there is no
271
+ // /connected-clusters route -- it renders "No match for the current
272
+ // location". With Hub's Environments feature on (camunda-hub#29210),
273
+ // WorkspaceProjectDetail hides the Connected clusters card altogether and
274
+ // the deploy dialog offers the environments assigned to the parent
275
+ // workspace instead, so the binding is made there.
276
+ const workspaceId = paUrl.match(/\/workspaces\/([^/]+)\/projects\//)?.[1];
269
277
  await (0, test_1.expect)(async () => {
278
+ if (workspaceId) {
279
+ await this.ensureWorkspaceEnvironment(paUrl, workspaceId, clusterName);
280
+ return;
281
+ }
270
282
  await this.page.goto(`${paUrl}/connected-clusters`, {
271
283
  waitUntil: 'domcontentloaded',
272
284
  });
@@ -277,6 +289,90 @@ class ProcessApplicationPage {
277
289
  await this.page.goto(returnTo, { waitUntil: 'domcontentloaded' });
278
290
  return true;
279
291
  }
292
+ // Assigns the Environment backed by clusterName to the workspace, from
293
+ // workspace management's Environments tab (camunda-hub
294
+ // WorkspaceEnvironmentsTab). Without the Environments feature the project
295
+ // page still carries the Connected clusters card, whose "Configure" button
296
+ // opens the Define stages dialog -- that path is taken instead.
297
+ //
298
+ // Save replaces the workspace's whole assignment with the picker's
299
+ // selection, and parallel workers share this workspace, so only this
300
+ // cluster's toggle is touched and the result re-read afterwards. A
301
+ // concurrent save that drops it anyway surfaces as a deploy dialog without
302
+ // the cluster, which forgets the binding and comes back here (see
303
+ // forgetStageCluster).
304
+ async ensureWorkspaceEnvironment(paUrl, workspaceId, clusterName) {
305
+ await this.page.goto(paUrl, { waitUntil: 'domcontentloaded' });
306
+ await this.waitForProcessApplication();
307
+ const configure = this.page.getByRole('button', {
308
+ name: 'Configure',
309
+ exact: true,
310
+ });
311
+ // isVisible() is a point-in-time check: the Connected clusters card can
312
+ // still be mounting right after waitForProcessApplication resolves, so an
313
+ // instant read here races the card and can wrongly fall through to the
314
+ // (possibly unavailable) Environments tab below. Give it the same short
315
+ // wait the analogous hasEnvironmentRow checks elsewhere in this PR use.
316
+ const configureAppeared = await configure
317
+ .waitFor({ state: 'visible', timeout: 10000 })
318
+ .then(() => true)
319
+ .catch(() => false);
320
+ if (configureAppeared) {
321
+ await configure.click({ timeout: 30000 });
322
+ await this.defineDevelopmentStage(clusterName);
323
+ return;
324
+ }
325
+ const manageUrl = `${new URL(paUrl).origin}/workspaces/${workspaceId}/manage?tab=environments`;
326
+ await this.page.goto(manageUrl, { waitUntil: 'domcontentloaded' });
327
+ const heading = this.page.locator('[data-test="manage-environments-heading"]');
328
+ if (!(await heading
329
+ .waitFor({ state: 'visible', timeout: 60000 })
330
+ .then(() => true)
331
+ .catch(() => false))) {
332
+ throw new Error(`Could not bind "${clusterName}" as a deploy target: the project page ` +
333
+ 'has no Connected clusters "Configure" control, and the workspace ' +
334
+ `Environments tab did not render at ${manageUrl}.`);
335
+ }
336
+ const editButton = this.page.locator('[data-test="edit-environments-button"]');
337
+ await (0, test_1.expect)(editButton).toBeEnabled({ timeout: 60000 });
338
+ await editButton.click({ timeout: 30000 });
339
+ const editor = this.page.getByRole('group', {
340
+ name: 'Edit assigned environments',
341
+ });
342
+ await (0, test_1.expect)(editor).toBeVisible({ timeout: 30000 });
343
+ const search = editor.locator('[data-test="picker-search"]');
344
+ if (await search.isVisible().catch(() => false)) {
345
+ await search.fill(clusterName);
346
+ }
347
+ // Picker card toggles are named "Select <environment> (<cluster>)" and
348
+ // flip to "Selected ..." with aria-pressed once picked.
349
+ const escapedClusterName = clusterName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
350
+ const toggle = editor
351
+ .locator('[data-test="picker-environment"]')
352
+ .getByRole('button', {
353
+ name: new RegExp(`^Select(ed)? .*\\(${escapedClusterName}\\)$`),
354
+ })
355
+ .first();
356
+ await (0, test_1.expect)(toggle).toBeVisible({ timeout: 60000 });
357
+ if ((await toggle.getAttribute('aria-pressed')) === 'true') {
358
+ await this.page
359
+ .getByRole('button', { name: 'Cancel', exact: true })
360
+ .click({ timeout: 30000 });
361
+ await (0, test_1.expect)(editor).toBeHidden({ timeout: 30000 });
362
+ return;
363
+ }
364
+ await toggle.click({ timeout: 30000 });
365
+ await (0, test_1.expect)(toggle).toHaveAttribute('aria-pressed', 'true');
366
+ await this.page
367
+ .getByRole('button', { name: 'Save', exact: true })
368
+ .click({ timeout: 30000 });
369
+ await (0, test_1.expect)(editor).toBeHidden({ timeout: 60000 });
370
+ await (0, test_1.expect)(this.page
371
+ .getByRole('list', { name: 'Assigned environments' })
372
+ .getByRole('listitem')
373
+ .filter({ hasText: clusterName })
374
+ .first()).toBeVisible({ timeout: 60000 });
375
+ }
280
376
  // The /connected-clusters deep link opens the Define stages dialog by itself
281
377
  // while the application has no stage bound. Once one is bound -- a test that
282
378
  // deploys to a second cluster -- it has to be reopened from the page, so try
@@ -7,8 +7,18 @@ class SettingsPage {
7
7
  logoutButton;
8
8
  constructor(page) {
9
9
  this.page = page;
10
- this.openSettingsButton = page.getByLabel('Open Settings');
11
- this.logoutButton = page.getByRole('button', { name: 'Log out' });
10
+ // The Camunda Hub shell replaces Console's "Open Settings" header button
11
+ // with the design system's UserMenu, whose trigger carries
12
+ // data-slot="user-menu-trigger" and whose logout entry is a "Log out"
13
+ // menuitem (camunda-hub NavShellDs.common.tsx, nav-shell-ds.spec.saas.tsx).
14
+ this.openSettingsButton = page
15
+ .getByLabel('Open Settings')
16
+ .or(page.locator('[data-slot="user-menu-trigger"]'))
17
+ .first();
18
+ this.logoutButton = page
19
+ .getByRole('button', { name: 'Log out' })
20
+ .or(page.getByRole('menuitem', { name: 'Log out', exact: true }))
21
+ .first();
12
22
  }
13
23
  async clickOpenSettingsButton(retries = 3) {
14
24
  for (let i = 0; i < retries; i++) {
@@ -18,7 +18,8 @@ _8_8_1.test.describe('Cluster Setup Tests', () => {
18
18
  await (0, _setup_1.captureScreenshot)(page, testInfo);
19
19
  await (0, _setup_1.captureFailureVideo)(page, testInfo);
20
20
  });
21
- (0, _8_8_1.test)('Enable Alpha Features', async ({ page, loginPage, consoleOrganizationsPage, homePage, }, testInfo) => {
21
+ // Skipped due to bug #29346: https://github.com/camunda/camunda-hub/issues/29346
22
+ _8_8_1.test.skip('Enable Alpha Features', async ({ page, loginPage, consoleOrganizationsPage, homePage, }, testInfo) => {
22
23
  _8_8_1.test.skip(process.env.IS_PROD === 'true', 'Skipping test because not required on PROD test org');
23
24
  _8_8_1.test.skip(process.env.IS_AG === 'true', 'Skipping test because not required when IS_AG is true');
24
25
  await (0, UtilitiesPage_1.loginWithRetry)(page, loginPage, users_1.testUsers.mainUser, (testInfo.workerIndex + 1) * 1000);
@@ -45,6 +46,14 @@ _8_8_1.test.describe('Cluster Setup Tests', () => {
45
46
  await (0, consoleApiHelpers_1.waitForClusterHealthyViaApi)(page, clusterUuid);
46
47
  await homePage.clickClusters();
47
48
  }
49
+ // Whichever way the cluster was just created -- through the UI above or via
50
+ // the API in the else branch -- link it to the suite's shared Hub
51
+ // workspace: Hub gates deployment on the ENVIRONMENTS assigned to a
52
+ // workspace, so a cluster the workspace does not list is invisible in every
53
+ // deploy dialog. A no-op when the workspace does not exist yet (this test
54
+ // races the workspace-creating ones), which the deploy-time ensure-link
55
+ // then covers.
56
+ await (0, consoleApiHelpers_1.linkClusterToSharedWorkspaceViaApi)(page, clusterName);
48
57
  await clusterPage.clickClusterLink(clusterName);
49
58
  await clusterDetailsPage.assertComponentsHealth();
50
59
  // API-client creation is needed whenever this run will also exercise the
@@ -94,6 +103,14 @@ _8_8_1.test.describe('Cluster Setup Tests', () => {
94
103
  await (0, consoleApiHelpers_1.waitForClusterHealthyViaApi)(page, clusterUuid, 1500000);
95
104
  await homePage.clickClusters();
96
105
  }
106
+ // Whichever way the cluster was just created -- through the UI above or via
107
+ // the API in the else branch -- link it to the suite's shared Hub
108
+ // workspace: Hub gates deployment on the ENVIRONMENTS assigned to a
109
+ // workspace, so a cluster the workspace does not list is invisible in every
110
+ // deploy dialog. A no-op when the workspace does not exist yet (this test
111
+ // races the workspace-creating ones), which the deploy-time ensure-link
112
+ // then covers.
113
+ await (0, consoleApiHelpers_1.linkClusterToSharedWorkspaceViaApi)(page, clusterName);
97
114
  await clusterPage.clickClusterLink(clusterName);
98
115
  await clusterDetailsPage.assertComponentsHealth();
99
116
  if (process.env.IS_AG !== 'true') {
@@ -107,11 +124,17 @@ _8_8_1.test.describe('Cluster Setup Tests', () => {
107
124
  await (0, UtilitiesPage_1.loginWithRetry)(page, loginPage, user, (testInfo.workerIndex + 1) * 1000);
108
125
  });
109
126
  await _8_8_1.test.step('Navigate to Web Modeler', async () => {
110
- await (0, test_1.expect)(homePage.camundaComponentsButton).toBeVisible({
127
+ // Camunda Hub replaced Console + Web Modeler on SaaS: it lands on the
128
+ // Modeler home directly and has no app switcher to wait for.
129
+ await (0, test_1.expect)(homePage.camundaComponentsButton.or(homePage.hubHeader)).toBeVisible({
111
130
  timeout: 120000,
112
131
  });
113
- await appsPage.clickCamundaApps();
114
- await appsPage.clickModeler();
132
+ // Camunda Hub (hub.*) replaced Console + Web Modeler on SaaS and has no
133
+ // app switcher: its home already is the Modeler home.
134
+ if (!new URL(page.url()).hostname.startsWith('hub.')) {
135
+ await appsPage.clickCamundaApps();
136
+ await appsPage.clickModeler();
137
+ }
115
138
  await (0, test_1.expect)(modelerHomePage.modelerPageBanner).toBeVisible({
116
139
  timeout: 180000,
117
140
  });
@@ -120,6 +143,8 @@ _8_8_1.test.describe('Cluster Setup Tests', () => {
120
143
  await homePage.clickSkipCustomization();
121
144
  await modelerHomePage.createCrossComponentProjectFolder();
122
145
  await modelerHomePage.clickHomeBreadcrumb();
146
+ // Hub resets the overview to "My workspaces" on every load.
147
+ await modelerHomePage.showAllWorkspaces(10000);
123
148
  // The project-rename PATCH commits on the backend, but navigating to
124
149
  // Home right after can render the SPA's stale (pre-rename) project
125
150
  // list without refetching, so the folder never appears under a plain
@@ -106,6 +106,7 @@ _8_9_1.test.describe('Console User Flow Tests @tasklistV2', () => {
106
106
  await _8_9_1.test.step('Create First Email Alert', async () => {
107
107
  await clusterDetailsPage.createAlert(true);
108
108
  await (0, test_1.expect)(async () => {
109
+ await clusterDetailsPage.expandAlerts();
109
110
  (0, test_1.expect)(await clusterDetailsPage.alertsList.count()).toBeGreaterThan(1);
110
111
  await (0, test_1.expect)(clusterDetailsPage.alertsList.nth(0)).toContainText(`Email`);
111
112
  await (0, test_1.expect)(clusterDetailsPage.alertsList.nth(1)).toContainText(testUser.username);
@@ -157,6 +158,7 @@ _8_9_1.test.describe('Console User Flow Tests @tasklistV2', () => {
157
158
  await (0, test_1.expect)(clusterDetailsPage.alertsList.first()).toBeVisible({
158
159
  timeout: 20000,
159
160
  });
161
+ await clusterDetailsPage.expandAlerts();
160
162
  (0, test_1.expect)(await clusterDetailsPage.alertsList.count()).toBeGreaterThan(1);
161
163
  await (0, test_1.expect)(clusterDetailsPage.alertsList.nth(0)).toContainText(`Email`);
162
164
  await clusterDetailsPage.assertAlertText('Amount triggered1');
@@ -196,6 +198,7 @@ _8_9_1.test.describe('Console User Flow Tests @tasklistV2', () => {
196
198
  await (0, test_1.expect)(clusterDetailsPage.alertsList.first()).toBeVisible({
197
199
  timeout: 20000,
198
200
  });
201
+ await clusterDetailsPage.expandAlerts();
199
202
  (0, test_1.expect)(await clusterDetailsPage.alertsList.count()).toBeGreaterThan(1);
200
203
  }).toPass({ timeout: 60000 });
201
204
  await clusterDetailsPage.assertAlertText('Amount triggered3');
@@ -9,3 +9,8 @@ export declare function waitForClusterHealthyViaApi(page: Page, clusterUuid: str
9
9
  export declare function waitForClusterRestartHealthyViaApi(page: Page, clusterUuid: string, timeoutMs?: number): Promise<void>;
10
10
  export declare function areAiFeaturesEnabledViaApi(page: Page): Promise<boolean>;
11
11
  export declare function waitForAiFeaturesEnabledViaApi(page: Page, timeoutMs?: number): Promise<void>;
12
+ export declare function linkClusterToWorkspaceViaApi(page: Page, workspaceId: string, clusterName: string): Promise<void>;
13
+ export declare function rememberHubWorkspaceId(page: Page, workspaceId: string): void;
14
+ export declare function getHubWorkspaceId(page: Page): string | undefined;
15
+ export declare function linkClusterToSharedWorkspaceViaApi(page: Page, clusterName: string): Promise<void>;
16
+ export declare function ensureClusterLinkedToSharedWorkspace(page: Page, clusterName: string): Promise<void>;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.waitForAiFeaturesEnabledViaApi = exports.areAiFeaturesEnabledViaApi = exports.waitForClusterRestartHealthyViaApi = exports.waitForClusterHealthyViaApi = exports.ensureClientAuditLoggingViaApi = exports.ensureTasklistV2Enabled = exports.ensureClusterViaApi = exports.createClusterViaApi = exports.deleteClusterViaApi = exports.resetHubBearerToken = void 0;
3
+ exports.ensureClusterLinkedToSharedWorkspace = exports.linkClusterToSharedWorkspaceViaApi = exports.getHubWorkspaceId = exports.rememberHubWorkspaceId = exports.linkClusterToWorkspaceViaApi = exports.waitForAiFeaturesEnabledViaApi = exports.areAiFeaturesEnabledViaApi = exports.waitForClusterRestartHealthyViaApi = exports.waitForClusterHealthyViaApi = exports.ensureClientAuditLoggingViaApi = exports.ensureTasklistV2Enabled = exports.ensureClusterViaApi = exports.createClusterViaApi = exports.deleteClusterViaApi = exports.resetHubBearerToken = void 0;
4
4
  const test_1 = require("@playwright/test");
5
5
  const sleep_1 = require("./sleep");
6
6
  const PLAN_TYPE_ID = '574cde5f-54ae-43c1-851a-e6fb4f03423a';
@@ -169,7 +169,7 @@ const API_WRITE_TIMEOUT_MS = 45000;
169
169
  // POST is the only non-idempotent verb used here: repeating it creates a second
170
170
  // cluster under the same name. Let its timeout surface instead, so the caller
171
171
  // can recover by looking the cluster up by name (see createClusterViaApi).
172
- const TIMEOUT_RETRYABLE_METHODS = ['GET', 'PATCH', 'DELETE'];
172
+ const TIMEOUT_RETRYABLE_METHODS = ['GET', 'PUT', 'PATCH', 'DELETE'];
173
173
  async function clusterApiRequest(page, method, url, data) {
174
174
  let response;
175
175
  for (let attempt = 1; attempt <= API_MAX_ATTEMPTS; attempt++) {
@@ -751,3 +751,178 @@ async function waitForAiFeaturesEnabledViaApi(page, timeoutMs = 180000) {
751
751
  .toBe(true);
752
752
  }
753
753
  exports.waitForAiFeaturesEnabledViaApi = waitForAiFeaturesEnabledViaApi;
754
+ // Camunda Hub gates deployment on ENVIRONMENTS assigned to the workspace, not
755
+ // on the org's clusters: with the feature on, the deploy modal lists only the
756
+ // workspace's assigned environments, so a cluster created after the workspace
757
+ // exists is invisible there and the picker comes up empty. The wizard's
758
+ // "Assign environments" step only covers a workspace created AFTER its
759
+ // cluster, which the nightly cannot guarantee -- test-setup creates clusters
760
+ // and workspaces in parallel.
761
+ //
762
+ // Endpoints and semantics mirror camunda-hub's own
763
+ // e2e-playwright/api/modeler/EnvironmentsApiClient.ts.
764
+ async function hubApiOrigin(page) {
765
+ return new URL(page.url()).origin;
766
+ }
767
+ // Every `/api/internal/v2/**` body is wrapped by Hub's
768
+ // `ResponseBodyWrapperAdvice` as `{data, errors}`, so the array lives under
769
+ // `data` -- reading the body directly yielded an object and every array
770
+ // operation on it threw. Mirrors camunda-hub's own `toEnvironments`.
771
+ function toHubEnvironments(body) {
772
+ const payload = body && typeof body === 'object' && 'data' in body
773
+ ? body.data
774
+ : body;
775
+ const list = Array.isArray(payload) ? payload : [];
776
+ return list
777
+ .map((item) => (item ?? {}))
778
+ .filter((item) => typeof item.id === 'string')
779
+ .map((item) => ({
780
+ id: item.id,
781
+ name: typeof item.name === 'string' ? item.name : '',
782
+ clusterName: typeof item.clusterName === 'string' ? item.clusterName : '',
783
+ discoveryState: typeof item.discoveryState === 'string' ? item.discoveryState : '',
784
+ }));
785
+ }
786
+ async function listOrganizationEnvironments(page) {
787
+ const origin = await hubApiOrigin(page);
788
+ const response = await clusterApiRequest(page, 'GET', `${origin}/api/internal/v2/organizations/${encodeURIComponent(process.env.ORG_ID)}/environments`);
789
+ // 404 means the environments feature is off for this org: there is nothing
790
+ // to assign and deployment is not gated on it, so callers no-op.
791
+ if (response.status() === 404) {
792
+ return null;
793
+ }
794
+ return toHubEnvironments(await response.json());
795
+ }
796
+ async function listWorkspaceEnvironments(page, workspaceId) {
797
+ const origin = await hubApiOrigin(page);
798
+ const response = await clusterApiRequest(page, 'GET', `${origin}/api/internal/v2/workspaces/${encodeURIComponent(workspaceId)}/environments`);
799
+ if (!response.ok()) {
800
+ // NOT an empty list: the caller feeds this into a full-set replace, so
801
+ // reporting "nothing assigned" for a 401/5xx would unassign every
802
+ // environment the workspace already had.
803
+ throw new Error(`Failed to read environments for workspace ${workspaceId}: ` +
804
+ `${response.status()} ${response.statusText()}`);
805
+ }
806
+ return toHubEnvironments(await response.json());
807
+ }
808
+ // Assigns the environment backing `clusterName` to `workspaceId`, keeping the
809
+ // workspace's existing assignments: the PUT is a full-set REPLACE, so sending
810
+ // only the new id would silently unassign every other cluster the suite had
811
+ // already linked (the AWS cluster, a sibling Tasklist-mode project's default).
812
+ async function linkClusterToWorkspaceViaApi(page, workspaceId, clusterName) {
813
+ const environments = await listOrganizationEnvironments(page);
814
+ if (environments === null) {
815
+ console.log('Environments feature is off for this org; leaving the workspace unlinked');
816
+ return;
817
+ }
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];
823
+ if (!match) {
824
+ throw new Error(`No environment backs cluster "${clusterName}"; available: ` +
825
+ (environments
826
+ .map((e) => `${e.name}@${e.clusterName} (${e.discoveryState})`)
827
+ .join(', ') || '<none>'));
828
+ }
829
+ const assigned = await listWorkspaceEnvironments(page, workspaceId);
830
+ if (assigned.some((environment) => environment.id === match.id)) {
831
+ // Logged rather than returning silently: a quiet no-op here is
832
+ // indistinguishable from the helper never having run at all, which is
833
+ // exactly the ambiguity that hid an earlier bug.
834
+ console.log(`Cluster "${clusterName}" is already linked to workspace ${workspaceId}`);
835
+ return;
836
+ }
837
+ const origin = await hubApiOrigin(page);
838
+ const url = `${origin}/api/internal/v2/workspaces/${encodeURIComponent(workspaceId)}/environments`;
839
+ // Read-modify-write against a full-set replace, and test-setup runs its
840
+ // cluster tests in parallel: two workers can read the same assignment list
841
+ // and the second PUT then drops the first one's environment. Re-read after
842
+ // writing and retry when our own id is missing, so a lost update heals
843
+ // instead of silently removing a cluster from every later deploy dialog.
844
+ for (let attempt = 1; attempt <= 3; attempt++) {
845
+ const current = attempt === 1
846
+ ? assigned
847
+ : await listWorkspaceEnvironments(page, workspaceId);
848
+ if (attempt > 1 && current.some((e) => e.id === match.id)) {
849
+ return;
850
+ }
851
+ const ids = [...new Set([...current.map((e) => e.id), match.id])];
852
+ const response = await clusterApiRequest(page, 'PUT', url, {
853
+ environmentIds: ids,
854
+ });
855
+ if (!response.ok()) {
856
+ // clusterApiRequest returns non-2xx for non-transient statuses rather
857
+ // than throwing, so an unchecked call would log success and let the
858
+ // deploy fail much later with an unrelated-looking empty picker.
859
+ throw new Error(`Failed to link cluster "${clusterName}" to workspace ${workspaceId}: ` +
860
+ `${response.status()} ${response.statusText()}`);
861
+ }
862
+ 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}`);
865
+ return;
866
+ }
867
+ }
868
+ throw new Error(`Cluster "${clusterName}" did not stay linked to workspace ${workspaceId} ` +
869
+ 'after 3 attempts; a concurrent environment replace keeps dropping it');
870
+ }
871
+ exports.linkClusterToWorkspaceViaApi = linkClusterToWorkspaceViaApi;
872
+ // The Hub workspace the suite's shared project lives in. Remembered while the
873
+ // flow is on a /workspaces/<id> path so later steps -- the deploy dialog runs
874
+ // from the form editor, whose path has no workspace segment -- can still
875
+ // address it.
876
+ const hubWorkspaceIds = new WeakMap();
877
+ function rememberHubWorkspaceId(page, workspaceId) {
878
+ hubWorkspaceIds.set(page, workspaceId);
879
+ }
880
+ exports.rememberHubWorkspaceId = rememberHubWorkspaceId;
881
+ function getHubWorkspaceId(page) {
882
+ return hubWorkspaceIds.get(page);
883
+ }
884
+ exports.getHubWorkspaceId = getHubWorkspaceId;
885
+ // Resolves the suite's shared Hub workspace by NAME. Needed at cluster-creation
886
+ // time, where there is no /workspaces/<id> page URL to read the id from.
887
+ // Endpoint and payload shape from camunda-hub's own
888
+ // e2e-playwright/api/modeler/WorkspacesApiClient.ts: the list is nested as
889
+ // `{data: {items: [...]}}`, not the bare array the environments routes return.
890
+ const SHARED_HUB_WORKSPACE_NAME = 'Cross Component Test Project';
891
+ async function findHubWorkspaceIdByName(page, name) {
892
+ const origin = await hubApiOrigin(page);
893
+ const response = await clusterApiRequest(page, 'GET', `${origin}/api/internal/v2/organizations/${encodeURIComponent(process.env.ORG_ID)}/workspaces?sort=name,asc&offset=0&limit=100`);
894
+ if (!response.ok()) {
895
+ return undefined;
896
+ }
897
+ const body = (await response.json());
898
+ const data = (body.data ?? body);
899
+ const items = Array.isArray(data.items) ? data.items : [];
900
+ const match = items
901
+ .map((item) => (item ?? {}))
902
+ .find((item) => item.name === name && typeof item.id === 'string');
903
+ return match?.id;
904
+ }
905
+ // Links a just-created cluster to the suite's shared workspace. Safe to call
906
+ // right after creating a cluster by API or through the UI: when the workspace
907
+ // does not exist yet -- test-setup creates clusters and workspaces in parallel,
908
+ // so either order happens -- this logs and returns, and the ensure-link at
909
+ // deploy time covers that case instead.
910
+ async function linkClusterToSharedWorkspaceViaApi(page, clusterName) {
911
+ const workspaceId = getHubWorkspaceId(page) ??
912
+ (await findHubWorkspaceIdByName(page, SHARED_HUB_WORKSPACE_NAME));
913
+ if (!workspaceId) {
914
+ console.log(`Shared workspace "${SHARED_HUB_WORKSPACE_NAME}" does not exist yet; ` +
915
+ `"${clusterName}" will be linked when a deploy first needs it`);
916
+ return;
917
+ }
918
+ rememberHubWorkspaceId(page, workspaceId);
919
+ await linkClusterToWorkspaceViaApi(page, workspaceId, clusterName);
920
+ }
921
+ exports.linkClusterToSharedWorkspaceViaApi = linkClusterToSharedWorkspaceViaApi;
922
+ // Ensure-link at the point a deploy dialog is about to be opened. Resolves the
923
+ // workspace from the id remembered while the flow was on it, else by name.
924
+ // Cheap and idempotent: it returns early once the environment is assigned.
925
+ async function ensureClusterLinkedToSharedWorkspace(page, clusterName) {
926
+ await linkClusterToSharedWorkspaceViaApi(page, clusterName);
927
+ }
928
+ exports.ensureClusterLinkedToSharedWorkspace = ensureClusterLinkedToSharedWorkspace;
@@ -0,0 +1,3 @@
1
+ import { Locator, Page } from '@playwright/test';
2
+ export declare function selectOnlyThisResourceInDialog(page: Page, dialog: Locator): Promise<void>;
3
+ export declare function assertClusterSelectedInDialog(dialog: Locator, clusterName: string): Promise<void>;
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.assertClusterSelectedInDialog = exports.selectOnlyThisResourceInDialog = void 0;
4
+ const test_1 = require("@playwright/test");
5
+ // Hub's deploy / "Deploy & run" dialog scopes a deployment through a
6
+ // design-system select. The trigger carries no aria-labelledby, so its
7
+ // accessible name is its own VALUE ("All resources") rather than the
8
+ // "Resources to deploy" label beside it -- a name-based lookup silently
9
+ // matched nothing and left every deployment scoped to the whole project.
10
+ // Address it by id instead.
11
+ //
12
+ // Shared by the form deploy dialog and the diagram deploy-and-run dialog so
13
+ // the two cannot drift apart.
14
+ async function selectOnlyThisResourceInDialog(page, dialog) {
15
+ const scopeSelect = dialog
16
+ .locator('#deployment-scope-selection')
17
+ .or(dialog.getByRole('combobox', { name: 'Resources to deploy' }))
18
+ .first();
19
+ const hasScopeSelect = await scopeSelect
20
+ .waitFor({ state: 'visible', timeout: 10000 })
21
+ .then(() => true)
22
+ .catch(() => false);
23
+ if (!hasScopeSelect) {
24
+ // On Hub the default scope is the WHOLE PROJECT, so quietly accepting a
25
+ // missing trigger turns a resource deployment into a project deployment
26
+ // -- and the deployment confirmation is worded broadly enough that the
27
+ // test would still pass. Fail there; only the pre-Hub shells, which have
28
+ // no scope selector at all, may continue.
29
+ if (new URL(page.url()).hostname.startsWith('hub.')) {
30
+ throw new Error('Hub deploy dialog exposed no deployment-scope select ' +
31
+ '(#deployment-scope-selection); refusing to fall back to deploying ' +
32
+ 'the whole project');
33
+ }
34
+ return;
35
+ }
36
+ if ((await scopeSelect.innerText()).includes('Only this resource')) {
37
+ return;
38
+ }
39
+ await scopeSelect.click();
40
+ // Page-scoped: the select renders its options in a portal attached to
41
+ // <body>, outside the dialog element.
42
+ await page
43
+ .getByRole('option', { name: 'Only this resource' })
44
+ .or(page.getByRole('menuitem', { name: 'Only this resource' }))
45
+ .or(page.getByText('Only this resource', { exact: true }))
46
+ .first()
47
+ .click({ timeout: 30000 });
48
+ await (0, test_1.expect)(scopeSelect).toContainText('Only this resource', {
49
+ timeout: 10000,
50
+ });
51
+ }
52
+ exports.selectOnlyThisResourceInDialog = selectOnlyThisResourceInDialog;
53
+ // Confirms the chosen cluster is selected in a deploy dialog.
54
+ //
55
+ // Carbon nested a toggle `switch` inside the cluster's <label>; Hub's
56
+ // design-system dialog renders the control as a sibling radio next to the
57
+ // cluster name (auto-selected when a single cluster is offered), so a
58
+ // label-scoped lookup finds nothing at all. Search the DIALOG for a control
59
+ // named after the cluster, and keep the label-scoped shapes for the older
60
+ // shells.
61
+ async function assertClusterSelectedInDialog(dialog, clusterName) {
62
+ const byName = new RegExp(`^${clusterName}`);
63
+ const labelRow = dialog.locator('label').filter({ hasText: byName }).first();
64
+ const control = dialog
65
+ .getByRole('radio', { name: byName })
66
+ .or(dialog.getByRole('switch', { name: byName }))
67
+ .or(dialog.getByRole('checkbox', { name: byName }))
68
+ .or(labelRow.getByRole('switch'))
69
+ .or(labelRow.getByRole('radio'))
70
+ .or(labelRow.getByRole('checkbox'))
71
+ .first();
72
+ await (0, test_1.expect)(control).toBeChecked({ timeout: 30000 });
73
+ }
74
+ exports.assertClusterSelectedInDialog = assertClusterSelectedInDialog;
@@ -0,0 +1,4 @@
1
+ import { Page, Locator } from '@playwright/test';
2
+ export declare function hubBreadcrumbs(page: Page): Locator;
3
+ export declare function hubRenameInput(page: Page): Locator;
4
+ export declare function renameHubResource(page: Page, currentName: string, newName: string): Promise<void>;
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.renameHubResource = exports.hubRenameInput = exports.hubBreadcrumbs = void 0;
4
+ const test_1 = require("@playwright/test");
5
+ // Camunda Hub no longer exposes a resource's title as a directly editable
6
+ // field: the rename happens in a shared dialog (`rename-input` /
7
+ // `rename-submit`) opened from the CURRENT breadcrumb crumb's actions menu.
8
+ //
9
+ // Ported from camunda-hub's own e2e-playwright/pages/BasePage.ts
10
+ // (`renameCurrentEntityViaBreadcrumb`) rather than invented, including two
11
+ // hazards documented there:
12
+ // * the trigger must be scoped to the breadcrumbs and taken as `.last()` --
13
+ // an unscoped `/ actions$/` match can resolve to the PARENT's crumb
14
+ // mid-navigation and rename the parent instead;
15
+ // * NavProvider keeps the previous page's settled crumbs while the new
16
+ // route's are still pending, so gate positively on the trigger naming the
17
+ // entity we mean to rename before opening its menu.
18
+ function hubBreadcrumbs(page) {
19
+ return page
20
+ .locator('[data-test="breadcrumbs"]')
21
+ .or(page.getByRole('navigation', { name: 'Context navigation' }))
22
+ .or(page.getByRole('navigation', { name: 'Breadcrumb', exact: true }));
23
+ }
24
+ exports.hubBreadcrumbs = hubBreadcrumbs;
25
+ function hubRenameInput(page) {
26
+ return page
27
+ .locator('[data-test="rename-input"]')
28
+ .or(page.locator('[data-test="editable-input"]'))
29
+ .first();
30
+ }
31
+ exports.hubRenameInput = hubRenameInput;
32
+ function breadcrumbActionsButton(page) {
33
+ return hubBreadcrumbs(page)
34
+ .getByRole('button', { name: / actions$/ })
35
+ .last();
36
+ }
37
+ // `currentName` is the server default the resource still carries (e.g.
38
+ // "New form", "New BPMN diagram"); it is what the gate matches on.
39
+ async function renameHubResource(page, currentName, newName) {
40
+ const breadcrumbs = hubBreadcrumbs(page);
41
+ const trigger = breadcrumbActionsButton(page);
42
+ const renameInput = page.locator('[data-test="rename-input"]');
43
+ const renameSubmit = page.locator('[data-test="rename-submit"]');
44
+ const hasBreadcrumbRename = await trigger
45
+ .waitFor({ state: 'visible', timeout: 30000 })
46
+ .then(() => true)
47
+ .catch(() => false);
48
+ if (!hasBreadcrumbRename) {
49
+ // A shell that still renames through the inline editable title: fill it
50
+ // directly, the way this flow always used to.
51
+ const inline = hubRenameInput(page);
52
+ await (0, test_1.expect)(inline).toBeVisible({ timeout: 60000 });
53
+ await inline.fill(newName, { timeout: 60000 });
54
+ await inline.press('Enter');
55
+ return;
56
+ }
57
+ await (0, test_1.expect)(trigger).toHaveAccessibleName(`${currentName} actions`, {
58
+ timeout: 30000,
59
+ });
60
+ await (0, test_1.expect)(async () => {
61
+ if (await breadcrumbs.getByText(newName).isVisible())
62
+ return;
63
+ await trigger.click({ timeout: 15000 });
64
+ await page.getByRole('menuitem', { name: 'Rename' }).click({ timeout: 15000 });
65
+ await (0, test_1.expect)(renameInput).toBeVisible({ timeout: 15000 });
66
+ await renameInput.fill(newName);
67
+ await (0, test_1.expect)(renameInput).toHaveValue(newName, { timeout: 15000 });
68
+ await renameSubmit.click({ timeout: 15000 });
69
+ await (0, test_1.expect)(breadcrumbs.getByText(newName)).toBeVisible({
70
+ timeout: 15000,
71
+ });
72
+ }).toPass({ timeout: 120000 });
73
+ }
74
+ exports.renameHubResource = renameHubResource;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camunda/e2e-test-suite",
3
- "version": "0.0.1272",
3
+ "version": "0.0.1274",
4
4
  "description": "End-to-end test helpers for Camunda 8",
5
5
  "repository": {
6
6
  "type": "git",