@camunda/e2e-test-suite 0.0.1250 → 0.0.1252

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 (54) hide show
  1. package/dist/pages/8.9/AppsPage.js +24 -12
  2. package/dist/pages/8.9/ClusterDetailsPage.js +8 -1
  3. package/dist/pages/8.9/ClusterPage.js +22 -20
  4. package/dist/pages/8.9/HomePage.js +6 -1
  5. package/dist/pages/8.9/ProcessApplicationPage.js +30 -10
  6. package/dist/pages/BrowseAllConnectorMarketplacePage.d.ts +17 -0
  7. package/dist/pages/BrowseAllConnectorMarketplacePage.js +145 -0
  8. package/dist/pages/SM-8.10/ClusterDetailsPage.d.ts +1 -1
  9. package/dist/pages/SM-8.10/ClusterDetailsPage.js +13 -6
  10. package/dist/pages/SM-8.10/ClusterPage.js +3 -1
  11. package/dist/pages/SM-8.10/ConnectorMarketplacePage.d.ts +2 -0
  12. package/dist/pages/SM-8.10/ConnectorMarketplacePage.js +16 -1
  13. package/dist/pages/SM-8.10/ModelerCreatePage.d.ts +4 -1
  14. package/dist/pages/SM-8.10/ModelerCreatePage.js +78 -51
  15. package/dist/pages/SM-8.10/ModelerHomePage.d.ts +2 -1
  16. package/dist/pages/SM-8.10/ModelerHomePage.js +24 -0
  17. package/dist/pages/SM-8.10/OperateHomePage.js +8 -1
  18. package/dist/pages/SM-8.10/OptimizeHomePage.js +13 -4
  19. package/dist/pages/SM-8.10/ProcessApplicationPage.d.ts +10 -5
  20. package/dist/pages/SM-8.10/ProcessApplicationPage.js +163 -68
  21. package/dist/pages/SM-8.10/UtilitiesPage.js +6 -6
  22. package/dist/pages/SM-8.7/NavigationPage.js +2 -1
  23. package/dist/tests/8.10/connectors-user-flows.spec.js +4 -2
  24. package/dist/tests/8.10/saas-migration-path-user-flows.spec.js +2 -1
  25. package/dist/tests/8.10/smoke-tests.spec.js +2 -1
  26. package/dist/tests/8.7/connectors-user-flows.spec.js +8 -4
  27. package/dist/tests/8.7/saas-migration-path-user-flows.spec.js +2 -1
  28. package/dist/tests/8.7/smoke-tests.spec.js +2 -1
  29. package/dist/tests/8.8/connectors-user-flows.spec.js +4 -2
  30. package/dist/tests/8.8/saas-migration-path-user-flows.spec.js +2 -1
  31. package/dist/tests/8.8/smoke-tests.spec.js +2 -1
  32. package/dist/tests/8.9/connectors-user-flows.spec.js +4 -2
  33. package/dist/tests/8.9/saas-migration-path-user-flows.spec.js +2 -1
  34. package/dist/tests/8.9/smoke-tests.spec.js +2 -1
  35. package/dist/tests/SM-8.10/cluster-management-user-flows.spec.js +13 -6
  36. package/dist/tests/SM-8.10/connectors-user-flows.spec.js +2 -1
  37. package/dist/tests/SM-8.10/hub-web-modeler-smoke.spec.d.ts +1 -0
  38. package/dist/tests/SM-8.10/hub-web-modeler-smoke.spec.js +24 -0
  39. package/dist/tests/SM-8.10/mt-enabled-user-flows.spec.js +3 -1
  40. package/dist/tests/SM-8.10/topology-orchestration-smoke.spec.d.ts +1 -0
  41. package/dist/tests/SM-8.10/topology-orchestration-smoke.spec.js +58 -0
  42. package/dist/tests/SM-8.7/topology-orchestration-smoke.spec.d.ts +1 -0
  43. package/dist/tests/SM-8.7/topology-orchestration-smoke.spec.js +62 -0
  44. package/dist/tests/SM-8.8/topology-orchestration-smoke.spec.d.ts +1 -0
  45. package/dist/tests/SM-8.8/topology-orchestration-smoke.spec.js +58 -0
  46. package/dist/tests/SM-8.9/topology-orchestration-smoke.spec.d.ts +1 -0
  47. package/dist/tests/SM-8.9/topology-orchestration-smoke.spec.js +58 -0
  48. package/dist/tests/c8Run-8.7/connectors-user-flows.spec.js +6 -3
  49. package/dist/tests/c8Run-8.8/connectors-user-flows.spec.js +6 -3
  50. package/dist/tests/c8Run-8.9/connectors-user-flows.spec.js +6 -3
  51. package/dist/utils/modelerCluster.d.ts +4 -6
  52. package/dist/utils/modelerCluster.js +55 -32
  53. package/package.json +1 -1
  54. package/playwright.config.ts +18 -5
@@ -9,6 +9,8 @@ const modelerCluster_1 = require("../../utils/modelerCluster");
9
9
  class ModelerCreatePage {
10
10
  page;
11
11
  processApplicationPage;
12
+ pendingFileSaves = 0;
13
+ lastFileSaveActivity = 0;
12
14
  generalPanel;
13
15
  taskDefinitionPanel;
14
16
  processIdInput;
@@ -43,7 +45,6 @@ class ModelerCreatePage {
43
45
  restConnectorOption;
44
46
  changeElementSearchInput;
45
47
  changeElementResults;
46
- changeElementTabStrip;
47
48
  reusableAssetsTab;
48
49
  marketPlaceButton;
49
50
  clientIdTextbox;
@@ -108,6 +109,23 @@ class ModelerCreatePage {
108
109
  constructor(page) {
109
110
  this.page = page;
110
111
  this.processApplicationPage = new ProcessApplicationPage_1.ProcessApplicationPage(page);
112
+ // Track the modeler's debounced autosave (PATCH /files/{id}) so a deploy
113
+ // can wait until the server holds the latest edits. See
114
+ // waitForFileSavesToSettle().
115
+ page.on('request', (request) => {
116
+ if (ModelerCreatePage.isFileSave(request)) {
117
+ this.pendingFileSaves++;
118
+ this.lastFileSaveActivity = Date.now();
119
+ }
120
+ });
121
+ const settleFileSave = (request) => {
122
+ if (ModelerCreatePage.isFileSave(request)) {
123
+ this.pendingFileSaves = Math.max(0, this.pendingFileSaves - 1);
124
+ this.lastFileSaveActivity = Date.now();
125
+ }
126
+ };
127
+ page.on('requestfinished', settleFileSave);
128
+ page.on('requestfailed', settleFileSave);
111
129
  this.generalPanel = page.locator('[data-group-id="group-general"]');
112
130
  this.taskDefinitionPanel = page
113
131
  .locator('div')
@@ -211,17 +229,17 @@ class ModelerCreatePage {
211
229
  .locator('[data-id="replace.template-io.camunda.connectors.HttpJson.v2"]');
212
230
  this.changeElementSearchInput = page.locator('.djs-popup-search input');
213
231
  this.changeElementResults = page.locator('.djs-popup-results');
214
- this.changeElementTabStrip = page.locator('.djs-popup-tabs-container');
215
232
  // The tab renders its label as text content, and the product source
216
233
  // spells it both "Reusable Assets" and "Reusable assets".
217
234
  this.reusableAssetsTab = page.getByRole('tab', {
218
235
  name: /reusable assets/i,
219
236
  });
220
- // A title query is ambiguous: when the filtered result list has no connector
221
- // match the popup also renders an empty-state link carrying the same title,
222
- // so the query resolves to two elements and clicking throws in strict mode.
223
- // Target the popup header entry by its stable entry id instead.
224
- this.marketPlaceButton = page.locator('[data-id="browseConnectors"]');
237
+ // Browse All replaces the legacy Marketplace entry only in large menus
238
+ // when the Design System is enabled; small or DS-off menus keep the
239
+ // original entry.
240
+ this.marketPlaceButton = page
241
+ .locator('[data-id="hub-browse-all"]')
242
+ .or(page.locator('[data-id="browseConnectors"]'));
225
243
  this.clientIdTextbox = page.getByLabel('Client ID');
226
244
  this.clientSecretTextbox = page.getByLabel('Client secret');
227
245
  this.rememberCredentialsCheckbox = page.getByText('Remember credentials');
@@ -455,9 +473,13 @@ class ModelerCreatePage {
455
473
  await legacyItem.click({ timeout: 90000 });
456
474
  }
457
475
  async runProcessInstance(variables = '', tenant = '', businessId = '') {
476
+ // ensureStageCluster may navigate away from the diagram, which would drop
477
+ // a save that is still debounced, so settle before and after it.
478
+ await this.waitForFileSavesToSettle();
458
479
  await this.processApplicationPage.ensureStageCluster(process.env.MODELER_CLUSTER_NAME);
459
- await this.page.waitForTimeout(2000); // the canvas autosaves. This can take a second to save.
480
+ await this.waitForFileSavesToSettle();
460
481
  const maxRetries = 3;
482
+ let executeSucceeded = false;
461
483
  for (let attempt = 0; attempt < maxRetries; attempt++) {
462
484
  try {
463
485
  await this.clickDeployAndRunMainButton();
@@ -482,9 +504,16 @@ class ModelerCreatePage {
482
504
  });
483
505
  await this.businessIdInput.fill(businessId);
484
506
  }
485
- await this.clickDeployAndRunSubButton();
486
- await this.instanceStartedAssertion();
487
- return;
507
+ const [executeResponse] = await Promise.all([
508
+ this.page.waitForResponse((response) => response.request().method() === 'POST' &&
509
+ // Workspace-era Hub builds execute via /v2/projects/{id}; older
510
+ // builds used /process-applications/{id}.
511
+ /\/(process-applications|projects)\/[^/]+\/execute$/.test(new URL(response.url()).pathname), { timeout: 30000 }),
512
+ this.clickDeployAndRunSubButton(),
513
+ ]);
514
+ (0, test_1.expect)(executeResponse.ok(), await executeResponse.text()).toBeTruthy();
515
+ executeSucceeded = true;
516
+ break;
488
517
  }
489
518
  catch (error) {
490
519
  if (attempt < maxRetries - 1) {
@@ -497,12 +526,33 @@ class ModelerCreatePage {
497
526
  }
498
527
  }
499
528
  }
529
+ if (executeSucceeded) {
530
+ await this.instanceStartedAssertion();
531
+ }
500
532
  }
501
533
  // Test mode mounts a one-time "A new way to test your processes" modal
502
534
  // (camunda-hub TestAnnouncementModal) that covers the setup steps behind it.
503
535
  // It renders unless localStorage already carries the seen flag, which it
504
536
  // reads on mount; a fresh Playwright context always starts empty, so set the
505
537
  // flag before entering Test mode rather than clicking the modal away.
538
+ static isFileSave(request) {
539
+ return (request.method() === 'PATCH' &&
540
+ /\/api\/internal\/files\/[^/]+$/.test(new URL(request.url()).pathname));
541
+ }
542
+ // Deploy & run deploys the diagram the server holds, not what is on screen.
543
+ // Hub debounces edits for roughly a second before it sends
544
+ // PATCH /files/{id}, and the autosave label keeps showing the previous
545
+ // "Autosaved at ..." time during that window, so the label cannot prove the
546
+ // latest edits are stored. Wait until no file save is in flight and none
547
+ // has started for longer than the debounce, measured from this call, so a
548
+ // save queued by edits made just before it is observed and awaited.
549
+ async waitForFileSavesToSettle(quietMs = 2000, timeout = 60000) {
550
+ const start = Date.now();
551
+ await test_1.expect
552
+ .poll(() => this.pendingFileSaves === 0 &&
553
+ Date.now() - Math.max(start, this.lastFileSaveActivity) >= quietMs, { timeout, intervals: [250] })
554
+ .toBe(true);
555
+ }
506
556
  async suppressTestModeAnnouncement() {
507
557
  await this.page.evaluate(() => localStorage.setItem('play-testAnnouncementSeen', 'true'));
508
558
  }
@@ -996,15 +1046,13 @@ class ModelerCreatePage {
996
1046
  // before bpmn-js attaches its search handler during the popup re-render.
997
1047
  async searchAndClickRestConnector() {
998
1048
  await (0, test_1.expect)(this.changeElementSearchInput).toBeVisible({ timeout: 30000 });
999
- await this.changeElementSearchInput.click();
1000
- await this.changeElementSearchInput.fill('');
1001
1049
  // 'Send REST Request' is the template's current name -- connectors #7917
1002
1050
  // renamed it from 'REST Outbound Connector' on 2026-07-15, and "Outbound"
1003
1051
  // now appears in no searchable field, so the old filter matched nothing.
1004
- await this.changeElementSearchInput.pressSequentially('Send REST Request', {
1005
- delay: 50,
1006
- });
1007
- await (0, test_1.expect)(this.restConnectorOption).toBeVisible({ timeout: 90000 });
1052
+ // Templates live under the popup's "Reusable assets" tab, so searching the
1053
+ // BPMN tab alone never lists them.
1054
+ await this.revealChangeElementTemplate(this.restConnectorOption, 'Send REST Request');
1055
+ await (0, test_1.expect)(this.restConnectorOption).toBeVisible({ timeout: 30000 });
1008
1056
  await this.restConnectorOption.click({ timeout: 90000 });
1009
1057
  }
1010
1058
  async clickRestConnectorOption(recoverSession) {
@@ -1106,38 +1154,10 @@ class ModelerCreatePage {
1106
1154
  name: /^Configure (test case|scenario)$/,
1107
1155
  }))
1108
1156
  .first();
1109
- // Play settles into exactly ONE of three states, and which one is not
1110
- // knowable up front, so race them instead of timing out on each in turn:
1111
- // the setup overlay, the legacy Continue button, or the configure-test
1112
- // panel (meaning setup is already done for this diagram).
1113
- //
1114
- // Waiting only for the first two and reading "neither" as "already ready"
1115
- // was wrong once the diagram moved inside a process application.
1116
- // ConfigurationOverlay renders null until isDeploymentStateValidated, and
1117
- // it sets that flag synchronously ONLY when processApplicationId is
1118
- // undefined -- exactly what a project-root diagram used to give it. With
1119
- // the diagram in a process application the id is defined, so the overlay
1120
- // must first await checkExistingDeployment(). The old 15s probe raced that
1121
- // async call, concluded "already ready" and returned while the overlay was
1122
- // still about to appear. ConfigureTestPanel returns null for as long as the
1123
- // overlay is up, so the Start button never existed and
1124
- // clickStartInstanceButton burned its entire retry budget looking for it.
1125
- // camunda-hub's "Play" -> "Test Studio" rebrand renamed this container's
1126
- // data-test from `play-configuration-overlay` to
1127
- // `test-studio-configuration-overlay`, and a later build dropped both
1128
- // container data-test attributes entirely (neither is present in the
1129
- // failing run's trace DOM), so those two gates now match nothing. The
1130
- // always-present anchor is the panel's heading, which the current build
1131
- // renders as "Set up environment" (verb, two words) -- the old
1132
- // `getByText('Setup environment')` (one word) never matched it and the
1133
- // whole chain timed out. Match the heading with a spelling-tolerant regex
1134
- // so a future flip between the two forms cannot re-break the gate, and keep
1135
- // the legacy data-test attributes for older modeler builds. This locator
1136
- // means "the setup panel is still on screen".
1137
1157
  const configurationOverlay = this.page
1138
1158
  .locator('[data-test="test-studio-configuration-overlay"]')
1139
1159
  .or(this.page.locator('[data-test="play-configuration-overlay"]'))
1140
- .or(this.page.getByText(/set ?up environment/i))
1160
+ .or(this.page.getByText(/set up test run/i))
1141
1161
  .first();
1142
1162
  const configureTestPanel = this.page.getByTestId('configure-test-panel');
1143
1163
  await (0, test_1.expect)(configurationOverlay
@@ -1156,18 +1176,23 @@ class ModelerCreatePage {
1156
1176
  if (isNewFlow) {
1157
1177
  // Step 1: connect cluster if not yet connected (modal with cluster dropdown)
1158
1178
  const connectClusterButton = this.page.getByRole('button', {
1159
- name: 'Connect cluster',
1179
+ name: 'Select environment',
1160
1180
  });
1161
1181
  const needsConnect = await connectClusterButton
1162
1182
  .isVisible({ timeout: 3000 })
1163
1183
  .catch(() => false);
1164
1184
  if (needsConnect) {
1165
1185
  await connectClusterButton.click({ timeout });
1186
+ // camunda-hub's TestModeDeployEndpointConfigModal replaced the
1187
+ // "Configure environment" combobox dialog with a "Choose where to
1188
+ // run" dialog: environments now render as a radio group (one radio
1189
+ // per environment), not a dropdown. The first environment is
1190
+ // auto-selected once the list loads, but click it explicitly rather
1191
+ // than relying on that default.
1166
1192
  const configureEnvDialog = this.page
1167
1193
  .getByRole('dialog')
1168
- .filter({ hasText: 'Configure environment' });
1169
- await configureEnvDialog.getByRole('combobox').click({ timeout });
1170
- await this.page.getByRole('option').first().click({ timeout });
1194
+ .filter({ hasText: 'Choose where to run' });
1195
+ await configureEnvDialog.getByRole('radio').first().click({ timeout });
1171
1196
  await configureEnvDialog
1172
1197
  .getByRole('button', { name: 'Save' })
1173
1198
  .click({ timeout });
@@ -1255,7 +1280,9 @@ class ModelerCreatePage {
1255
1280
  if (await this.isVisibleWithin(option, 5000)) {
1256
1281
  return true;
1257
1282
  }
1258
- if (await this.isVisibleWithin(this.changeElementTabStrip, 5000)) {
1283
+ // The tab strip's container class changed across Hub builds; probe the
1284
+ // "Reusable assets" tab itself instead of its wrapper.
1285
+ if (await this.isVisibleWithin(this.reusableAssetsTab, 5000)) {
1259
1286
  // A failed tab click leaves the search path intact, so do not throw.
1260
1287
  await this.reusableAssetsTab.click({ timeout: 30000 }).catch(() => { });
1261
1288
  if (await this.isVisibleWithin(option, 15000)) {
@@ -1,4 +1,4 @@
1
- import { Page, Locator } from '@playwright/test';
1
+ import { Browser, Page, Locator } from '@playwright/test';
2
2
  declare class ModelerHomePage {
3
3
  private page;
4
4
  private defaultFolderName;
@@ -57,6 +57,7 @@ declare class ModelerHomePage {
57
57
  username: string;
58
58
  password: string;
59
59
  }): Promise<void>;
60
+ assignWorkspaceEnvironmentAsOrgAdmin(browser: Browser): Promise<void>;
60
61
  clickCrossComponentProjectFolder(credentials?: {
61
62
  username: string;
62
63
  password: string;
@@ -349,6 +349,30 @@ class ModelerHomePage {
349
349
  // under flag-on that folder maps to a Workspace.
350
350
  await this.ensureWorkspaceOpen(credentials);
351
351
  }
352
+ // Only an org admin can assign Environments to a workspace (Hub 8.10), so a
353
+ // workspace created by a plain Member has nothing to deploy to until one does.
354
+ // Call while this page is inside that workspace: assigns an Environment as the
355
+ // default Identity first user (an org admin) in a separate browser context,
356
+ // leaving this page's session untouched.
357
+ async assignWorkspaceEnvironmentAsOrgAdmin(browser) {
358
+ const workspaceUrl = this.page.url();
359
+ const context = await browser.newContext({
360
+ baseURL: new URL(workspaceUrl).origin,
361
+ });
362
+ try {
363
+ const adminPage = await context.newPage();
364
+ await new NavigationPage_1.NavigationPage(adminPage).goToModeler();
365
+ await new ProcessApplicationPage_1.ProcessApplicationPage(adminPage).assignEnvironmentToWorkspace(workspaceUrl, process.env.MODELER_CLUSTER_NAME);
366
+ }
367
+ finally {
368
+ await context.close();
369
+ }
370
+ // This page loaded the workspace before the admin assigned the
371
+ // Environment in the other session, so Hub still holds the empty
372
+ // environment list and Deploy & run has no target until it refetches.
373
+ await this.page.reload({ waitUntil: 'domcontentloaded' });
374
+ this.processApplicationPage.markWorkspaceEnvironmentAssignedByAdmin(workspaceUrl);
375
+ }
352
376
  async clickCrossComponentProjectFolder(credentials) {
353
377
  // Kept name: callers use this to (re-)enter the shared Workspace's projects
354
378
  // listing after leaving Modeler. Creates the Workspace if it is somehow
@@ -119,7 +119,14 @@ class OperateHomePage {
119
119
  // yields `"updatedValue""` -- invalid JSON, and Apply rejects it exactly
120
120
  // like the per-character version did. Same recipe as
121
121
  // pages/c8Run-8.10/OperateHomePage.ts.
122
- await this.page.keyboard.press('ControlOrMeta+A');
122
+ //
123
+ // Select with Home + Shift+End rather than ControlOrMeta+A: headless
124
+ // Chromium on macOS does not apply Meta+A as select-all inside Monaco's
125
+ // native edit context, so the insert landed mid-string. The value is a
126
+ // single-line JSON string, so selecting the line selects the document on
127
+ // every platform.
128
+ await this.page.keyboard.press('Home');
129
+ await this.page.keyboard.press('Shift+End');
123
130
  await this.page.keyboard.insertText(`"${newValue}`);
124
131
  }
125
132
  async clickSaveVariableButton() {
@@ -52,10 +52,19 @@ class OptimizeHomePage {
52
52
  }
53
53
  async assertProcessHasBeenImported(processId) {
54
54
  await this.clickDashboardLink();
55
- await this.page.reload();
56
- await (0, test_1.expect)(this.page.getByRole('link', { name: processId })).toBeVisible({
57
- timeout: 180000,
58
- });
55
+ // The process list is paginated, so on a cluster with more than one page
56
+ // of processes the new one is not rendered until the list is filtered by
57
+ // name. Import is asynchronous, so reload and re-filter until it appears.
58
+ const search = this.page
59
+ .getByPlaceholder(/Search for name/i)
60
+ .or(this.page.getByPlaceholder('Filter table'))
61
+ .first();
62
+ const processLink = this.page.getByRole('link', { name: processId });
63
+ await (0, test_1.expect)(async () => {
64
+ await this.page.reload();
65
+ await search.fill(processId, { timeout: 30000 });
66
+ await (0, test_1.expect)(processLink).toBeVisible({ timeout: 15000 });
67
+ }).toPass({ timeout: 180000, intervals: [5000, 10000, 15000] });
59
68
  }
60
69
  async clickOpenUserSettingsButton() {
61
70
  await this.openUserSettingsButton.click();
@@ -12,19 +12,24 @@ declare class ProcessApplicationPage {
12
12
  readonly titleBar: Locator;
13
13
  readonly createNewDropdown: Locator;
14
14
  readonly inlineNameInput: Locator;
15
- readonly defineStagesDialog: Locator;
16
- readonly developmentClusterSelect: Locator;
17
- readonly defineStagesSaveButton: Locator;
15
+ readonly manageEnvironmentsHeading: Locator;
16
+ readonly assignedEnvironmentCards: Locator;
17
+ readonly editEnvironmentsButton: Locator;
18
+ readonly environmentEditorRegion: Locator;
19
+ readonly saveEnvironmentsButton: Locator;
18
20
  constructor(page: Page);
19
21
  private pageReady;
20
22
  waitForProcessApplication(timeout?: number): Promise<void>;
21
23
  clickCreateProcessApplicationButton(): Promise<void>;
22
24
  clickCreateProcessApplicationOption(): Promise<void>;
23
25
  nameNewProcessApplication(name: string): Promise<void>;
24
- private defineDevelopmentStage;
25
26
  rememberResourceContainer(url: string): void;
27
+ private workspaceEnvironmentsUrl;
26
28
  ensureStageCluster(clusterName?: string): Promise<void>;
27
- private openStageDefinition;
29
+ assignEnvironmentToWorkspace(workspaceUrl: string, clusterName?: string): Promise<void>;
30
+ markWorkspaceEnvironmentAssignedByAdmin(workspaceUrl: string): void;
31
+ private workspaceId;
32
+ private assignWorkspaceEnvironment;
28
33
  openByName(name: string): Promise<void>;
29
34
  }
30
35
  export { ProcessApplicationPage };
@@ -6,6 +6,10 @@ const submitModalWithRetry_1 = require("../../utils/assertionHelpers/submitModal
6
6
  // ModelerHomePage creates the container, while ModelerCreatePage later binds
7
7
  // its stage. Both page objects share the same Playwright Page.
8
8
  const resourceContainers = new WeakMap();
9
+ // Workspace ids whose Environment an org admin assigned from a separate session
10
+ // (see assignEnvironmentToWorkspace), keyed by the non-admin user's Page. That
11
+ // user cannot open the workspace Environments tab, so ensureStageCluster skips it.
12
+ const adminAssignedWorkspaces = new WeakMap();
9
13
  // Flag-on Self-Managed 8.10 (camunda-hub#27903: workspaces-enabled=true) renamed
10
14
  // the entity hierarchy: the legacy "project folder" is now a Workspace and the
11
15
  // legacy "process application" is now a Project (camunda-hub#28005). A Project is
@@ -14,11 +18,19 @@ const resourceContainers = new WeakMap();
14
18
  // from the workspace's projects listing via the "Create project" split button +
15
19
  // dialog, and the app lands on `/workspace/{wsId}/projects/{projectId}`.
16
20
  //
17
- // This page object owns that container's surface (creating a Project, and binding
18
- // its development-stage cluster). Entering and leaving it is driven from
19
- // ModelerHomePage so the ~150 existing spec call sites keep working unchanged.
20
- // Method names are kept from the flag-off era ("...ProcessApplication...") so the
21
- // callers do not have to change.
21
+ // camunda-hub#29210 ("Environments phase1") then moved cluster assignment up a
22
+ // level again: a Project no longer has its own "Define stages" dialog or
23
+ // "Connected clusters" card at all. Assignment is now an Environment (backed by
24
+ // a registered cluster) assigned to the whole Workspace via its Environments
25
+ // settings tab (`/workspaces/{wsId}/settings?tab=environments`), shared by every
26
+ // Project inside that Workspace.
27
+ //
28
+ // This page object owns that container's surface (creating a Project) plus the
29
+ // Workspace-level Environment assignment Deploy/Play need. Entering and leaving
30
+ // it is driven from ModelerHomePage so the ~150 existing spec call sites keep
31
+ // working unchanged. Method names are kept from the flag-off era
32
+ // ("...ProcessApplication...", "...StageCluster...") so the callers do not have
33
+ // to change.
22
34
  class ProcessApplicationPage {
23
35
  page;
24
36
  // The workspace projects listing's "Create project" split button. exact:true
@@ -40,9 +52,15 @@ class ProcessApplicationPage {
40
52
  titleBar;
41
53
  createNewDropdown;
42
54
  inlineNameInput;
43
- defineStagesDialog;
44
- developmentClusterSelect;
45
- defineStagesSaveButton;
55
+ // Workspace management -> Environments tab (camunda-hub#29210). Page-scoped,
56
+ // not container-scoped: this tab lives on a different page
57
+ // (`/workspaces/{wsId}/settings`) than the Project detail these other
58
+ // locators target.
59
+ manageEnvironmentsHeading;
60
+ assignedEnvironmentCards;
61
+ editEnvironmentsButton;
62
+ environmentEditorRegion;
63
+ saveEnvironmentsButton;
46
64
  constructor(page) {
47
65
  this.page = page;
48
66
  this.createProcessApplicationButton = page
@@ -95,11 +113,20 @@ class ProcessApplicationPage {
95
113
  .or(page.getByRole('button', { name: 'Create new' }))
96
114
  .first();
97
115
  this.inlineNameInput = page.locator('[data-test="editable-input"]');
98
- this.defineStagesDialog = page
99
- .getByRole('dialog')
100
- .filter({ hasText: 'Define stages' });
101
- this.developmentClusterSelect = this.defineStagesDialog.locator('#dropdown-development-cluster');
102
- this.defineStagesSaveButton = this.defineStagesDialog.getByRole('button', {
116
+ // "Assigned environments" heading: WorkspaceEnvironmentsTab's ready signal
117
+ // (it only renders once the workspace-scoped read has resolved).
118
+ this.manageEnvironmentsHeading = page.locator('[data-test="manage-environments-heading"]');
119
+ this.assignedEnvironmentCards = page.locator('[data-test="environment-card"]');
120
+ // Reads "Add environments" with none assigned yet, "Edit environments"
121
+ // otherwise -- same data-test either way.
122
+ this.editEnvironmentsButton = page.locator('[data-test="edit-environments-button"]');
123
+ this.environmentEditorRegion = page.getByRole('group', {
124
+ name: 'Edit assigned environments',
125
+ });
126
+ // Page-scoped, not environmentEditorRegion-scoped: Save/Cancel are
127
+ // rendered in the tab's header row, a SIBLING of the "Edit assigned
128
+ // environments" group, not a descendant of it.
129
+ this.saveEnvironmentsButton = page.getByRole('button', {
103
130
  name: 'Save',
104
131
  exact: true,
105
132
  });
@@ -152,82 +179,150 @@ class ProcessApplicationPage {
152
179
  await this.inlineNameInput.press('Enter');
153
180
  await this.waitForProcessApplication();
154
181
  }
155
- async defineDevelopmentStage(clusterName) {
156
- await (0, test_1.expect)(this.defineStagesDialog).toBeVisible({ timeout: 60000 });
157
- await this.developmentClusterSelect.click({ timeout: 30000 });
158
- const escapedClusterName = clusterName?.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
159
- // The listbox leads with a "No cluster" entry that unassigns the stage; the
160
- // placeholder "Select a development cluster" is only the trigger's text, not an
161
- // option. Excluding just the placeholder leaves "No cluster" first, so an
162
- // unnamed pick binds no cluster and saves defaultDevClusterId=null -- which
163
- // makes the later Deploy dialog report "Clusters not configured for stages".
164
- // Exclude "No cluster" too so the fallback lands on a real cluster.
165
- const option = clusterName
166
- ? this.page
167
- .getByRole('option', { name: new RegExp(`^${escapedClusterName}`) })
168
- .first()
169
- : this.page
170
- .getByRole('option')
171
- .filter({ hasNotText: 'Select a development cluster' })
172
- .filter({ hasNotText: 'No cluster' })
173
- .first();
174
- await option.click({ timeout: 30000 });
175
- await this.defineStagesSaveButton.click({ timeout: 30000 });
176
- await (0, test_1.expect)(this.defineStagesDialog).toBeHidden({ timeout: 60000 });
177
- }
178
182
  rememberResourceContainer(url) {
179
183
  resourceContainers.set(this.page, { url });
180
184
  }
185
+ // Derives the shared Workspace's Environments settings URL from a Project
186
+ // detail URL (".../workspaces/<wsId>/projects/<projectId>[?...]"): the
187
+ // origin/app-root plus "/workspaces/<wsId>/settings?tab=environments" -- the
188
+ // same deep link camunda-hub's own useManageEnvironmentsLink builds for the
189
+ // deploy dialog's "Manage environments" link. Always emits the plural
190
+ // "workspaces" regardless of which spelling the container URL used
191
+ // (ModelerHomePage.openWorkspaceTile still tolerates a singular
192
+ // "/workspace/<id>/projects" from camunda-hub#28928 not yet being merged
193
+ // everywhere) -- the settings route itself (App.tsx) is plural-only, with no
194
+ // singular alias, so reusing a singular prefix verbatim would 404.
195
+ workspaceEnvironmentsUrl(containerUrl) {
196
+ const match = containerUrl.match(/^(.*)\/workspaces?\/([^/?#]+)\//);
197
+ if (!match) {
198
+ throw new Error(`Could not derive the workspace Environments URL from "${containerUrl}": ` +
199
+ 'expected a "/workspaces/<id>/..." segment.');
200
+ }
201
+ const [, origin, workspaceId] = match;
202
+ return `${origin}/workspaces/${workspaceId}/settings?tab=environments`;
203
+ }
204
+ // Assigns an Environment to the shared Workspace via its Environments
205
+ // settings tab, so Deploy/Play (and Play's "Configure environment") find a
206
+ // usable target instead of rendering "No environments assigned" and staying
207
+ // disabled -- camunda-hub#29210 moved this out of the Project detail's own
208
+ // "Define stages" dialog and up to the Workspace, shared by every Project
209
+ // inside it.
210
+ //
211
+ // Checks the read-only "Assigned environments" list before entering edit
212
+ // mode, so a Workspace another (parallel) test already assigned is left
213
+ // alone regardless of this Page's own memo flag below -- required because
214
+ // every test's Project lives in the one shared Workspace. This is a
215
+ // best-effort check, not a lock: two tests racing to assign at once each
216
+ // still write their own snapshot of the selected set (Save persists the
217
+ // whole set, it does not merge), so a genuine concurrent race could still
218
+ // trigger two writes. It converges correctly here only because every test
219
+ // targets the one MODELER_CLUSTER_NAME (or, when unset, is happy with
220
+ // whichever single Environment ends up assigned).
181
221
  async ensureStageCluster(clusterName) {
182
222
  const container = resourceContainers.get(this.page);
183
223
  if (!container ||
184
224
  (container.stageBound && container.developmentCluster === clusterName)) {
185
225
  return;
186
226
  }
227
+ if (adminAssignedWorkspaces
228
+ .get(this.page)
229
+ ?.has(this.workspaceId(container.url))) {
230
+ container.stageBound = true;
231
+ container.developmentCluster = clusterName;
232
+ return;
233
+ }
187
234
  const returnTo = this.page.url();
188
- // Under workspaces-enabled there is no `/connected-clusters` sub-route: the
189
- // "Connected clusters" card lives in the Project detail's sidebar and its
190
- // "Configure" button opens the Define stages modal (see camunda-hub
191
- // ConnectedClustersDs). So navigate to the Project detail itself and let
192
- // openStageDefinition() click that button.
193
- const processApplicationUrl = container.url
194
- .replace(/[?#].*$/, '')
195
- .replace(/\/$/, '');
196
- await (0, test_1.expect)(async () => {
197
- await this.page.goto(processApplicationUrl, {
198
- waitUntil: 'domcontentloaded',
199
- });
200
- await this.waitForProcessApplication();
201
- await this.openStageDefinition();
202
- await this.defineDevelopmentStage(clusterName);
203
- }).toPass({ timeout: 240000, intervals: [1000, 2000, 5000] });
235
+ await this.assignEnvironmentToWorkspace(container.url, clusterName);
204
236
  container.stageBound = true;
205
237
  container.developmentCluster = clusterName;
206
238
  await this.page.goto(returnTo, { waitUntil: 'domcontentloaded' });
207
239
  }
208
- async openStageDefinition() {
209
- const opened = await this.defineStagesDialog
210
- .waitFor({ state: 'visible', timeout: 3000 })
240
+ // Assigns an Environment to the workspace that `workspaceUrl` (any
241
+ // "/workspaces/<id>/..." URL) belongs to, driving this Page's own session.
242
+ async assignEnvironmentToWorkspace(workspaceUrl, clusterName) {
243
+ const environmentsUrl = this.workspaceEnvironmentsUrl(workspaceUrl);
244
+ await (0, test_1.expect)(async () => {
245
+ await this.page.goto(environmentsUrl, { waitUntil: 'domcontentloaded' });
246
+ await this.assignWorkspaceEnvironment(clusterName);
247
+ }).toPass({ timeout: 240000, intervals: [1000, 2000, 5000] });
248
+ }
249
+ markWorkspaceEnvironmentAssignedByAdmin(workspaceUrl) {
250
+ const assigned = adminAssignedWorkspaces.get(this.page) ?? new Set();
251
+ assigned.add(this.workspaceId(workspaceUrl));
252
+ adminAssignedWorkspaces.set(this.page, assigned);
253
+ }
254
+ workspaceId(workspaceUrl) {
255
+ const match = workspaceUrl.match(/\/workspaces?\/([^/?#]+)\//);
256
+ if (!match) {
257
+ throw new Error(`Could not derive the workspace id from "${workspaceUrl}": ` +
258
+ 'expected a "/workspaces/<id>/..." segment.');
259
+ }
260
+ return match[1];
261
+ }
262
+ async assignWorkspaceEnvironment(clusterName) {
263
+ const tabReady = await this.manageEnvironmentsHeading
264
+ .or(this.editEnvironmentsButton)
265
+ .first()
266
+ .waitFor({ state: 'visible', timeout: 90000 })
211
267
  .then(() => true)
212
268
  .catch(() => false);
213
- if (opened) {
269
+ if (!tabReady) {
270
+ // WorkspaceManageBody silently normalises an unrecognised/unusable
271
+ // "?tab=environments" back to Members -- e.g. isEnvironmentsEnabled()
272
+ // off, or the current user failing canManageWorkspaceEnvironments
273
+ // (assigning is stricter than viewing: a plain Editor with no
274
+ // elevated/DevOps org role cannot assign at all). Name the likely
275
+ // cause rather than timing out with an opaque "not visible".
276
+ const onMembersTab = await this.page
277
+ .locator('[data-test="tab-members"][data-state="active"]')
278
+ .isVisible()
279
+ .catch(() => false);
280
+ throw new Error('The workspace Environments settings tab never rendered' +
281
+ (onMembersTab ? ' -- landed on the Members tab instead' : '') +
282
+ '. Either the Environments feature is off for this deployment, or the ' +
283
+ "current user lacks permission to manage this workspace's environments.");
284
+ }
285
+ const alreadyAssigned = clusterName
286
+ ? this.assignedEnvironmentCards.filter({ hasText: clusterName })
287
+ : this.assignedEnvironmentCards;
288
+ if (await alreadyAssigned
289
+ .first()
290
+ .isVisible()
291
+ .catch(() => false)) {
214
292
  return;
215
293
  }
216
- // The "Connected clusters" card's action is labelled "Configure"; earlier
217
- // shells used "Define stages" / "Edit stages".
218
- const reopen = this.page
219
- .getByRole('button', { name: /^(define stages|edit stages|configure)$/i })
220
- .first();
221
- const canReopen = await reopen
222
- .waitFor({ state: 'visible', timeout: 15000 })
294
+ await this.editEnvironmentsButton.click({ timeout: 30000 });
295
+ await (0, test_1.expect)(this.environmentEditorRegion).toBeVisible({ timeout: 30000 });
296
+ const escapedClusterName = clusterName?.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
297
+ // A picker card's toggle button is "Select <name> (<clusterName or
298
+ // clusterId>)" (see camunda-hub EnvironmentPickerCard) -- match the
299
+ // cluster name in that parenthetical rather than the environment's own
300
+ // display name, which is a separate, admin-chosen field that need not
301
+ // match the cluster at all.
302
+ const targetOption = clusterName
303
+ ? this.environmentEditorRegion
304
+ .getByRole('button', {
305
+ name: new RegExp(`^Select .*\\(${escapedClusterName}\\)`),
306
+ })
307
+ .first()
308
+ : this.environmentEditorRegion
309
+ .getByRole('button', { name: /^Select / })
310
+ .first();
311
+ const optionAppeared = await targetOption
312
+ .waitFor({ state: 'visible', timeout: 30000 })
223
313
  .then(() => true)
224
314
  .catch(() => false);
225
- if (!canReopen) {
226
- throw new Error('Could not open the "Define stages" dialog: it did not open by ' +
227
- 'itself and no "Configure" / "Define stages" / "Edit stages" ' +
228
- 'control was found on the Project detail page.');
315
+ if (!optionAppeared) {
316
+ throw new Error(clusterName
317
+ ? `No Environment backed by cluster "${clusterName}" is available to assign to this ` +
318
+ 'workspace. Register the cluster (or its Environment) first.'
319
+ : 'No Environment is available to assign to this workspace: the picker shows "No ' +
320
+ 'environments available".');
229
321
  }
230
- await reopen.click({ timeout: 30000 });
322
+ await targetOption.click({ timeout: 30000 });
323
+ await (0, test_1.expect)(this.saveEnvironmentsButton).toBeEnabled({ timeout: 30000 });
324
+ await this.saveEnvironmentsButton.click({ timeout: 30000 });
325
+ await (0, test_1.expect)(this.environmentEditorRegion).toBeHidden({ timeout: 60000 });
231
326
  }
232
327
  async openByName(name) {
233
328
  // Workspace-mode projects table: the whole row is the click target (see the