@camunda/e2e-test-suite 0.0.1253 → 0.0.1254

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.
@@ -261,8 +261,12 @@ class AppsPage {
261
261
  }
262
262
  };
263
263
  for (let attempt = 0; attempt < maxRetries; attempt++) {
264
- await this.clickCamundaApps();
265
- await this.clickModeler();
264
+ // Camunda Hub (hub.*) replaced Console + Web Modeler on SaaS and has no
265
+ // app switcher: its home already is the Modeler home.
266
+ if (!new URL(this.page.url()).hostname.startsWith('hub.')) {
267
+ await this.clickCamundaApps();
268
+ await this.clickModeler();
269
+ }
266
270
  try {
267
271
  await modelerReady();
268
272
  return;
@@ -32,6 +32,7 @@ declare class ClusterDetailsPage {
32
32
  readonly createAlertButton: Locator;
33
33
  readonly createNewClientButton: Locator;
34
34
  readonly clientsList: Locator;
35
+ readonly clientsFilterInput: Locator;
35
36
  readonly envVarsTab: Locator;
36
37
  readonly zeebeCheckbox: Locator;
37
38
  readonly apiClientNameTextbox: Locator;
@@ -50,7 +51,7 @@ declare class ClusterDetailsPage {
50
51
  readonly restartRequiredDialog: Locator;
51
52
  readonly restartConfirmButton: Locator;
52
53
  readonly mcpTab: Locator;
53
- readonly mcpTextarea: Locator;
54
+ readonly mcpConfigBlock: Locator;
54
55
  constructor(page: Page);
55
56
  clickAPITab(): Promise<void>;
56
57
  dismissOopsIfPresent(): Promise<void>;
@@ -64,7 +65,6 @@ declare class ClusterDetailsPage {
64
65
  clickCreateButton(): Promise<void>;
65
66
  clickCloseModalButton(): Promise<void>;
66
67
  assertAPIClientScopes(name: string): Promise<void>;
67
- filterClientsList(name: string): Promise<void>;
68
68
  clickSettingsTab(): Promise<void>;
69
69
  private waitForAuthorizationsSection;
70
70
  enableAuthorizations(): Promise<void>;
@@ -39,6 +39,7 @@ class ClusterDetailsPage {
39
39
  createAlertButton;
40
40
  createNewClientButton;
41
41
  clientsList;
42
+ clientsFilterInput;
42
43
  envVarsTab;
43
44
  zeebeCheckbox;
44
45
  apiClientNameTextbox;
@@ -57,7 +58,7 @@ class ClusterDetailsPage {
57
58
  restartRequiredDialog;
58
59
  restartConfirmButton;
59
60
  mcpTab;
60
- mcpTextarea;
61
+ mcpConfigBlock;
61
62
  constructor(page) {
62
63
  this.page = page;
63
64
  this.apiTab = page.getByRole('tab', { name: 'API' });
@@ -101,7 +102,14 @@ class ClusterDetailsPage {
101
102
  this.clientCredentialsDialog = page.getByRole('dialog').filter({
102
103
  has: page.getByText('The Client Secret will not be shown again.'),
103
104
  });
104
- this.closeModalButton = this.clientCredentialsDialog.getByLabel('Close');
105
+ // Hub's client-credentials dialog (design-system Dialog, not the old
106
+ // Carbon one) exposes its close affix as an accessible button named
107
+ // "Close" rather than via an aria-label picked up by getByLabel -- see
108
+ // camunda-hub's own e2e suite, ConsoleClusterDetailsPage.ts
109
+ // (clientCredentialsDialogCloseButton), which already uses this pattern.
110
+ this.closeModalButton = this.clientCredentialsDialog.getByRole('button', {
111
+ name: 'Close',
112
+ });
105
113
  this.clustersLink = page
106
114
  .getByRole('banner')
107
115
  .getByRole('link', { name: 'Clusters' });
@@ -129,6 +137,17 @@ class ClusterDetailsPage {
129
137
  name: 'Create new client',
130
138
  });
131
139
  this.clientsList = page.getByRole('row').filter({ hasNotText: 'Scopes' });
140
+ // The clients table paginates at ten rows and is not ordered by creation
141
+ // time, so on a cluster that already has clients a newly created one is
142
+ // usually on a page other than the one being displayed -- matching a row
143
+ // without filtering first reports it as missing (main's filterClientsList
144
+ // existed for exactly this). "Filter table" was Carbon's default
145
+ // TableToolbarSearch label; Hub's rebranded panel renders a plain
146
+ // "Search" input instead, so match either.
147
+ this.clientsFilterInput = page
148
+ .getByRole('searchbox', { name: 'Filter table' })
149
+ .or(page.getByRole('searchbox', { name: 'Search' }))
150
+ .or(page.getByPlaceholder('Search'));
132
151
  this.envVarsTab = this.clientCredentialsDialog.getByRole('tab', {
133
152
  name: 'Env Vars',
134
153
  });
@@ -179,7 +198,13 @@ class ClusterDetailsPage {
179
198
  name: 'Continue',
180
199
  });
181
200
  this.mcpTab = page.getByRole('tab', { name: 'MCP' });
182
- this.mcpTextarea = page.getByRole('textbox', { name: 'Copy to clipboard' });
201
+ // The config text renders in a <pre> inside the MCP-labelled panel, not
202
+ // a textbox -- matches the already-working 8.9 sibling's locator. A live
203
+ // trace confirmed "Copy to clipboard" is actually the aria-label/title of
204
+ // an unrelated icon-only <button> positioned over that block, which is
205
+ // why getByRole('textbox', {name: 'Copy to clipboard'}) always found
206
+ // nothing here.
207
+ this.mcpConfigBlock = page.getByLabel('MCP', { exact: true }).locator('pre');
183
208
  }
184
209
  async clickAPITab() {
185
210
  await (0, test_1.expect)(this.apiTab).toBeVisible({ timeout: 60000 });
@@ -252,33 +277,34 @@ class ClusterDetailsPage {
252
277
  async assertAPIClientScopes(name) {
253
278
  const row = this.clientsList.filter({ hasText: name });
254
279
  const scopes = /(?=.*Orchestration)(?=.*Optimize)(?=.*Secrets)/;
255
- await this.filterClientsList(name);
256
- try {
257
- await (0, test_1.expect)(row).toContainText(scopes, { timeout: 30000 });
280
+ const maxAttempts = 3;
281
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
282
+ try {
283
+ // The table paginates and isn't ordered by creation time, so a newly
284
+ // created client is usually on a page other than the one displayed --
285
+ // filter down to it first, the same way main's filterClientsList did,
286
+ // rather than matching rows only on the currently rendered page.
287
+ await (0, test_1.expect)(this.clientsFilterInput).toBeVisible({ timeout: 60000 });
288
+ await this.clientsFilterInput.fill(name);
289
+ await (0, test_1.expect)(row).toContainText(scopes, { timeout: 60000 });
290
+ return;
291
+ }
292
+ catch (error) {
293
+ if (attempt === maxAttempts) {
294
+ throw error;
295
+ }
296
+ // A panel that rendered before the client existed — or that failed
297
+ // into the "Oops ... something went wrong." state — only picks the
298
+ // new client up on a refetch, which is what reloading the API tab
299
+ // forces. A single reload isn't always enough on a nightly cluster
300
+ // this suite reuses across parallel test workers, so retry more
301
+ // than once before failing the test.
302
+ console.warn(`assertAPIClientScopes: "${name}" is not listed yet (attempt ${attempt}/${maxAttempts}); reloading the API tab and retrying. ${error}`);
303
+ await this.dismissOopsIfPresent();
304
+ await this.page.reload({ waitUntil: 'domcontentloaded' });
305
+ await this.clickAPITab();
306
+ }
258
307
  }
259
- catch (error) {
260
- // A panel that rendered before the client existed — or that failed into
261
- // the "Oops ... something went wrong." state — only picks the new client
262
- // up on a refetch, which is what reloading the API tab forces.
263
- console.warn(`assertAPIClientScopes: "${name}" is not listed yet; reloading the API tab and retrying. ${error}`);
264
- await this.dismissOopsIfPresent();
265
- await this.page.reload({ waitUntil: 'domcontentloaded' });
266
- await this.clickAPITab();
267
- await this.filterClientsList(name);
268
- await (0, test_1.expect)(row).toContainText(scopes, { timeout: 60000 });
269
- }
270
- }
271
- // Narrows the clients table to one client through the table's own filter.
272
- // The table paginates at ten rows and is not ordered by creation time, so on
273
- // a cluster that already has clients a newly created one is usually on a
274
- // page other than the one being displayed — matching a row without filtering
275
- // first reports it as missing.
276
- async filterClientsList(name) {
277
- const filterInput = this.page.getByRole('searchbox', {
278
- name: 'Filter table',
279
- });
280
- await (0, test_1.expect)(filterInput).toBeVisible({ timeout: 60000 });
281
- await filterInput.fill(name);
282
308
  }
283
309
  async clickSettingsTab() {
284
310
  // Console paints the cluster-details tab list before it finishes
@@ -772,8 +798,26 @@ class ClusterDetailsPage {
772
798
  await this.mcpTab.click();
773
799
  }
774
800
  async getMcpConfigurationText() {
775
- await (0, test_1.expect)(this.mcpTextarea).toBeVisible({ timeout: 60000 });
776
- return await this.mcpTextarea.innerText();
801
+ // CI (PR #3629, "Create Agentic Orchestration Cluster") showed the MCP
802
+ // panel's config block still not rendered 60s after clickMCPTab()
803
+ // reported a successful click -- the same "click landed but the panel
804
+ // never switched" race already seen on the Connector Secrets tab.
805
+ // Re-click the tab and retry rather than failing on the first miss.
806
+ const maxAttempts = 3;
807
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
808
+ try {
809
+ await (0, test_1.expect)(this.mcpConfigBlock).toBeVisible({ timeout: 60000 });
810
+ return await this.mcpConfigBlock.innerText();
811
+ }
812
+ catch (error) {
813
+ if (attempt === maxAttempts) {
814
+ throw error;
815
+ }
816
+ console.warn(`getMcpConfigurationText: MCP panel did not render (attempt ${attempt}/${maxAttempts}); re-clicking the MCP tab and retrying. ${error}`);
817
+ await this.clickMCPTab();
818
+ }
819
+ }
820
+ throw new Error('getMcpConfigurationText: unreachable');
777
821
  }
778
822
  async createMCPClientAndReturnVariables(name, setEnvVariables = true) {
779
823
  await this.createAPIClient(name);
@@ -143,10 +143,38 @@ class ClusterPage {
143
143
  this.clusterVersion = (clusterName, versionText) => this.clusterRow(clusterName).getByText(versionText, { exact: false });
144
144
  }
145
145
  async clickClusterBanner() {
146
- await this.page
146
+ // Camunda Hub has no `banner` landmark; "Clusters" instead lives in its
147
+ // persistent "Main navigation" sidebar, and once the org has environments
148
+ // enabled that link is further renamed "Environments" (same rollout
149
+ // HomePage.clusterTab handles).
150
+ const clusterLink = this.page
147
151
  .getByRole('banner')
148
152
  .getByRole('link', { name: 'Clusters' })
149
- .click({ timeout: 60000 });
153
+ .or(this.page
154
+ .getByRole('navigation', { name: 'Main navigation' })
155
+ .getByRole('link', { name: 'Clusters', exact: true }))
156
+ .or(this.page
157
+ .getByRole('navigation', { name: 'Main navigation' })
158
+ .getByRole('link', { name: 'Environments', exact: true }));
159
+ await clusterLink.click({ timeout: 60000 });
160
+ // Landing on Hub's Environments overview (see above) renders clusters as
161
+ // cards, not the getByRole('row') table this page's own locators expect
162
+ // -- route through the page's own "Clusters" tab to reach the classic
163
+ // route. Same fix as HomePage.clickClusters().
164
+ const clustersPageTab = this.page
165
+ .getByRole('navigation', { name: 'Environments views' })
166
+ .getByRole('link', { name: 'Clusters', exact: true });
167
+ // On Hub this tab is required -- the row-based locators callers use right
168
+ // after this method return nothing on the card-based Environments
169
+ // overview, so a slow render must not be treated as "tab doesn't exist"
170
+ // the way the legacy (non-Hub) shell's 5s probe is allowed to.
171
+ if (new URL(this.page.url()).hostname.startsWith('hub.')) {
172
+ await (0, test_1.expect)(clustersPageTab).toBeVisible({ timeout: 30000 });
173
+ await clustersPageTab.click({ timeout: 15000 });
174
+ }
175
+ else if (await clustersPageTab.isVisible({ timeout: 5000 }).catch(() => false)) {
176
+ await clustersPageTab.click({ timeout: 15000 });
177
+ }
150
178
  }
151
179
  async confirmClusterDeletion() {
152
180
  await (0, test_1.expect)(this.dialog).toBeVisible({ timeout: 60000 });
@@ -56,15 +56,46 @@ class ClusterSecretsPage {
56
56
  name: 'Cancel',
57
57
  exact: true,
58
58
  });
59
- this.bulkImportErrorBanner = this.dialog.getByText(/Imported \d+ secrets?, \d+ failed/);
60
- this.secretsSearchBox = page.getByRole('searchbox', { name: 'Filter table' });
59
+ // Hub's Import secrets dialog reports partial failures as "Imported X of
60
+ // Y secrets. Not imported: <key> — a secret with this key already
61
+ // exists; ..." -- confirmed from the PR #3629 CI trace screenshot for
62
+ // "Create AWS Cluster". This page object is also still exercised on the
63
+ // legacy Console under IS_PROD (that shell reports "Imported X secrets, Y
64
+ // failed" instead), so match either rather than assuming every env has
65
+ // rolled Hub out.
66
+ this.bulkImportErrorBanner = this.dialog
67
+ .getByText(/Imported \d+ of \d+ secrets?\./)
68
+ .or(this.dialog.getByText(/Imported \d+ secrets?, \d+ failed/));
69
+ // "Filter table" was Carbon's default TableToolbarSearch label. Camunda
70
+ // Hub's rebranded panel renders a plain "Search" input instead, so the
71
+ // old exact-name match never resolves and assertSecretsOrCreate falls
72
+ // through to (re-)creating secrets that may already exist. Match either
73
+ // (see the 8.9 sibling fix, confirmed via a live trace).
74
+ this.secretsSearchBox = page
75
+ .getByRole('searchbox', { name: 'Filter table' })
76
+ .or(page.getByRole('searchbox', { name: 'Search' }))
77
+ .or(page.getByPlaceholder('Search'));
78
+ // Camunda Hub has no `banner` landmark; "Clusters" instead lives in its
79
+ // persistent "Main navigation" sidebar, and once the org has environments
80
+ // enabled that link is further renamed "Environments" (same rollout
81
+ // HomePage.clusterTab handles).
61
82
  this.clusterBanner = this.page
62
83
  .getByRole('banner')
63
- .getByRole('link', { name: 'Clusters' });
84
+ .getByRole('link', { name: 'Clusters' })
85
+ .or(this.page
86
+ .getByRole('navigation', { name: 'Main navigation' })
87
+ .getByRole('link', { name: 'Clusters', exact: true }))
88
+ .or(this.page
89
+ .getByRole('navigation', { name: 'Main navigation' })
90
+ .getByRole('link', { name: 'Environments', exact: true }));
64
91
  this.clusterLink = (name) => page.getByRole('link', { name: name });
65
- this.clusterSecretTab = this.page.getByRole('tab', {
66
- name: 'Connector Secrets',
67
- });
92
+ // Some environments render this tab as "Cluster secrets" rather than
93
+ // "Connector Secrets" -- match either (see ClusterPage's identical
94
+ // connectorSecretsTab fallback; this page object has its own separate
95
+ // locator and was missing it).
96
+ this.clusterSecretTab = this.page
97
+ .getByRole('tab', { name: 'Cluster secrets' })
98
+ .or(this.page.getByRole('tab', { name: 'Connector Secrets' }));
68
99
  this.confirmDeleteInput = page.getByRole('textbox', {
69
100
  name: 'Type the word DELETE to confirm',
70
101
  });
@@ -106,17 +137,55 @@ class ClusterSecretsPage {
106
137
  async clickCreateNewSecretButton(cluster) {
107
138
  try {
108
139
  await this.createNewSecretButton.click({ timeout: 60000 });
140
+ return;
109
141
  }
110
142
  catch (error) {
143
+ // fall through to the recovery path below
144
+ }
145
+ // CI (PR #3629, "Create Default Cluster" / "Create AWS Cluster") showed
146
+ // the "Connector Secrets" tab still not rendered 30s after navigating
147
+ // back to the cluster -- a single recovery attempt isn't always enough
148
+ // once the page is this slow to settle, so retry the whole
149
+ // banner->cluster->tab sequence rather than failing on the first miss.
150
+ const maxAttempts = 3;
151
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
111
152
  await this.clickClusterBanner();
112
153
  await this.clickCluster(cluster);
113
- await this.clusterSecretTab.click({ timeout: 30000 });
114
- await this.createNewSecretButton.click({ timeout: 30000 });
154
+ try {
155
+ await this.clusterSecretTab.click({ timeout: 30000 });
156
+ await this.createNewSecretButton.click({ timeout: 30000 });
157
+ return;
158
+ }
159
+ catch (error) {
160
+ if (attempt === maxAttempts) {
161
+ throw error;
162
+ }
163
+ console.warn(`clickCreateNewSecretButton: recovery attempt ${attempt}/${maxAttempts} failed; retrying. ${error}`);
164
+ }
115
165
  }
116
166
  }
117
167
  async clickClusterBanner() {
118
168
  await (0, test_1.expect)(this.clusterBanner).toBeVisible({ timeout: 30000 });
119
169
  await this.clusterBanner.click({ timeout: 60000 });
170
+ // Landing on Hub's Environments overview (see clusterBanner above) renders
171
+ // clusters as cards, not the getByRole('link') the clusterLink() locator
172
+ // below expects to find directly -- route through the page's own
173
+ // "Clusters" tab to reach the classic route. Same fix as
174
+ // HomePage.clickClusters().
175
+ const clustersPageTab = this.page
176
+ .getByRole('navigation', { name: 'Environments views' })
177
+ .getByRole('link', { name: 'Clusters', exact: true });
178
+ // On Hub this tab is required -- clickCluster()'s row-based clusterLink
179
+ // resolves nothing on the card-based Environments overview, so a slow
180
+ // render must not be treated as "tab doesn't exist" the way the legacy
181
+ // (non-Hub) shell's 5s probe is allowed to.
182
+ if (new URL(this.page.url()).hostname.startsWith('hub.')) {
183
+ await (0, test_1.expect)(clustersPageTab).toBeVisible({ timeout: 30000 });
184
+ await clustersPageTab.click({ timeout: 15000 });
185
+ }
186
+ else if (await clustersPageTab.isVisible({ timeout: 5000 }).catch(() => false)) {
187
+ await clustersPageTab.click({ timeout: 15000 });
188
+ }
120
189
  }
121
190
  async clickCluster(name) {
122
191
  await (0, test_1.expect)(this.clusterLink(name)).toBeVisible({ timeout: 30000 });
@@ -171,9 +240,72 @@ class ClusterSecretsPage {
171
240
  await (0, test_1.expect)(this.page.getByText('Creating...')).not.toBeVisible({
172
241
  timeout: 60000,
173
242
  });
174
- await (0, test_1.expect)(this.createNewSecretButton).toBeVisible({
175
- timeout: 60000,
243
+ const secretRow = this.page.getByRole('row', {
244
+ name: new RegExp(`^${key}\\b`, 'i'),
176
245
  });
246
+ // Two separate CI runs (35854706213 with 7 secrets, and 36011511221 with
247
+ // 13) each showed 100% of secrets taking the reload path on attempt 1 --
248
+ // not an occasional race, and not even "near-universal": zero exceptions
249
+ // across 20 samples. A wait on the unreloaded panel was therefore never
250
+ // going to pay off, so reload unconditionally before the first check
251
+ // instead of probing a state that has never once been the real one.
252
+ await this.page.reload({ waitUntil: 'domcontentloaded' });
253
+ await this.clickClusterBanner();
254
+ await this.clickCluster(cluster);
255
+ await this.clusterSecretTab.click({ timeout: 30000 });
256
+ const maxAttempts = 3;
257
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
258
+ try {
259
+ await (0, test_1.expect)(this.createNewSecretButton).toBeVisible({
260
+ timeout: 60000,
261
+ });
262
+ // The button reappearing only proves the panel re-rendered, not that
263
+ // this specific secret was created -- a failed create request can
264
+ // leave the panel in the same state. Verify the row before
265
+ // returning: CI showed the row can lag briefly behind the button on
266
+ // a slow reload.
267
+ //
268
+ // Filter down to this key first: a live CI trace (run 36001802609,
269
+ // "Create Default Cluster") showed the unfiltered check time out on
270
+ // both attempts even though the key was confirmed present -- the
271
+ // create POST itself returned 409 (already exists) and the API's own
272
+ // list included it -- because the cluster's shared, reused secret set
273
+ // had grown past what the alphabetically-sorted table renders without
274
+ // narrowing it, the same "off-screen in a long list" pattern
275
+ // documented for Hub's other list views. assertSecretsOrCreate
276
+ // already searches before deciding a secret is missing; do the same
277
+ // here before concluding the create failed.
278
+ await (0, test_1.expect)(this.secretsSearchBox).toBeVisible({ timeout: 10000 });
279
+ await this.secretsSearchBox.fill(key);
280
+ await (0, test_1.expect)(secretRow).toBeVisible({ timeout: 60000 });
281
+ return;
282
+ }
283
+ catch (error) {
284
+ if (attempt === maxAttempts) {
285
+ throw error;
286
+ }
287
+ // The secrets panel can land in a Carbon "Oops ... something went
288
+ // wrong." state after a background request fails post-create, which
289
+ // never recovers on its own and only clears on a reload. Reload
290
+ // unconditionally rather than gating on an "Oops" heading: PR#3627's
291
+ // CI hit this same timeout with no Oops heading ever rendered, and
292
+ // the panel still only recovered on a reload -- the same
293
+ // unconditional reload bulkImportSecrets() already uses on this
294
+ // identical panel. A single retry wasn't always enough either on
295
+ // this suite's shared, reused cluster, so retry more than once.
296
+ console.warn(`createNewSecret: "${key}" was created but the secrets list did not re-render (attempt ${attempt}/${maxAttempts}); reloading and retrying. ${error}`);
297
+ await this.page.reload({ waitUntil: 'domcontentloaded' });
298
+ // The reload lands back on the cluster's default Overview tab, not
299
+ // Connector Secrets -- a CI failure screenshot confirmed the page
300
+ // stuck there with the secrets panel never reached, so
301
+ // createNewSecretButton was never going to reappear on the next
302
+ // attempt regardless of whether the reload fixed the underlying
303
+ // render issue. Re-navigate to the tab before retrying.
304
+ await this.clickClusterBanner();
305
+ await this.clickCluster(cluster);
306
+ await this.clusterSecretTab.click({ timeout: 30000 });
307
+ }
308
+ }
177
309
  }
178
310
  async createSetOfSecrets(cluster, secrets) {
179
311
  await this.bulkImportSecrets(secrets);
@@ -12,6 +12,8 @@ declare class HomePage {
12
12
  readonly appSwitcher: Locator;
13
13
  readonly openOrganizationButton: Locator;
14
14
  readonly consoleBanner: Locator;
15
+ readonly hubHeader: Locator;
16
+ readonly hubSidebar: Locator;
15
17
  readonly modelerBanner: Locator;
16
18
  readonly gettingStartedHeading: Locator;
17
19
  readonly buttonSkipCustomization: Locator;
@@ -15,16 +15,35 @@ class HomePage {
15
15
  appSwitcher;
16
16
  openOrganizationButton;
17
17
  consoleBanner;
18
+ hubHeader;
19
+ hubSidebar;
18
20
  modelerBanner;
19
21
  gettingStartedHeading;
20
22
  buttonSkipCustomization;
21
23
  constructor(page) {
22
24
  this.page = page;
25
+ // Camunda Hub replaces Console on SaaS: an app header (only carrying the
26
+ // user menu once authenticated) plus a sidebar whose Console section
27
+ // links to /org/<id>/clusters and /org/<id>/management.
28
+ this.hubHeader = page
29
+ .locator('header[data-slot="app-header"]')
30
+ .filter({ has: page.locator('[data-slot="user-menu-trigger"]') });
31
+ this.hubSidebar = page.locator('nav[data-slot="app-sidebar"]');
23
32
  this.dashboardTab = page.getByRole('link', { name: 'Dashboard' });
33
+ // Once an org has environmentsEnabled, camunda-hub's
34
+ // use-ds-sidebar-items.tsx replaces the sidebar's Clusters link with one
35
+ // labelled "Environments" whose href is '/environments', not
36
+ // '/clusters' -- accept both hrefs so this resolves either way.
24
37
  this.clusterTab = page
25
38
  .getByRole('banner')
26
- .getByRole('link', { name: 'Clusters' });
27
- this.organizationTab = page.getByRole('link', { name: 'Organization' });
39
+ .getByRole('link', { name: 'Clusters' })
40
+ .or(this.hubSidebar.locator('a[href$="/clusters"]'))
41
+ .or(this.hubSidebar.locator('a[href$="/environments"]'))
42
+ .first();
43
+ this.organizationTab = page
44
+ .getByRole('link', { name: 'Organization' })
45
+ .or(this.hubSidebar.locator('a[href$="/management"]'))
46
+ .first();
28
47
  this.organizationViewTitle = page.getByText('Organization Management');
29
48
  this.welcomeMessage = page.getByText('Welcome to Camunda!').first();
30
49
  this.informationDialog = page.getByRole('button', {
@@ -58,6 +77,25 @@ class HomePage {
58
77
  await this.closeInformationDialog();
59
78
  }
60
79
  await this.clusterTab.click({ timeout: 15000 });
80
+ // Landing on Hub's Environments overview (see clusterTab above) doesn't
81
+ // put us on the classic row-based cluster list ClusterPage.ts expects --
82
+ // that page renders clusters as cards. Its own header renders a separate
83
+ // "Clusters" tab (camunda-hub EnvironmentsPageHeader.tsx, href /clusters)
84
+ // that does.
85
+ const clustersPageTab = this.page
86
+ .getByRole('navigation', { name: 'Environments views' })
87
+ .getByRole('link', { name: 'Clusters', exact: true });
88
+ // On Hub this tab is required -- the row-based locators callers use right
89
+ // after this method return nothing on the card-based Environments
90
+ // overview, so a slow render must not be treated as "tab doesn't exist"
91
+ // the way the legacy (non-Hub) shell's 5s probe is allowed to.
92
+ if (new URL(this.page.url()).hostname.startsWith('hub.')) {
93
+ await (0, test_1.expect)(clustersPageTab).toBeVisible({ timeout: 30000 });
94
+ await clustersPageTab.click({ timeout: 15000 });
95
+ }
96
+ else if (await clustersPageTab.isVisible({ timeout: 5000 }).catch(() => false)) {
97
+ await clustersPageTab.click({ timeout: 15000 });
98
+ }
61
99
  }
62
100
  async clickDashboard() {
63
101
  await this.dashboardTab.click({ timeout: 90000 });
@@ -26,6 +26,7 @@ declare class LoginPage {
26
26
  clickContinueButton(): Promise<void>;
27
27
  fillPassword(password: string): Promise<void>;
28
28
  clickLoginButton(): Promise<void>;
29
+ private throwIfCredentialsRejected;
29
30
  login(credentials?: {
30
31
  username?: string;
31
32
  password?: string;
@@ -4,6 +4,7 @@ exports.LoginPage = void 0;
4
4
  const test_1 = require("@playwright/test");
5
5
  const UtilitiesPage_1 = require("./UtilitiesPage");
6
6
  const authErrorClassification_1 = require("../../utils/authErrorClassification");
7
+ const consoleApiHelpers_1 = require("../../utils/consoleApiHelpers");
7
8
  const userNameInputValidInputSchema = {
8
9
  _schema: {
9
10
  maxlength: 254,
@@ -75,14 +76,33 @@ class LoginPage {
75
76
  async clickLoginButton() {
76
77
  await this.loginButton.click({ timeout: 60000 });
77
78
  }
79
+ // Auth0 responds to a rejected password by re-rendering the same password
80
+ // step with an error message rather than navigating anywhere, so success
81
+ // and failure aren't told apart by a URL or element one side has and the
82
+ // other doesn't. Without this check, a permanently wrong password falls
83
+ // through into assertTestUsesCorrectOrganization's generic ~120s wait for a
84
+ // Console/Hub shell that is never coming, and loginWithRetry then spends
85
+ // its remaining attempts resubmitting the exact same doomed credentials.
86
+ async throwIfCredentialsRejected(username) {
87
+ const message = await (0, authErrorClassification_1.classifyLoginRejection)(this.page, username);
88
+ if (message) {
89
+ throw new Error(message);
90
+ }
91
+ }
78
92
  async login(credentials = {}) {
79
93
  const { username = process.env.C8_USERNAME, password = process.env.C8_PASSWORD, } = credentials;
94
+ // The Hub bearer-token cache in consoleApiHelpers.ts is keyed by Page for
95
+ // the tab's whole lifetime; without clearing it here, a flow that logs a
96
+ // different user into the same page could have getUserToken() return the
97
+ // PREVIOUS user's token.
98
+ (0, consoleApiHelpers_1.resetHubBearerToken)(this.page);
80
99
  await (0, test_1.expect)(this.usernameInput.locator).toBeVisible({ timeout: 200000 });
81
100
  await this.fillUsername(username);
82
101
  await this.clickContinueButton();
83
102
  await this.fillPassword(password);
84
103
  await (0, test_1.expect)(this.loginButton).toBeVisible({ timeout: 120000 });
85
104
  await this.clickLoginButton();
105
+ await this.throwIfCredentialsRejected(username);
86
106
  await (0, UtilitiesPage_1.assertTestUsesCorrectOrganization)(this.page);
87
107
  }
88
108
  // The identity provider's email step submits through client-side JS, so a
@@ -128,6 +148,10 @@ class LoginPage {
128
148
  }
129
149
  async loginWithTestUser(credentials = {}) {
130
150
  const { username = process.env.C8_USERNAME_TEST, password = process.env.C8_PASSWORD_TEST, } = credentials;
151
+ // See login() above: this re-authenticates the same page and must clear
152
+ // the Hub bearer-token cache too, or getUserToken() can return the
153
+ // previous user's cached token.
154
+ (0, consoleApiHelpers_1.resetHubBearerToken)(this.page);
131
155
  // Skip navigation if the auth0 login page is already loaded — re-navigating
132
156
  // to '/' when already on auth0 undoes the redirect and causes a 60-second
133
157
  // waitForURL race. This is the common case when called from loginWithRetry,
@@ -158,6 +182,7 @@ class LoginPage {
158
182
  await this.fillPassword(password);
159
183
  await (0, test_1.expect)(this.loginButton).toBeVisible({ timeout: 120000 });
160
184
  await this.clickLoginButton();
185
+ await this.throwIfCredentialsRejected(username);
161
186
  await (0, UtilitiesPage_1.assertTestUsesCorrectOrganization)(this.page);
162
187
  }
163
188
  async loginFailAssertion() {
@@ -170,6 +195,10 @@ class LoginPage {
170
195
  }
171
196
  async loginWithoutOrgAssertion(credentials = {}) {
172
197
  const { username = process.env.C8_USERNAME, password = process.env.C8_PASSWORD, } = credentials;
198
+ // See login() above: this re-authenticates the same page and must clear
199
+ // the Hub bearer-token cache too, or getUserToken() can return the
200
+ // previous user's cached token.
201
+ (0, consoleApiHelpers_1.resetHubBearerToken)(this.page);
173
202
  await (0, test_1.expect)(this.usernameInput.locator).toBeVisible({ timeout: 180000 });
174
203
  await this.fillUsername(username);
175
204
  await this.clickContinueButton();
@@ -1,6 +1,7 @@
1
1
  import { Page, Locator } from '@playwright/test';
2
2
  declare class ModelerHomePage {
3
3
  private defaultFolderName;
4
+ private defaultClusterName;
4
5
  private defaultNewProjectName;
5
6
  private page;
6
7
  private processApplicationPage;
@@ -8,6 +9,11 @@ declare class ModelerHomePage {
8
9
  private processApplicationName?;
9
10
  readonly modelerPageBanner: Locator;
10
11
  readonly createNewProjectButton: Locator;
12
+ readonly createWorkspaceButton: Locator;
13
+ readonly workspaceWizardNameInput: Locator;
14
+ readonly workspaceWizardSubmitButton: Locator;
15
+ readonly workspaceWizardMembersStep: Locator;
16
+ readonly workspaceWizardEnvironmentsHeading: Locator;
11
17
  readonly projectNameInput: Locator;
12
18
  readonly formNameInput: Locator;
13
19
  readonly chooseBpmnTemplateButton: Locator;
@@ -62,11 +68,13 @@ declare class ModelerHomePage {
62
68
  returnToResourceContainer(): Promise<void>;
63
69
  startBlankBpmnDiagram(): Promise<void>;
64
70
  private becameVisible;
71
+ private createWorkspaceViaWizard;
65
72
  clickHomeBreadcrumb(): Promise<void>;
66
73
  clickOpenOrganizationsButton(): Promise<void>;
67
74
  createForm(formName: string): Promise<void>;
68
75
  clickCreateIdpApplicationButton(): Promise<void>;
69
76
  clickCrossComponentProjectFolder(): Promise<void>;
77
+ private waitForCrossComponentProjectFolder;
70
78
  createCrossComponentProjectFolder(): Promise<void>;
71
79
  clickManageButton(retries?: number): Promise<void>;
72
80
  clickUploadFilesButton(): Promise<void>;
@@ -8,6 +8,11 @@ const randomName_1 = require("../../utils/randomName");
8
8
  const ProcessApplicationPage_1 = require("./ProcessApplicationPage");
9
9
  class ModelerHomePage {
10
10
  defaultFolderName = 'Cross Component Test Project';
11
+ // The default cluster test-setup.spec.ts's "Create Default Cluster" test
12
+ // provisions for the whole suite -- assigned to the shared workspace's
13
+ // Environments step so downstream specs can deploy to it without a manual
14
+ // assignment step first.
15
+ defaultClusterName = 'Test Cluster';
11
16
  // The name hub gives a project on create, before the inline rename commits.
12
17
  defaultNewProjectName = 'New project';
13
18
  page;
@@ -26,6 +31,11 @@ class ModelerHomePage {
26
31
  processApplicationName;
27
32
  modelerPageBanner;
28
33
  createNewProjectButton;
34
+ createWorkspaceButton;
35
+ workspaceWizardNameInput;
36
+ workspaceWizardSubmitButton;
37
+ workspaceWizardMembersStep;
38
+ workspaceWizardEnvironmentsHeading;
29
39
  projectNameInput;
30
40
  formNameInput;
31
41
  chooseBpmnTemplateButton;
@@ -61,12 +71,17 @@ class ModelerHomePage {
61
71
  // Hub's design-system shell renders the app header as a role=banner with no
62
72
  // accessible name after the Camunda Hub rebrand, so the classic
63
73
  // name-matched banner no longer resolves; the Modeler logo link inside it
64
- // still carries "Camunda Modeler home". Keep the legacy named-banner branch
65
- // for shells where the rename has not shipped yet.
74
+ // carries "Camunda Hub home" when the org has workspacesEnabled, else
75
+ // "Camunda Modeler home" (camunda-hub NavShellDs.common.tsx) -- match
76
+ // either. Keep the legacy named-banner branch for shells where the rename
77
+ // has not shipped yet.
66
78
  this.modelerPageBanner = page
67
79
  .getByRole('banner', {
68
80
  name: 'Camunda Modeler',
69
81
  })
82
+ .or(page
83
+ .getByRole('link', { name: 'Camunda Hub home' })
84
+ .and(page.locator(':not([href$="/login"])')))
70
85
  .or(page
71
86
  .getByRole('link', { name: 'Camunda Modeler home' })
72
87
  .and(page.locator(':not([href$="/login"])')))
@@ -83,6 +98,24 @@ class ModelerHomePage {
83
98
  name: 'Create project',
84
99
  exact: true,
85
100
  }));
101
+ // Camunda Hub, which replaced Console + Web Modeler on SaaS, tops the
102
+ // hierarchy with a Workspaces overview whose "Create workspace" CTA opens a
103
+ // wizard (General -> Members) that names the workspace up front. Same
104
+ // hooks as pages/SM-8.10/ModelerHomePage.ts. exact:true keeps the split
105
+ // button's options chevron out of the match.
106
+ this.createWorkspaceButton = page.getByRole('button', {
107
+ name: 'Create workspace',
108
+ exact: true,
109
+ });
110
+ this.workspaceWizardNameInput = page.locator('#workspace-name');
111
+ this.workspaceWizardSubmitButton = page.locator('[data-test="workspace-wizard-submit"]');
112
+ this.workspaceWizardMembersStep = page.locator('[data-test="step-members"]');
113
+ // When the org has environments enabled, an "Assign environments" step is
114
+ // inserted after Members -- camunda-hub's own
115
+ // e2e-playwright/pages/CreateWorkspacePage.ts detects it the same way.
116
+ this.workspaceWizardEnvironmentsHeading = page.getByRole('heading', {
117
+ name: 'Assign environments',
118
+ });
86
119
  this.projectNameInput = page.locator('[data-test="editable-input"]');
87
120
  this.formNameInput = page.locator('[data-test="editable-input"]');
88
121
  this.idpApplicationNameInput = page.getByPlaceholder('New IDP Application');
@@ -121,10 +154,13 @@ class ModelerHomePage {
121
154
  // than one project with this name — an unscoped by-title match throws a
122
155
  // strict-mode violation the moment a duplicate exists, which fails every
123
156
  // later run in that org rather than just the run that created it.
157
+ // On the Camunda Hub Workspaces overview the folder is a workspace tile
158
+ // whose "Open workspace <name>" button navigates into it.
124
159
  this.crossComponentProjectFolder = page
125
- .getByTitle(this.defaultFolderName, {
160
+ .getByRole('button', { name: `Open workspace ${this.defaultFolderName}` })
161
+ .or(page.getByTitle(this.defaultFolderName, {
126
162
  exact: true,
127
- })
163
+ }))
128
164
  .first();
129
165
  this.rows = page.getByRole('row');
130
166
  this.uploadFilesButton = page.getByRole('menuitem', { name: 'Upload files' });
@@ -582,7 +618,83 @@ class ModelerHomePage {
582
618
  .then(() => true)
583
619
  .catch(() => false);
584
620
  }
621
+ // Drives Camunda Hub's workspace-creation wizard. The name is entered on
622
+ // the General step, which enables "Next"; the same submit button then
623
+ // relabels itself through the remaining steps and finally reads "Create
624
+ // workspace" once it creates the workspace and routes the creator into its
625
+ // projects listing. Gate the Members-step click on that step becoming
626
+ // active so the shared submit button is not clicked twice while still on
627
+ // General.
628
+ async createWorkspaceViaWizard(name) {
629
+ await this.dismissOverlays();
630
+ await this.createWorkspaceButton.click({ timeout: 60000 });
631
+ await this.workspaceWizardNameInput.fill(name, { timeout: 60000 });
632
+ await this.workspaceWizardSubmitButton.click({ timeout: 60000 });
633
+ await (0, test_1.expect)(this.workspaceWizardMembersStep).toHaveAttribute('data-state', 'active', { timeout: 30000 });
634
+ await this.workspaceWizardSubmitButton.click({ timeout: 60000 });
635
+ // When the org has environments enabled, this only advances the wizard
636
+ // into an "Assign environments" step instead of submitting. Assign the
637
+ // suite's default cluster here rather than submitting with an empty
638
+ // selection -- otherwise this shared workspace starts with no
639
+ // environments and every downstream spec that deploys to it needs its
640
+ // own manual assignment first. Each card's accessible name is
641
+ // "Select <cluster name>" before it is picked (camunda-hub's own
642
+ // create-workspace-environments.spec.tsx asserts the same pattern).
643
+ // isVisible() only samples the current state -- if the wizard's step
644
+ // transition hasn't rendered yet at that exact instant, this would
645
+ // wrongly conclude the org has no environments step and immediately
646
+ // wait for the post-submit URL while the wizard is actually still open
647
+ // (mid-transition), timing out every workspace creation. Give the step
648
+ // transition a bounded wait instead of an instantaneous probe.
649
+ if (await this.becameVisible(this.workspaceWizardEnvironmentsHeading, 5000)) {
650
+ // "Create Default Cluster" runs in parallel with this flow
651
+ // (test-setup.spec.ts's mode: 'parallel'), so the cluster this picker
652
+ // lists may still be provisioning -- wait for it a generous 90s rather
653
+ // than sampling once. If it genuinely never appears (the cluster
654
+ // failed rather than just being slow), don't fail the whole shared
655
+ // workspace's setup over it: Hub's own recovery copy for this step
656
+ // ("Could not load environments. You can finish creating the
657
+ // workspace and assign them later.") confirms submitting unassigned
658
+ // is a supported, recoverable path, not a broken one.
659
+ const clusterOption = this.page.getByRole('button', {
660
+ name: new RegExp(`^Select ${this.defaultClusterName}`),
661
+ });
662
+ if (await this.becameVisible(clusterOption, 90000)) {
663
+ await clusterOption.click({ timeout: 10000 });
664
+ }
665
+ else {
666
+ console.warn(`"${this.defaultClusterName}" was not selectable on the Environments step; creating the workspace without it.`);
667
+ }
668
+ await this.workspaceWizardSubmitButton.click({ timeout: 60000 });
669
+ }
670
+ await this.page.waitForURL(/\/workspaces?\/[^/]+\/projects/, {
671
+ timeout: 120000,
672
+ });
673
+ }
585
674
  async clickHomeBreadcrumb() {
675
+ // Camunda Hub has no Modeler home breadcrumb: its home is the Workspaces
676
+ // overview, reached through the header logo link so the SPA keeps its
677
+ // in-memory session (a full navigation re-runs the whole Auth0 hand-off).
678
+ // The link's accessible name is "Camunda Hub home" when the org has
679
+ // workspacesEnabled, else "Camunda Modeler home" (camunda-hub
680
+ // NavShellDs.common.tsx) -- match either.
681
+ if (new URL(this.page.url()).hostname.startsWith('hub.')) {
682
+ // The authenticated header can still expose this same link with its
683
+ // href pointed at /login while the header is mid-update (same race
684
+ // modelerPageBanner already guards against) -- clicking it then sends
685
+ // the flow back through Auth0 and drops the session.
686
+ await this.page
687
+ .locator('header[data-slot="app-header"]')
688
+ .getByRole('link', { name: 'Camunda Hub home' })
689
+ .and(this.page.locator(':not([href$="/login"])'))
690
+ .or(this.page
691
+ .locator('header[data-slot="app-header"]')
692
+ .getByRole('link', { name: 'Camunda Modeler home' })
693
+ .and(this.page.locator(':not([href$="/login"])')))
694
+ .first()
695
+ .click({ timeout: 30000 });
696
+ return;
697
+ }
586
698
  try {
587
699
  await this.homeBreadcrumb.click();
588
700
  }
@@ -649,38 +761,112 @@ class ModelerHomePage {
649
761
  },
650
762
  });
651
763
  }
652
- async createCrossComponentProjectFolder() {
653
- await this.dismissOverlays();
654
- // The projects list renders asynchronously, so an instantaneous
655
- // isVisible() reports "missing" whenever the list is still loading — and
656
- // this method then creates a SECOND project with the same name. Those
657
- // duplicates are permanent and break every subsequent run for that user,
658
- // so wait for the folder to actually show up before concluding it is
659
- // absent (this also replaces the blind 15s sleep the check relied on).
660
- const folderExists = await this.crossComponentProjectFolder
661
- .waitFor({ state: 'visible', timeout: 60000 })
662
- .then(() => true)
663
- .catch(() => false);
664
- if (folderExists) {
665
- console.log('Cross Component Project folder already exists. Clicking into it');
666
- await this.clickCrossComponentProjectFolder();
667
- return;
764
+ // Hub's "Create workspace" CTA is a persistent toolbar button, rendered
765
+ // regardless of whether the existing-workspaces list has finished loading
766
+ // -- so a single slow or failed list fetch must not be read as "no
767
+ // workspace exists yet". Retry the wait once after a reload before
768
+ // concluding the folder is genuinely absent, since creating one when it
769
+ // already exists (just hadn't rendered) leaves a permanent duplicate that
770
+ // breaks every later run for that user.
771
+ async waitForCrossComponentProjectFolder() {
772
+ for (let attempt = 0; attempt < 2; attempt++) {
773
+ const found = await this.crossComponentProjectFolder
774
+ .waitFor({ state: 'visible', timeout: 60000 })
775
+ .then(() => true)
776
+ .catch(() => false);
777
+ if (found)
778
+ return true;
779
+ if (attempt === 0) {
780
+ await this.page.reload();
781
+ await this.page.waitForLoadState('networkidle').catch(() => { });
782
+ }
668
783
  }
669
- await this.clickCreateNewProjectButton();
670
- await this.enterNewProjectName(this.defaultFolderName);
671
- // Both branches must leave the browser inside the workspace: the callers
672
- // go straight on to clickDiagramTypeDropdown(), whose create control only
673
- // exists there. Creating normally navigates into the new project, but when
674
- // hub leaves the browser on the projects list the rename happens in the row
675
- // and no navigation follows.
676
- //
677
- // Keyed on "New project", which hub renders only on the projects list and
678
- // never inside an open project, so it answers which view we are on
679
- // directly. The folder tile is the wrong signal: on a slow list render it
680
- // is still absent, which would read as "already inside" and skip a hop
681
- // that was needed.
682
- if (await this.becameVisible(this.createNewProjectButton, 5000)) {
683
- await this.clickCrossComponentProjectFolder();
784
+ return false;
785
+ }
786
+ // Retries the whole detect-or-create sequence, not just its individual
787
+ // steps. Up to 27 test-user workers race to create/open this one shared
788
+ // workspace concurrently, and a losing worker can land on a workspace-root
789
+ // page that never finishes rendering (neither the folder tile nor a create
790
+ // control ever becomes visible) for reasons the per-step probes/reloads
791
+ // below cannot resolve on their own -- each step bounds itself, but their
792
+ // sum does not bound the whole method, and a single unlucky pass used to
793
+ // run long enough to hit the 12-minute test timeout outright (run
794
+ // 36110457453 lost 6 "Create Project Folder" tests this way, pending
795
+ // inside a reload with no error of its own -- the test timeout fired
796
+ // first). Mirrors the 8.9 sibling's outer retry loop, which this method
797
+ // lost when it grew its own per-step reload-retries in place of it.
798
+ async createCrossComponentProjectFolder() {
799
+ const maxAttempts = 3;
800
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
801
+ await this.dismissOverlays();
802
+ // The projects list renders asynchronously, so an instantaneous
803
+ // isVisible() reports "missing" whenever the list is still loading — and
804
+ // this method then creates a SECOND project with the same name. Those
805
+ // duplicates are permanent and break every subsequent run for that user,
806
+ // so wait for the folder to actually show up before concluding it is
807
+ // absent (this also replaces the blind 15s sleep the check relied on).
808
+ // Re-checked on every attempt: a sibling worker may finish creating the
809
+ // folder while this one is retrying.
810
+ const folderExists = await this.waitForCrossComponentProjectFolder();
811
+ if (folderExists) {
812
+ console.log('Cross Component Project folder already exists. Clicking into it');
813
+ await this.clickCrossComponentProjectFolder();
814
+ return;
815
+ }
816
+ try {
817
+ // Same joint-probe pattern as clickCreateProcessApplication above: a
818
+ // single ORed probe with a generous timeout settles which shell
819
+ // actually rendered (Hub's "Create workspace" empty state vs. the
820
+ // legacy "New project" / "Create project" button) before a short
821
+ // confirmation decides the branch. A live CI trace showed probing
822
+ // createWorkspaceButton alone for 5s racing Hub's slower loads under
823
+ // worker contention: the probe reported "absent" on a workspaces
824
+ // overview that was merely still loading, sending the flow down the
825
+ // legacy branch -- which that overview never renders.
826
+ const rootLoaded = await this.becameVisible(this.createWorkspaceButton.or(this.createNewProjectButton).first(), 60000);
827
+ if (!rootLoaded) {
828
+ // Mirrors clickCreateProcessApplication's own reload-retry: a reload
829
+ // gives one more 60s window in case the root was still loading
830
+ // rather than genuinely absent, instead of committing to the wrong
831
+ // branch.
832
+ await this.page.reload();
833
+ await this.page.waitForLoadState('domcontentloaded');
834
+ await this.becameVisible(this.createWorkspaceButton.or(this.createNewProjectButton).first(), 60000);
835
+ }
836
+ if (await this.becameVisible(this.createWorkspaceButton, 5000)) {
837
+ await this.createWorkspaceViaWizard(this.defaultFolderName);
838
+ return;
839
+ }
840
+ await this.clickCreateNewProjectButton();
841
+ await this.enterNewProjectName(this.defaultFolderName);
842
+ // Both branches must leave the browser inside the workspace: the
843
+ // callers go straight on to clickDiagramTypeDropdown(), whose create
844
+ // control only exists there. Creating normally navigates into the new
845
+ // project, but when hub leaves the browser on the projects list the
846
+ // rename happens in the row and no navigation follows.
847
+ //
848
+ // Keyed on "New project", which hub renders only on the projects list
849
+ // and never inside an open project, so it answers which view we are
850
+ // on directly. The folder tile is the wrong signal: on a slow list
851
+ // render it is still absent, which would read as "already inside" and
852
+ // skip a hop that was needed.
853
+ if (await this.becameVisible(this.createNewProjectButton, 5000)) {
854
+ await this.clickCrossComponentProjectFolder();
855
+ }
856
+ return;
857
+ }
858
+ catch (error) {
859
+ if (attempt >= maxAttempts - 1) {
860
+ throw error;
861
+ }
862
+ // The stuck-root case above, or a wizard/rename step that threw, can
863
+ // leave the browser on a half-loaded or errored page that retrying
864
+ // in place cannot recover from. Return Home so the next attempt's
865
+ // existence check starts clean, instead of retrying against the same
866
+ // stuck page.
867
+ await this.clickHomeBreadcrumb().catch(() => { });
868
+ console.log(`createCrossComponentProjectFolder attempt ${attempt + 1} failed; retrying...`);
869
+ }
684
870
  }
685
871
  }
686
872
  async clickManageButton(retries = 3) {
@@ -10,6 +10,7 @@ const authErrorClassification_1 = require("../../utils/authErrorClassification")
10
10
  const LoginPage_1 = require("./LoginPage");
11
11
  const fileUpload_1 = require("../../utils/fileUpload");
12
12
  const mailSlurpClient_1 = require("../../utils/mailSlurpClient");
13
+ const consoleApiHelpers_1 = require("../../utils/consoleApiHelpers");
13
14
  async function clearWebStorage(page) {
14
15
  await page
15
16
  .evaluate(() => {
@@ -101,6 +102,27 @@ async function waitForLoginForm(page, loginPage, totalTimeout = 90000, roundTime
101
102
  // stagger sleep below, so a worker only pays it once per process lifetime
102
103
  // rather than on every call -- see the comment at the call site.
103
104
  const staggeredWorkerTimeouts = new Set();
105
+ const onboardingTourSuppressed = new WeakSet();
106
+ // Mirrors camunda-hub@452978f: pre-seed the onboarding tour's "seen" localStorage flag
107
+ // (SEEN_KEY_PREFIX in .../tour-dialog/use-onboarding-seen.ts) so it never auto-opens. The
108
+ // "Welcome to Camunda Hub" tour mounts app-wide -- Console included, not just Modeler -- and
109
+ // hides the post-login shell from the accessibility tree until dismissed, which times out
110
+ // assertTestUsesCorrectOrganization's banner check. addInitScript runs before any page
111
+ // script on every navigation/reload, so there's no login-response race to lose; the flag is
112
+ // matched by prefix since the user id isn't known ahead of login.
113
+ async function suppressOnboardingTour(page) {
114
+ if (onboardingTourSuppressed.has(page)) {
115
+ return;
116
+ }
117
+ onboardingTourSuppressed.add(page);
118
+ await page.addInitScript((prefixPattern) => {
119
+ const pattern = new RegExp(prefixPattern);
120
+ const originalGetItem = Storage.prototype.getItem;
121
+ Storage.prototype.getItem = function (key) {
122
+ return pattern.test(key) ? 'true' : originalGetItem.call(this, key);
123
+ };
124
+ }, '^hub\\.onboarding\\..*\\.seen\\.');
125
+ }
104
126
  async function loginWithRetry(page, loginPage, testUser, timeout, maxRetries = 5) {
105
127
  // A blank credential is not something retrying can fix, and from inside the
106
128
  // browser it does not look like a credential problem at all: the form takes
@@ -131,6 +153,7 @@ async function loginWithRetry(page, loginPage, testUser, timeout, maxRetries = 5
131
153
  '"Skip output ... since it may contain secret" warning on the ' +
132
154
  'matching C8_*_PASSWORD output.');
133
155
  }
156
+ await suppressOnboardingTour(page);
134
157
  let lastError;
135
158
  // `timeout` is a stable per-worker identifier (derived from workerIndex),
136
159
  // and a worker is a long-lived process across every test/repeat it runs, so
@@ -142,6 +165,11 @@ async function loginWithRetry(page, loginPage, testUser, timeout, maxRetries = 5
142
165
  for (let attempt = 0; attempt < maxRetries; attempt++) {
143
166
  try {
144
167
  await page.context().clearCookies();
168
+ // The Hub bearer-token cache in consoleApiHelpers.ts is keyed by Page
169
+ // for the tab's whole lifetime; without clearing it here alongside
170
+ // cookies/storage, a flow that logs a different user into the same
171
+ // page could have getUserToken() return the PREVIOUS user's token.
172
+ (0, consoleApiHelpers_1.resetHubBearerToken)(page);
145
173
  await page.goto('about:blank');
146
174
  await page
147
175
  .evaluate(() => {
@@ -196,6 +224,17 @@ async function loginWithRetry(page, loginPage, testUser, timeout, maxRetries = 5
196
224
  }
197
225
  catch (error) {
198
226
  lastError = error;
227
+ // A rejected password or a blocked account is a credential/
228
+ // provisioning-level failure, not a transient one: every attempt
229
+ // resubmits the same env-supplied credentials, so retrying just
230
+ // reproduces the same rejection up to maxRetries times (observed:
231
+ // ~6 minutes burned across 5 identical "Wrong email or password"
232
+ // cycles for one seeded user). Fail on the first rejection instead.
233
+ if (String(error).includes(authErrorClassification_1.AUTH0_INVALID_CREDENTIALS) ||
234
+ String(error).includes(authErrorClassification_1.AUTH0_ACCOUNT_BLOCKED)) {
235
+ console.error(lastError);
236
+ throw lastError;
237
+ }
199
238
  if (attempt < maxRetries - 1) {
200
239
  const rateLimited = String(error).includes(authErrorClassification_1.AUTH0_RATE_LIMIT);
201
240
  console.warn(`Attempt ${attempt + 1} failed for logging in. Retrying with clean session...`, error);
@@ -731,8 +770,12 @@ async function assertTestUsesCorrectOrganization(page) {
731
770
  const homePage = new HomePage_1.HomePage(page);
732
771
  if (page.url().includes('?code=') || page.url().includes('&state=')) {
733
772
  console.log('OAuth callback detected, waiting for redirect...');
773
+ // Camunda Hub (which replaced Console on SaaS) only exchanges the code
774
+ // once its SPA has booted, and on CI runners that boot alone was measured
775
+ // at ~45s past /login-callback -- a 30s wait aborted the exchange on every
776
+ // attempt.
734
777
  await page.waitForURL((url) => !url.toString().includes('?code='), {
735
- timeout: 30000,
778
+ timeout: 120000,
736
779
  });
737
780
  await (0, sleep_1.sleep)(5000);
738
781
  }
@@ -742,7 +785,20 @@ async function assertTestUsesCorrectOrganization(page) {
742
785
  await (0, test_1.expect)(homePage.modelerBanner).toBeVisible({ timeout: 30000 });
743
786
  }
744
787
  else {
745
- await (0, test_1.expect)(homePage.consoleBanner).toBeVisible({ timeout: 60000 });
788
+ await (0, test_1.expect)(homePage.consoleBanner.or(homePage.hubHeader)).toBeVisible({
789
+ timeout: 60000,
790
+ });
791
+ }
792
+ // Camunda Hub replaces Console on SaaS (8.10 release notes), and the Hub
793
+ // shell has neither the "Camunda logo Console" link nor the "Open
794
+ // Organizations" switcher, and lands on /workspaces without the org id in
795
+ // the URL. Its sidebar scopes the Clusters/Organization links to the active
796
+ // org, so assert the expected org id there instead.
797
+ if (await homePage.hubHeader.isVisible()) {
798
+ await (0, test_1.expect)(homePage.hubSidebar
799
+ .locator(`a[href*="/org/${process.env.ORG_ID}/"]`)
800
+ .first()).toBeVisible({ timeout: 180000 });
801
+ return;
746
802
  }
747
803
  await (0, test_1.expect)(homePage.openOrganizationButton).toBeVisible({ timeout: 180000 });
748
804
  try {
@@ -18,7 +18,8 @@ _8_10_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_10_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_10_1.test.skip('Enable Alpha Features', async ({ page, loginPage, consoleOrganizationsPage, homePage, }, testInfo) => {
22
23
  _8_10_1.test.skip(process.env.IS_PROD === 'true', 'Skipping test because not required on PROD test org');
23
24
  _8_10_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);
@@ -165,7 +166,9 @@ _8_10_1.test.describe('Cluster Setup Tests', () => {
165
166
  await (0, UtilitiesPage_1.loginWithRetry)(page, loginPage, user, Math.min((testInfo.workerIndex + 1) * 5000, 60000));
166
167
  });
167
168
  await _8_10_1.test.step('Navigate to Web Modeler', async () => {
168
- await (0, test_1.expect)(homePage.camundaComponentsButton).toBeVisible({
169
+ // Camunda Hub replaced Console + Web Modeler on SaaS: it lands on the
170
+ // Modeler home directly and has no app switcher to wait for.
171
+ await (0, test_1.expect)(homePage.camundaComponentsButton.or(homePage.hubHeader)).toBeVisible({
169
172
  timeout: 120000,
170
173
  });
171
174
  await appsPage.openModelerWithRetry(modelerHomePage.modelerPageBanner, 180000, 3, undefined, () => modelerHomePage.dismissBlockingDialog());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camunda/e2e-test-suite",
3
- "version": "0.0.1253",
3
+ "version": "0.0.1254",
4
4
  "description": "End-to-end test helpers for Camunda 8",
5
5
  "repository": {
6
6
  "type": "git",