diff --git a/.claude/migration-context.md b/.claude/migration-context.md index c93a556d99f..756160c8dd4 100644 --- a/.claude/migration-context.md +++ b/.claude/migration-context.md @@ -97,9 +97,11 @@ When migrating a Cypress test that uses `cy.get('[data-test-id="x"]')` or `cy.by | Cypress | Playwright | | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `cy.wait(3000)` | **AVOID.** Use `await expect(locator).toBeVisible()` or condition-based waits. Only use `page.waitForTimeout()` as absolute last resort during debugging. | -| `cy.get(s, { timeout: 60000 })` | `await this.page.locator(s).waitFor({ state: 'visible', timeout: 60000 })` | -| `cy.contains(text, { timeout })` | `await this.page.getByText(text).waitFor({ state: 'visible', timeout })` | +| `cy.wait(3000)` | Use web assertions e.g. `await expect(locator).toBeVisible()` or condition-based waits. Only use `page.waitForTimeout()` as absolute last resort during debugging. | +| `cy.get(s, { timeout }).click()` | `await locator.click({ timeout })` — pass timeout to the action, not a separate `waitFor()`. All Playwright actions accept a `timeout` option | +| `cy.get(s, { timeout }).should('be.visible')` | `await expect(locator).toBeVisible({ timeout })` — pass timeout to the assertion | +| `cy.get(s, { timeout })` (no action, just waiting) | `await locator.waitFor({ state: 'visible', timeout })` — only when no action or assertion follows | +| `cy.contains(text, { timeout })` | `await expect(page.getByText(text)).toBeVisible({ timeout })` or `await page.getByText(text).click({ timeout })` depending on what follows | | `cy.intercept('GET', url).as('req')` + `cy.wait('@req')` | `await this.page.waitForResponse(url)` or `page.waitForResponse(resp => resp.url().includes(url))` | ### Resource Lifecycle @@ -439,11 +441,96 @@ The MCP's tracked page stays on `about:blank` — use the `p` reference from the --- +## Playwright Auto-Awaiting + +Playwright action methods (`fill()`, `click()`, `check()`, `uncheck()`, `selectOption()`, `type()`, `press()`) **auto-wait for the element to be actionable** (visible, enabled, stable). You do NOT need an explicit `waitFor()` before calling these actions. This includes `robustClick()` in page objects — it also auto-waits. + +> **ESLint enforcement:** The `no-restricted-syntax` rule in `e2e/.eslintrc.cjs` warns on all `.waitFor()` calls. Legitimate uses must have `// eslint-disable-next-line no-restricted-syntax`. This catches redundant `waitFor()` at lint time — `yarn eslint` will flag new violations. + +```typescript +// WRONG — redundant waitFor before an action +await input.waitFor({ state: 'visible' }); +await input.fill('text'); + +// WRONG — redundant waitFor before robustClick +await action.waitFor({ state: 'visible', timeout: 10_000 }); +await this.robustClick(action); + +// RIGHT — actions auto-wait for actionability +await input.fill('text'); +await this.robustClick(action); + +// RIGHT — if you need a custom timeout, pass it to the action +await input.fill('text', { timeout: 10_000 }); +await action.click({ timeout: 10_000 }); +``` + +Only use explicit `waitFor()` when you need to wait for an element **without acting on it** — e.g., confirming navigation completed, or waiting for loading indicators to disappear: + +```typescript +// OK — waiting for a state transition, not an action +await page.getByTestId('loading-indicator').waitFor({ state: 'detached' }); + +// OK — confirming the editor loaded before reading its content (not an action on the element) +await page.getByTestId('code-editor').waitFor({ state: 'visible' }); +``` + +Similarly, `waitForLoadingComplete()` should not be called at the end of page object methods like `selectProject()`. The caller's next action will auto-wait for whatever element it needs. + +--- + +## Adding `data-test` Attributes + +When migrating selectors, **always check the React source** for existing `data-test` attributes before creating locators: + +1. **If `data-test` already exists** on the element → use `getByTestId('value')` directly. +2. **If only a legacy attribute exists** (`data-test-id`, `data-test-rows`, `data-test-dropdown-menu`, `data-test-action`, etc.) → add `data-test="value"` to the React component source alongside the existing legacy attribute, then use `getByTestId('value')`. +3. **Never use legacy attribute selectors** like `page.locator('[data-test-rows="..."]')` or `page.locator('[data-test-dropdown-menu="..."]')` when `data-test` exists or can be added. + +```typescript +// WRONG — using legacy selector directly +private readonly resourceRows = this.page.locator('[data-test-rows="resource-row"]'); + +// RIGHT — data-test="resource-row" already exists on the same element +private readonly resourceRows = this.page.getByTestId('resource-row'); +``` + +--- + +## k8sClient Cleanup + +`KubernetesClient.deleteNamespace()` and `KubernetesClient.deleteCustomResource()` catch errors and call `isNotFound(err)` to silently swallow 404 "not found" responses. Do NOT wrap these cleanup calls in try/catch blocks. Note: `deleteClusterCustomResource` is not implemented in `KubernetesClient` — do not reference it. + +```typescript +// WRONG — unnecessary error handling +test.afterAll(async ({ k8sClient }) => { + try { await k8sClient.deleteNamespace(namespace); } catch { /* may already be deleted */ } +}); + +// RIGHT — k8sClient handles 404 silently +test.afterAll(async ({ k8sClient }) => { + await k8sClient.deleteNamespace(namespace); +}); +``` + +--- + +## Page Object Naming + +- Do NOT prefix methods or locators with `legacy`. If a locator targets an older DOM structure that will be replaced, name it for what it does, not its age (e.g., `filterByNameInput` not `legacyFilterByName`). +- Common actions (navigate to form, click create dropdown item, filter + select) should be page object methods, not inline locator chains in spec files. + +--- + ## Things to NEVER Do - **Never import `test` or `expect` from `@playwright/test`** — import from `e2e/fixtures` - **Never transliterate** — `cy.get(x).click()` → `page.locator(x).click()` is not a migration. Understand intent, use idiomatic Playwright APIs - **Never use `page.waitForTimeout()`** as a replacement for `cy.wait()`. Find the condition to wait for +- **Never add `waitFor()` before an action** — `fill()`, `click()`, `check()`, etc. already auto-wait for actionability +- **Never use legacy test attribute selectors** (`[data-test-rows="..."]`, `[data-test-id="..."]`, `[data-test-dropdown-menu="..."]`) — add `data-test` to the React source and use `getByTestId()` +- **Never wrap k8sClient cleanup in try/catch** — `deleteNamespace` and `deleteCustomResource` already swallow 404s +- **Never prefix methods with `legacy`** — name for what it does, not its age - **Never put locators in spec files** when a page object exists or should exist - **Never rely on test order** — each `test()` must work independently. - **Never skip cleanup** — every created resource must be tracked with `cleanup.track*()` diff --git a/.claude/skills/migrate-cypress/SKILL.md b/.claude/skills/migrate-cypress/SKILL.md index 9d77a096992..8d4dfc14d46 100644 --- a/.claude/skills/migrate-cypress/SKILL.md +++ b/.claude/skills/migrate-cypress/SKILL.md @@ -79,8 +79,10 @@ If MCP is unavailable or no cluster is reachable, log a warning: "Playwright MCP 1. Create/extend page objects with locators and interaction methods. Follow the established pattern: - Import `Locator` type from `@playwright/test` and default-import `BasePage` - Use `getByTestId()` for `data-test` attributes, `locator()` for other selectors + - If the React component only has a legacy test attribute (`data-test-id`, `data-test-rows`, `data-test-dropdown-menu`, etc.) but no `data-test`, **add `data-test` to the React component source** and use `getByTestId()` — never use legacy attribute selectors directly - Expose locators via getter methods (`getX(): Locator`), keep locator properties `private readonly` - - Use `robustClick()` inside page objects; specs use plain `.click()`. + - Use `robustClick()` inside page objects; specs use plain `.click()` + - Do NOT name methods or locators with a `legacy` prefix — name for what they do Example: ```typescript @@ -145,8 +147,10 @@ Example: - **Self-contained tests** — merge sequential `it` blocks into one `test()` with `test.step()` - **No fixed waits** — replace `cy.wait(ms)` with condition-based waits or assertion timeouts - **No shell commands** — replace `cy.exec('oc ...')` with `KubernetesClient` +- **No try/catch in cleanup** — `k8sClient.deleteNamespace()` and `deleteCustomResource()` already swallow 404 errors +- **Add `data-test` to React source** — when the component only has legacy test attributes (`data-test-id`, `data-test-rows`, etc.), add `data-test` alongside and use `getByTestId()` - **Framework-first** — use existing page objects before creating new ones -- **Correct layer** — locators in page objects, test scenarios in specs +- **Correct layer** — locators in page objects, test scenarios in specs; common multi-step interactions belong in page object methods, not inline in specs ## Troubleshooting diff --git a/frontend/e2e/.eslintrc.cjs b/frontend/e2e/.eslintrc.cjs index 932a10c6220..7b2bb03cbad 100644 --- a/frontend/e2e/.eslintrc.cjs +++ b/frontend/e2e/.eslintrc.cjs @@ -9,6 +9,17 @@ module.exports = { rules: { 'no-console': 'off', 'no-empty-pattern': 'off', + 'no-restricted-syntax': [ + 'warn', + { + selector: 'CallExpression[callee.property.name="waitFor"]', + message: + 'Playwright actions (click, fill, check, clear) auto-wait for actionability. ' + + 'Do not call waitFor() before an action on the same locator. ' + + 'If this waitFor() is intentional (waiting for state without a subsequent action), ' + + 'add // eslint-disable-next-line no-restricted-syntax', + }, + ], 'playwright/no-conditional-in-test': 'off', 'playwright/no-skipped-test': ['warn', { allowConditional: true }], }, diff --git a/frontend/e2e/pages/alertmanager-page.ts b/frontend/e2e/pages/alertmanager-page.ts index 7cda29db98d..b2f0f7dafd0 100644 --- a/frontend/e2e/pages/alertmanager-page.ts +++ b/frontend/e2e/pages/alertmanager-page.ts @@ -24,18 +24,17 @@ export class AlertmanagerPage extends BasePage { async navigateToAlertmanager(): Promise { await this.goTo('/settings/cluster/alertmanagerconfig'); - await this.createReceiverButton.waitFor({ state: 'visible' }); + await expect(this.createReceiverButton).toBeVisible(); } async navigateToYAMLPage(): Promise { await this.goTo('/settings/cluster/alertmanageryaml'); - // Wait for editor toolbar to load (indicates editor is ready) - await this.page.getByRole('button', { name: 'Copy code to clipboard' }).waitFor(); + await expect(this.page.getByRole('button', { name: 'Copy code to clipboard' })).toBeVisible(); } async navigateToEditReceiver(receiverName: string): Promise { await this.goTo(`/settings/cluster/alertmanagerconfig/receivers/${receiverName}/edit`); - await this.saveChangesButton.waitFor({ state: 'visible' }); + await expect(this.saveChangesButton).toBeVisible(); } async createReceiver(receiverName: string, receiverTypeConfig: string): Promise { @@ -51,7 +50,7 @@ export class AlertmanagerPage extends BasePage { async save(): Promise { await expect(this.saveChangesButton).toBeEnabled(); await this.robustClick(this.saveChangesButton); - await this.createReceiverButton.waitFor({ state: 'visible', timeout: 60_000 }); + await expect(this.createReceiverButton).toBeVisible({ timeout: 60_000 }); } async showAdvancedConfiguration(): Promise { @@ -60,7 +59,7 @@ export class AlertmanagerPage extends BasePage { const button = this.advancedConfigButton.locator('button'); await this.robustClick(button); - await sendResolved.waitFor({ state: 'visible', timeout: 15_000 }); + await expect(sendResolved).toBeVisible({ timeout: 15_000 }); } async getYAMLContent(): Promise { diff --git a/frontend/e2e/pages/base-page.ts b/frontend/e2e/pages/base-page.ts index e8bcc9eb325..404d46f252e 100644 --- a/frontend/e2e/pages/base-page.ts +++ b/frontend/e2e/pages/base-page.ts @@ -21,6 +21,7 @@ export default abstract class BasePage { try { const count = await loadingElements.count().catch(() => 0); if (count > 0) { + // eslint-disable-next-line no-restricted-syntax await loadingElements.first().waitFor({ state: 'hidden', timeout: timeoutMs }); } } catch { @@ -61,6 +62,7 @@ export default abstract class BasePage { for (let attempt = 1; attempt <= retries; attempt++) { try { await this.waitForLoadingComplete(Math.min(attemptTimeout / 4, 3_000)); + // eslint-disable-next-line no-restricted-syntax await locator.waitFor({ state: 'visible', timeout: attemptTimeout }); await locator.scrollIntoViewIfNeeded({ timeout: attemptTimeout / 3 }); diff --git a/frontend/e2e/pages/cluster-dashboard-page.ts b/frontend/e2e/pages/cluster-dashboard-page.ts index a611b8401a7..07fc593bf62 100644 --- a/frontend/e2e/pages/cluster-dashboard-page.ts +++ b/frontend/e2e/pages/cluster-dashboard-page.ts @@ -1,4 +1,5 @@ import type { Locator } from '@playwright/test'; +import { expect } from '@playwright/test'; import BasePage from './base-page'; @@ -18,7 +19,8 @@ export class ClusterDashboardPage extends BasePage { } async waitForStatusCardLoaded(): Promise { - await this.statusCard.waitFor({ state: 'visible', timeout: 30_000 }); + await expect(this.statusCard).toBeVisible({ timeout: 30_000 }); + // eslint-disable-next-line no-restricted-syntax await this.statusCard.locator('.skeleton-health').waitFor({ state: 'hidden', timeout: 30_000 }).catch(() => { // Skeletons may have already disappeared }); @@ -38,6 +40,6 @@ export class ClusterDashboardPage extends BasePage { async openInsightsPopup(): Promise { await this.robustClick(this.insightsButton); - await this.popover.waitFor({ state: 'visible', timeout: 10_000 }); + await expect(this.popover).toBeVisible({ timeout: 10_000 }); } } diff --git a/frontend/e2e/pages/cluster-settings-page.ts b/frontend/e2e/pages/cluster-settings-page.ts index 86646517718..b11da3583b7 100644 --- a/frontend/e2e/pages/cluster-settings-page.ts +++ b/frontend/e2e/pages/cluster-settings-page.ts @@ -1,4 +1,5 @@ import type { Locator } from '@playwright/test'; +import { expect } from '@playwright/test'; import BasePage from './base-page'; @@ -29,7 +30,7 @@ export class ClusterSettingsPage extends BasePage { */ async navigateToDetails(): Promise { await this.goTo('/settings/cluster'); - await this.detailsTab.waitFor({ state: 'visible' }); + await expect(this.detailsTab).toBeVisible(); } /** @@ -43,9 +44,8 @@ export class ClusterSettingsPage extends BasePage { * Click the current channel update link to open the modal */ async openChannelModal(): Promise { - await this.currentChannelUpdateLink.waitFor({ state: 'visible' }); await this.currentChannelUpdateLink.click(); - await this.modalTitle.waitFor({ state: 'visible', timeout: 30_000 }); + await expect(this.modalTitle).toBeVisible({ timeout: 30_000 }); } /** @@ -59,7 +59,6 @@ export class ClusterSettingsPage extends BasePage { * Type a channel name into the input field (for "Input channel" modal) */ async inputChannelName(channelName: string): Promise { - await this.channelModalInput.waitFor({ state: 'visible' }); await this.channelModalInput.clear(); await this.channelModalInput.fill(channelName); } @@ -81,6 +80,7 @@ export class ClusterSettingsPage extends BasePage { async confirmAction(): Promise { await this.robustClick(this.confirmActionButton); // Wait for modal to close + // eslint-disable-next-line no-restricted-syntax await this.modalTitle.waitFor({ state: 'hidden', timeout: 10_000 }).catch(() => { // Modal may close quickly, ignore timeout }); @@ -91,11 +91,9 @@ export class ClusterSettingsPage extends BasePage { */ async navigateToConfigurationTab(): Promise { await this.navigateToTab(this.configurationTab); - // Wait for loading to complete - await this.page - .locator('.loading-box__loaded') - .first() - .waitFor({ state: 'visible', timeout: 30_000 }); + await expect(this.page.locator('.loading-box__loaded').first()).toBeVisible({ + timeout: 30_000, + }); } /** @@ -169,21 +167,11 @@ export class ClusterSettingsPage extends BasePage { */ async openUpdateModal(): Promise { const updateButton = this.page.getByTestId('cv-update-button'); - await updateButton.waitFor({ state: 'visible' }); await this.robustClick(updateButton); // Wait for modal to appear - await this.modalTitle.waitFor({ state: 'visible', timeout: 10_000 }); - await this.page.waitForFunction( - () => { - const title = document.querySelector('[data-test="modal-title"]'); - return title?.textContent?.includes('Update cluster'); - }, - { timeout: 10_000 }, - ); - - const modal = this.page.getByTestId('update-cluster-modal'); - await modal.waitFor({ state: 'visible' }); + await expect(this.modalTitle).toContainText('Update cluster', { timeout: 10_000 }); + await expect(this.page.getByTestId('update-cluster-modal')).toBeVisible(); } /** @@ -193,9 +181,6 @@ export class ClusterSettingsPage extends BasePage { const modal = this.page.getByTestId('update-cluster-modal'); const dropdownToggle = modal.getByTestId('dropdown-with-switch-toggle'); - await dropdownToggle.waitFor({ state: 'visible' }); - await dropdownToggle.waitFor({ state: 'attached' }); - await this.robustClick(dropdownToggle); } } diff --git a/frontend/e2e/pages/details-page.ts b/frontend/e2e/pages/details-page.ts index b884c16e8fa..643eb9d33e9 100644 --- a/frontend/e2e/pages/details-page.ts +++ b/frontend/e2e/pages/details-page.ts @@ -4,22 +4,6 @@ import BasePage from './base-page'; export class DetailsPage extends BasePage { private readonly pageHeading = this.page.getByTestId('page-heading'); - private readonly resourceTitle = this.page.getByTestId('resource-title'); - private readonly skeletonLoader = this.page.getByTestId('skeleton-detail-view'); - - /** - * Wait for the details page to load - */ - async waitForPageLoad(): Promise { - await this.skeletonLoader.waitFor({ state: 'detached', timeout: 30_000 }).catch(() => { - // Skeleton may not appear for fast loads - }); - // Wait for either resource title or page heading to be visible - await Promise.race([ - this.resourceTitle.waitFor({ state: 'visible', timeout: 30_000 }), - this.pageHeading.waitFor({ state: 'visible', timeout: 30_000 }), - ]); - } /** * Get the page heading locator @@ -42,7 +26,6 @@ export class DetailsPage extends BasePage { */ async clickKebabAction(actionId: string): Promise { const action = this.page.locator(`[data-test-action="${actionId}"]`); - await action.waitFor({ state: 'visible', timeout: 10_000 }); await this.robustClick(action); } @@ -64,15 +47,10 @@ export class DetailsPage extends BasePage { /** * Click a resource row to navigate to its details - * Set waitForLoad=false to skip waiting for page load (useful for in-page navigation) */ - async clickResourceRow(resourceId: string, waitForLoad = true): Promise { + async clickResourceRow(resourceId: string): Promise { const row = this.getResourceRow(resourceId); - await row.waitFor({ state: 'visible', timeout: 30_000 }); await this.robustClick(row); - if (waitForLoad) { - await this.waitForPageLoad(); - } } /** diff --git a/frontend/e2e/pages/navigation.ts b/frontend/e2e/pages/navigation.ts index 1b7b42e1bf5..9c350b04822 100644 --- a/frontend/e2e/pages/navigation.ts +++ b/frontend/e2e/pages/navigation.ts @@ -15,8 +15,6 @@ export class Navigation { // Navigate to home first to ensure app is loaded await this.page.goto('/'); const sectionButton = this.page.getByRole('button', { name: section }); - await sectionButton.waitFor({ state: 'visible' }); - await sectionButton.click(); await this.page.getByRole('link', { name: link }).click(); await this.page.waitForLoadState('domcontentloaded'); diff --git a/frontend/e2e/pages/oauth-page.ts b/frontend/e2e/pages/oauth-page.ts index 0ad03c5df43..f1e6aca19bb 100644 --- a/frontend/e2e/pages/oauth-page.ts +++ b/frontend/e2e/pages/oauth-page.ts @@ -1,4 +1,5 @@ import type { Locator } from '@playwright/test'; +import { expect } from '@playwright/test'; import BasePage from './base-page'; @@ -37,11 +38,11 @@ export class OAuthPage extends BasePage { await this.waitForLoadingComplete(); // Verify no error alert - await this.errorAlert.waitFor({ state: 'detached', timeout: 5_000 }); + await expect(this.errorAlert).not.toBeAttached({ timeout: 5_000 }); // Verify the IDP appears in the list const idpNameCell = this.page.getByTestId(`idp-name-${idpName}`); - await idpNameCell.waitFor({ state: 'visible', timeout: 30_000 }); + await expect(idpNameCell).toBeVisible({ timeout: 30_000 }); // Verify content matches expected values await this.page.waitForFunction( @@ -68,6 +69,7 @@ export class OAuthPage extends BasePage { async removeIDP(idpName: string): Promise { // First verify the IDP exists const kebabCell = this.getIDPKebabMenu(idpName); + // eslint-disable-next-line no-restricted-syntax await kebabCell.waitFor({ state: 'visible', timeout: 5_000 }).catch(() => { throw new Error(`IDP "${idpName}" not found in the list - cannot remove`); }); @@ -78,19 +80,17 @@ export class OAuthPage extends BasePage { // Click the Remove action const removeAction = this.page.locator('[data-test-action="Remove identity provider"]'); - await removeAction.waitFor({ state: 'visible', timeout: 10_000 }); await this.robustClick(removeAction); // Confirm the removal const confirmButton = this.page.getByTestId('confirm-action'); - await confirmButton.waitFor({ state: 'visible', timeout: 5_000 }); await this.robustClick(confirmButton); // Wait for loading to complete after removal await this.waitForLoadingComplete(); // Verify the IDP was removed (wait up to 30 seconds for OAuth operator to process) - await kebabCell.waitFor({ state: 'detached', timeout: 30_000 }); + await expect(kebabCell).not.toBeAttached({ timeout: 30_000 }); } /** @@ -98,6 +98,6 @@ export class OAuthPage extends BasePage { */ async verifyIDPNotExists(idpName: string): Promise { const kebab = this.getIDPKebabMenu(idpName); - await kebab.waitFor({ state: 'detached', timeout: 5_000 }); + await expect(kebab).not.toBeAttached({ timeout: 5_000 }); } } diff --git a/frontend/e2e/pages/web-terminal-config-page.ts b/frontend/e2e/pages/web-terminal-config-page.ts index 1b37e4a062e..6df68a4e2a2 100644 --- a/frontend/e2e/pages/web-terminal-config-page.ts +++ b/frontend/e2e/pages/web-terminal-config-page.ts @@ -16,6 +16,7 @@ export class WebTerminalConfigPage extends BasePage { await this.goTo('/k8s/cluster/operator.openshift.io~v1~Console/cluster'); await this.waitForLoadingComplete(10_000); const customizeButton = this.page.getByRole('button', { name: 'Customize' }); + // eslint-disable-next-line no-restricted-syntax await customizeButton .first() .waitFor({ state: 'visible', timeout: 30_000 }) diff --git a/frontend/e2e/pages/web-terminal-page.ts b/frontend/e2e/pages/web-terminal-page.ts index 48cf6a03247..bf9f4195199 100644 --- a/frontend/e2e/pages/web-terminal-page.ts +++ b/frontend/e2e/pages/web-terminal-page.ts @@ -1,4 +1,5 @@ import type { Locator } from '@playwright/test'; +import { expect } from '@playwright/test'; import BasePage from './base-page'; @@ -28,6 +29,7 @@ export class WebTerminalPage extends BasePage { async waitForTerminalIconVisible(maxRetries = 10): Promise { await this.goTo('/'); try { + // eslint-disable-next-line no-restricted-syntax await this.terminalIcon.waitFor({ state: 'visible', timeout: 30_000 }); return; } catch { @@ -36,6 +38,7 @@ export class WebTerminalPage extends BasePage { for (let attempt = 0; attempt < maxRetries; attempt++) { await this.page.reload(); try { + // eslint-disable-next-line no-restricted-syntax await this.terminalIcon.waitFor({ state: 'visible', timeout: 15_000 }); return; } catch { @@ -47,16 +50,18 @@ export class WebTerminalPage extends BasePage { async clickTerminalIcon(): Promise { await this.robustClick(this.terminalIcon); + // eslint-disable-next-line no-restricted-syntax await this.loadingBox.waitFor({ state: 'detached', timeout: 60_000 }).catch(() => {}); } async waitForTerminalWindow(timeoutMs = 60_000): Promise { - await this.terminalContainer.waitFor({ state: 'visible', timeout: timeoutMs }); - await this.terminalWindow.waitFor({ state: 'visible', timeout: timeoutMs }); + await expect(this.terminalContainer).toBeVisible({ timeout: timeoutMs }); + await expect(this.terminalWindow).toBeVisible({ timeout: timeoutMs }); } async closeTerminalDrawer(): Promise { await this.robustClick(this.drawerCloseButton); + // eslint-disable-next-line no-restricted-syntax await this.terminalContainer.waitFor({ state: 'hidden', timeout: 10_000 }).catch(() => {}); } @@ -67,12 +72,10 @@ export class WebTerminalPage extends BasePage { } async clickAdvancedTimeout(): Promise { - await this.timeoutLink.waitFor({ state: 'visible', timeout: 30_000 }); await this.robustClick(this.timeoutLink); } async setTimeoutValue(value: string): Promise { - await this.incrementButton.waitFor({ state: 'visible', timeout: 10_000 }); await this.robustClick(this.incrementButton); await this.timeoutInput.fill(value); await this.timeoutInput.press('Tab'); @@ -99,7 +102,7 @@ export class WebTerminalPage extends BasePage { } async getResourceTitle(): Promise { - await this.resourceTitle.waitFor({ state: 'visible' }); + await expect(this.resourceTitle).toBeVisible(); return (await this.resourceTitle.textContent()) || ''; } diff --git a/frontend/e2e/setup/login-helper.ts b/frontend/e2e/setup/login-helper.ts index 3c75fd4166e..a05875dc50a 100644 --- a/frontend/e2e/setup/login-helper.ts +++ b/frontend/e2e/setup/login-helper.ts @@ -23,11 +23,9 @@ export async function performLogin( return; } - await page - .locator('[data-test-id="login"]') - .or(page.locator('#inputUsername')) - .first() - .waitFor({ state: 'visible', timeout: 30_000 }); + await expect( + page.locator('[data-test-id="login"]').or(page.locator('#inputUsername')).first(), + ).toBeVisible({ timeout: 30_000 }); if (idpName) { const providerButton = page.getByText(idpName, { exact: true }); diff --git a/frontend/e2e/tests/console/cluster-settings/alertmanager/alertmanager.spec.ts b/frontend/e2e/tests/console/cluster-settings/alertmanager/alertmanager.spec.ts index 7ae7ccb826e..a92d29b6f67 100644 --- a/frontend/e2e/tests/console/cluster-settings/alertmanager/alertmanager.spec.ts +++ b/frontend/e2e/tests/console/cluster-settings/alertmanager/alertmanager.spec.ts @@ -27,9 +27,7 @@ type AlertmanagerRoute = { test.describe.configure({ mode: 'serial' }); -// Skipped due to flakes: OCPBUGS-88451 -// eslint-disable-next-line playwright/no-skipped-test -test.describe.skip('Alertmanager', { tag: ['@admin'] }, () => { +test.describe('Alertmanager', { tag: ['@admin'] }, () => { let alertmanager: AlertmanagerPage; let k8sClient: KubernetesClient; diff --git a/frontend/e2e/tests/console/cluster-settings/alertmanager/receivers/email.spec.ts b/frontend/e2e/tests/console/cluster-settings/alertmanager/receivers/email.spec.ts index 6490c25b642..5b6fac9eb2c 100644 --- a/frontend/e2e/tests/console/cluster-settings/alertmanager/receivers/email.spec.ts +++ b/frontend/e2e/tests/console/cluster-settings/alertmanager/receivers/email.spec.ts @@ -7,9 +7,7 @@ import KubernetesClient from '../../../../../clients/kubernetes-client'; test.describe.configure({ mode: 'serial' }); -// Skipped due to flakes: OCPBUGS-88451 -// eslint-disable-next-line playwright/no-skipped-test -test.describe.skip('Alertmanager Email Receiver Form', { tag: ['@admin'] }, () => { +test.describe('Alertmanager Email Receiver Form', { tag: ['@admin'] }, () => { let alertmanager: AlertmanagerPage; let k8sClient: KubernetesClient; diff --git a/frontend/e2e/tests/console/cluster-settings/alertmanager/receivers/pagerduty.spec.ts b/frontend/e2e/tests/console/cluster-settings/alertmanager/receivers/pagerduty.spec.ts index da4032513e1..3f504f916a6 100644 --- a/frontend/e2e/tests/console/cluster-settings/alertmanager/receivers/pagerduty.spec.ts +++ b/frontend/e2e/tests/console/cluster-settings/alertmanager/receivers/pagerduty.spec.ts @@ -8,9 +8,7 @@ import { resetAlertmanagerConfig } from '../alertmanager-test-utils'; test.describe.configure({ mode: 'serial' }); -// Skipped due to flakes: OCPBUGS-88451 -// eslint-disable-next-line playwright/no-skipped-test -test.describe.skip('Alertmanager PagerDuty Receiver Form', { tag: ['@admin'] }, () => { +test.describe('Alertmanager PagerDuty Receiver Form', { tag: ['@admin'] }, () => { let alertmanager: AlertmanagerPage; let k8sClient: KubernetesClient; diff --git a/frontend/e2e/tests/console/cluster-settings/alertmanager/receivers/webhook.spec.ts b/frontend/e2e/tests/console/cluster-settings/alertmanager/receivers/webhook.spec.ts index 68e3220b12b..9d21b932f81 100644 --- a/frontend/e2e/tests/console/cluster-settings/alertmanager/receivers/webhook.spec.ts +++ b/frontend/e2e/tests/console/cluster-settings/alertmanager/receivers/webhook.spec.ts @@ -8,9 +8,7 @@ import { resetAlertmanagerConfig } from '../alertmanager-test-utils'; test.describe.configure({ mode: 'serial' }); -// Skipped due to flakes: OCPBUGS-88451 -// eslint-disable-next-line playwright/no-skipped-test -test.describe.skip('Alertmanager Webhook Receiver Form', { tag: ['@admin'] }, () => { +test.describe('Alertmanager Webhook Receiver Form', { tag: ['@admin'] }, () => { let alertmanager: AlertmanagerPage; let k8sClient: KubernetesClient; diff --git a/frontend/e2e/tests/console/cluster-settings/channel-modal.spec.ts b/frontend/e2e/tests/console/cluster-settings/channel-modal.spec.ts index d49d98576fd..c6ef2e7c74c 100644 --- a/frontend/e2e/tests/console/cluster-settings/channel-modal.spec.ts +++ b/frontend/e2e/tests/console/cluster-settings/channel-modal.spec.ts @@ -11,65 +11,47 @@ test.describe('Cluster Settings channel modal', { tag: ['@admin', '@smoke'] }, ( test('changes based on cluster version', async ({ page }) => { const clusterSettings = new ClusterSettingsPage(page); - await test.step('Handle no channel configured scenario', async () => { - // Mock the API response to return cluster version without channel - await page.route(CLUSTER_VERSION_URL, async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify(clusterVersionWithoutChannel), - }); + // Use a mutable reference so the single route handler can serve different + // mock data across steps without unroute/route gaps that let real API + // responses (including WebSocket watch updates) slip through. + let activeMock = clusterVersionWithoutChannel; + await page.route(CLUSTER_VERSION_URL, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(activeMock), }); + }); - // Navigate to cluster settings (dismisses tour and triggers the mocked response) + await test.step('Handle no channel configured scenario', async () => { await clusterSettings.navigateToDetails(); - // Verify current channel shows "Not configured" await expect(clusterSettings.getCurrentChannelLink()).toContainText('Not configured'); - // Open the modal await clusterSettings.openChannelModal(); - // Verify modal title is "Input channel" (the key test - modal adapts to state) await expect(clusterSettings.getModalTitle()).toContainText('Input channel'); - - // Verify the input field is present (not a dropdown) await expect(clusterSettings.getChannelModalInput()).toBeVisible(); - // Close modal without submitting (this is a UI state test, not an integration test) await clusterSettings.page.keyboard.press('Escape'); }); await test.step('Handle channel configured with available channels scenario', async () => { - // Clear previous route and set new mock - await page.unroute(CLUSTER_VERSION_URL); - await page.route(CLUSTER_VERSION_URL, async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify(clusterVersionWithDesiredChannels), - }); - }); + activeMock = clusterVersionWithDesiredChannels; - // Navigate to cluster settings to trigger the mocked response await clusterSettings.navigateToDetails(); - // Verify current channel shows "stable-4.16" await expect(clusterSettings.getCurrentChannelLink()).toContainText('stable-4.16'); - // Open the modal await clusterSettings.openChannelModal(); - // Verify modal title is "Select channel" (the key test - modal adapts to state) await expect(clusterSettings.getModalTitle()).toContainText('Select channel'); - // Verify the dropdown is present (not an input field) const dropdown = clusterSettings .getChannelModal() .locator('[data-test="console-select-menu-toggle"]'); await expect(dropdown).toBeVisible(); - // Close modal without submitting (this is a UI state test, not an integration test) await clusterSettings.page.keyboard.press('Escape'); }); }); diff --git a/frontend/e2e/tests/console/cluster-settings/cluster-settings.spec.ts b/frontend/e2e/tests/console/cluster-settings/cluster-settings.spec.ts index 8bd5fed04c2..372a25d0d33 100644 --- a/frontend/e2e/tests/console/cluster-settings/cluster-settings.spec.ts +++ b/frontend/e2e/tests/console/cluster-settings/cluster-settings.spec.ts @@ -53,7 +53,7 @@ test.describe('Cluster Settings', { tag: ['@admin'] }, () => { await test.step('Navigate to Configuration tab', async () => { await clusterSettings.navigateToConfiguration(); // Wait for configuration resources to load - await page.getByTestId('ClusterVersion').waitFor({ state: 'visible', timeout: 30_000 }); + await expect(page.getByTestId('ClusterVersion')).toBeVisible({ timeout: 30_000 }); }); await test.step('Click ClusterVersion and select YAML tab', async () => { diff --git a/frontend/e2e/tests/console/cluster-settings/oauth.spec.ts b/frontend/e2e/tests/console/cluster-settings/oauth.spec.ts index 5ba26eb2e8a..3d075b06b48 100644 --- a/frontend/e2e/tests/console/cluster-settings/oauth.spec.ts +++ b/frontend/e2e/tests/console/cluster-settings/oauth.spec.ts @@ -3,9 +3,7 @@ import { test } from '../../../fixtures'; import { OAuthPage } from '../../../pages/oauth-page'; import type KubernetesClient from '../../../clients/kubernetes-client'; -// Skipped due to flakes: OCPBUGS-88451 -// eslint-disable-next-line playwright/no-skipped-test -test.describe.skip('OAuth', { tag: ['@admin'] }, () => { +test.describe('OAuth', { tag: ['@admin'] }, () => { let client: KubernetesClient; let originalOAuthConfig: any; const testPrefix = `e2e-${Date.now()}`; diff --git a/frontend/e2e/tests/console/cluster-settings/update-modal.spec.ts b/frontend/e2e/tests/console/cluster-settings/update-modal.spec.ts index b1481b2e32c..06d10d64886 100644 --- a/frontend/e2e/tests/console/cluster-settings/update-modal.spec.ts +++ b/frontend/e2e/tests/console/cluster-settings/update-modal.spec.ts @@ -64,9 +64,7 @@ async function stubMachineConfigPoolWebSocket(page: import('@playwright/test').P }); } -// Skipped due to flakes: OCPBUGS-88451 -// eslint-disable-next-line playwright/no-skipped-test -test.describe.skip('Cluster Settings cluster update modal', { tag: ['@admin'] }, () => { +test.describe('Cluster Settings cluster update modal', { tag: ['@admin'] }, () => { test('changes based on the cluster', async ({ page }) => { const clusterSettings = new ClusterSettingsPage(page); @@ -151,7 +149,7 @@ test.describe.skip('Cluster Settings cluster update modal', { tag: ['@admin'] }, }); await page.reload(); - await page.getByTestId('horizontal-link-Details').waitFor({ state: 'visible' }); + await expect(page.getByTestId('horizontal-link-Details')).toBeVisible(); // Open update modal and dropdown await clusterSettings.openUpdateModal(); @@ -186,7 +184,7 @@ test.describe.skip('Cluster Settings cluster update modal', { tag: ['@admin'] }, }); await page.reload(); - await page.getByTestId('horizontal-link-Details').waitFor({ state: 'visible' }); + await expect(page.getByTestId('horizontal-link-Details')).toBeVisible(); // Verify not-recommended alert on main page const mainPageAlert = page.getByTestId('cv-not-recommended-alert'); diff --git a/frontend/e2e/tests/console/crd-extensions/console-link.spec.ts b/frontend/e2e/tests/console/crd-extensions/console-link.spec.ts index c831e5826cd..2ec27148c85 100644 --- a/frontend/e2e/tests/console/crd-extensions/console-link.spec.ts +++ b/frontend/e2e/tests/console/crd-extensions/console-link.spec.ts @@ -90,7 +90,7 @@ test.describe(`${crd} CRD`, { tag: ['@admin'] }, () => { // Wait for the list to load const instanceRow = page.getByRole('row', { name: new RegExp(name) }); - await instanceRow.waitFor({ state: 'visible', timeout: 10000 }); + await expect(instanceRow).toBeVisible({ timeout: 10000 }); // Click kebab menu and delete const kebabButton = instanceRow.getByTestId('kebab-button'); diff --git a/frontend/e2e/tests/console/crd-extensions/crd-test-utils.ts b/frontend/e2e/tests/console/crd-extensions/crd-test-utils.ts index 925cd2762f7..74494800836 100644 --- a/frontend/e2e/tests/console/crd-extensions/crd-test-utils.ts +++ b/frontend/e2e/tests/console/crd-extensions/crd-test-utils.ts @@ -11,11 +11,10 @@ export async function navigateToCRDInstances(page: Page, crd: string): Promise { - await page.getByRole('button', { name: 'Copy code to clipboard' }).waitFor(); + await expect(page.getByRole('button', { name: 'Copy code to clipboard' })).toBeVisible(); } /** diff --git a/frontend/e2e/tests/console/favorites/favorites.spec.ts b/frontend/e2e/tests/console/favorites/favorites.spec.ts index 7f1da804060..675fc198c3d 100644 --- a/frontend/e2e/tests/console/favorites/favorites.spec.ts +++ b/frontend/e2e/tests/console/favorites/favorites.spec.ts @@ -5,7 +5,6 @@ test.describe('Favorites', { tag: ['@admin'] }, () => { const sidebar = page.locator('#page-sidebar'); await page.goto('/'); - await page.getByTestId('favorite-button').first().waitFor({ state: 'visible' }); await test.step('Verify no favorites message when none are added', async () => { await sidebar.getByRole('button', { name: 'Favorites' }).click(); @@ -30,15 +29,11 @@ test.describe('Favorites', { tag: ['@admin'] }, () => { await test.step('Remove a favorite by clicking the favorite button again', async () => { await page.getByTestId('favorite-button').click(); - - await sidebar.getByRole('button', { name: 'Favorites' }).click(); await expect(page.getByTestId('no-favorites-message')).toBeVisible(); }); await test.step('Remove a favorite from the left navigation menu', async () => { await page.goto('/'); - await page.getByTestId('favorite-button').first().waitFor({ state: 'visible' }); - await page.getByTestId('favorite-button').click(); const dialog = page.getByRole('dialog'); await expect(dialog).toContainText('Add to favorites'); @@ -47,8 +42,6 @@ test.describe('Favorites', { tag: ['@admin'] }, () => { await expect(sidebar).toContainText('Overview'); await page.getByTestId('remove-favorite-button').click(); - - await sidebar.getByRole('button', { name: 'Favorites' }).click(); await expect(page.getByTestId('no-favorites-message')).toBeVisible(); }); @@ -68,7 +61,6 @@ test.describe('Favorites', { tag: ['@admin'] }, () => { for (let i = 0; i < pages.length; i++) { await page.goto(pages[i]); - await page.getByTestId('favorite-button').first().waitFor({ state: 'visible' }); await page.getByTestId('favorite-button').first().click(); const dialog = page.getByRole('dialog'); await expect(dialog).toContainText('Add to favorites'); @@ -80,8 +72,8 @@ test.describe('Favorites', { tag: ['@admin'] }, () => { } await page.goto('/k8s/all-namespaces/apps~v1~DaemonSet'); - await page.getByTestId('favorite-button').first().waitFor({ state: 'visible' }); await expect(page.getByTestId('favorite-button').first()).toBeDisabled(); }); + }); }); diff --git a/frontend/e2e/tests/console/i18n/pseudolocalization.spec.ts b/frontend/e2e/tests/console/i18n/pseudolocalization.spec.ts index 4974cc82576..92d5153de28 100644 --- a/frontend/e2e/tests/console/i18n/pseudolocalization.spec.ts +++ b/frontend/e2e/tests/console/i18n/pseudolocalization.spec.ts @@ -33,7 +33,7 @@ test.describe('Pseudolocalization', { tag: ['@admin'] }, () => { page, }) => { await page.goto(dashboardUrl); - await page.getByTestId('activity').first().waitFor({ state: 'visible' }); + await expect(page.getByTestId('activity').first()).toBeVisible(); await test.step('Verify masthead help menu is pseudolocalized', async () => { await page.getByTestId('help-dropdown-toggle').click(); diff --git a/frontend/e2e/tests/olm/create-namespace.spec.ts b/frontend/e2e/tests/olm/create-namespace.spec.ts index 70ecf2cf521..45263faca56 100644 --- a/frontend/e2e/tests/olm/create-namespace.spec.ts +++ b/frontend/e2e/tests/olm/create-namespace.spec.ts @@ -55,8 +55,6 @@ test.describe('Create namespace from install operators', { tag: ['@admin'] }, () test('creates namespace from operator install page', async ({ page }) => { await test.step('Navigate to catalog and open operator details', async () => { await page.goto('/catalog/ns/default?catalogType=operator'); - await page.getByPlaceholder('Filter by keyword...').waitFor({ state: 'visible' }); - await page.getByPlaceholder('Filter by keyword...').fill(operatorName); await page.getByTestId(`operator-${operatorName}`).click(); }); diff --git a/frontend/packages/console-app/console-extensions.json b/frontend/packages/console-app/console-extensions.json index a8f253a333d..cce3f048615 100644 --- a/frontend/packages/console-app/console-extensions.json +++ b/frontend/packages/console-app/console-extensions.json @@ -10,12 +10,6 @@ "importRedirectURL": { "$codeRef": "perspective.getImportRedirectURL" } } }, - { - "type": "console.flag", - "properties": { - "handler": { "$codeRef": "detectIntegrationTest.handler" } - } - }, { "type": "console.flag", "properties": { @@ -29,8 +23,7 @@ "tour": { "$codeRef": "getGuidedTour" } }, "flags": { - "required": ["CONSOLE_CAPABILITY_GUIDEDTOUR_IS_ENABLED"], - "disallowed": ["INTEGRATION_TEST"] + "required": ["CONSOLE_CAPABILITY_GUIDEDTOUR_IS_ENABLED"] } }, { diff --git a/frontend/packages/console-app/package.json b/frontend/packages/console-app/package.json index 695cd757df4..b94057e1b9c 100644 --- a/frontend/packages/console-app/package.json +++ b/frontend/packages/console-app/package.json @@ -26,7 +26,6 @@ }, "consolePlugin": { "exposedModules": { - "detectIntegrationTest": "src/features/integration-test.ts", "isOpenShift5": "src/features/openshift5.ts", "tourContext": "src/components/tour/tour-context.ts", "quickStartContext": "src/components/quick-starts/utils/quick-start-context.tsx", diff --git a/frontend/packages/console-app/src/components/tour/__tests__/tour-context.spec.ts b/frontend/packages/console-app/src/components/tour/__tests__/tour-context.spec.ts index 497f7f46fdc..0e7fe4a1a28 100644 --- a/frontend/packages/console-app/src/components/tour/__tests__/tour-context.spec.ts +++ b/frontend/packages/console-app/src/components/tour/__tests__/tour-context.spec.ts @@ -93,11 +93,7 @@ describe('guided-tour-context', () => { }); it('should return context values from the hook', () => { - useSelectorMock - .mockReturnValueOnce({ A: true, B: false }) - .mockReturnValueOnce(false) - .mockReturnValueOnce({ A: true, B: false }) - .mockReturnValueOnce(false); + useSelectorMock.mockReturnValue({ A: true, B: false }); useResolvedExtensionsMock.mockReturnValue(mockTourExtension); // Mock useUserPreference to return { completed: false } for the tour state useUserPreferenceMock.mockReturnValue([{ dev: { completed: false } }, () => null, true]); @@ -116,11 +112,7 @@ describe('guided-tour-context', () => { }); it('should return tour null from the hook', () => { - useSelectorMock - .mockReturnValueOnce({ A: true, B: false }) - .mockReturnValueOnce(false) - .mockReturnValueOnce({ A: true, B: false }) - .mockReturnValueOnce(false); + useSelectorMock.mockReturnValue({ A: true, B: false }); useResolvedExtensionsMock.mockReturnValue([[]]); useUserPreferenceMock.mockReturnValue([{ dev: { completed: false } }, () => null, true]); const { result } = renderHook(() => useTourValuesForContext()); @@ -130,12 +122,25 @@ describe('guided-tour-context', () => { expect(totalSteps).toEqual(undefined); }); + it('should not re-show tour when completed changes', () => { + useSelectorMock.mockReturnValue({ A: true, B: false }); + useResolvedExtensionsMock.mockReturnValue(mockTourExtension); + // Step 1: Mount with loaded: true, completed: false + useUserPreferenceMock.mockReturnValue([{ dev: { completed: false } }, () => null, true]); + const { result, rerender } = renderHook(() => useTourValuesForContext()); + expect(result.current.tourState.startTour).toBe(true); + expect(result.current.tourState.completedTour).toBe(false); + + // Step 2: now completed becomes true, loaded stays true + useUserPreferenceMock.mockReturnValue([{ dev: { completed: true } }, () => null, true]); + rerender(); + + expect(result.current.tourState.startTour).toBe(false); + expect(result.current.tourState.completedTour).toBe(true); + }); + it('should return null from the hook if tour is available but data isnot loaded', () => { - useSelectorMock - .mockReturnValueOnce({ A: true, B: false }) - .mockReturnValueOnce(false) - .mockReturnValueOnce({ A: true, B: false }) - .mockReturnValueOnce(false); + useSelectorMock.mockReturnValue({ A: true, B: false }); useResolvedExtensionsMock.mockReturnValue(mockTourExtension); // Mock useUserPreference with loaded: false useUserPreferenceMock.mockReturnValue([{ dev: { completed: false } }, () => null, false]); diff --git a/frontend/packages/console-app/src/components/tour/tour-context.ts b/frontend/packages/console-app/src/components/tour/tour-context.ts index 7326c91a6c8..36ce6ae0714 100644 --- a/frontend/packages/console-app/src/components/tour/tour-context.ts +++ b/frontend/packages/console-app/src/components/tour/tour-context.ts @@ -1,5 +1,5 @@ import type { Reducer, Dispatch, ReducerAction } from 'react'; -import { createContext, useReducer, useRef, useState, useEffect, useCallback } from 'react'; +import { createContext, useCallback, useEffect, useReducer, useRef, useState } from 'react'; import { pick, union, isEqual } from 'lodash'; import { createSelector } from 'reselect'; import { useActivePerspective } from '@console/dynamic-plugin-sdk'; @@ -128,20 +128,17 @@ const useTranslatedTourExtensions = () => { export const useTourValuesForContext = (): TourContextType => { const [activePerspective] = useActivePerspective(); - const [perspective, setPerspective] = useState(activePerspective); const tourExtension = useTranslatedTourExtensions(); - const tour = tourExtension.find(({ properties }) => properties.perspective === perspective); + const tour = tourExtension.find(({ properties }) => properties.perspective === activePerspective); const selectorSteps = tour?.properties?.tour?.steps ?? []; const flags = useConsoleSelector( (state) => getRequiredFlagsByTour(state, selectorSteps), isEqual, ); - const isIntegrationTest = useConsoleSelector( - (state: RootState) => getFlagsObject(state).INTEGRATION_TEST, - ); const [tourCompletionState, setTourCompletionState, loaded] = useTourStateForPerspective( activePerspective, ); + const isIntegrationTest = window.navigator.userAgent === 'ConsoleIntegrationTestEnvironment'; const completed = tourCompletionState?.completed || isIntegrationTest; const onComplete = () => { if (completed === false) { @@ -154,18 +151,22 @@ export const useTourValuesForContext = (): TourContextType => { startTour: !completed, }); - const initializedWithLoadedData = useRef(false); + const [initializedWithLoadedData, setInitializedWithLoadedData] = useState(false); + const prevPerspective = useRef(activePerspective); + if (prevPerspective.current !== activePerspective) { + prevPerspective.current = activePerspective; + if (initializedWithLoadedData) { + setInitializedWithLoadedData(false); + } + } useEffect(() => { - tourDispatch({ type: TourActions.initialize, payload: { completed } }); - setPerspective(activePerspective); if (loaded) { - initializedWithLoadedData.current = true; + tourDispatch({ type: TourActions.initialize, payload: { completed } }); + setInitializedWithLoadedData(true); } - // only run effect when the active perspective changes - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activePerspective, loaded]); + }, [activePerspective, completed, loaded]); - if (!tour || !loaded || !initializedWithLoadedData.current) return { tour: null }; + if (!tour || !loaded || !initializedWithLoadedData) return { tour: null }; const { properties: { tour: { intro, steps: unfilteredSteps, end }, diff --git a/frontend/packages/console-app/src/consts.ts b/frontend/packages/console-app/src/consts.ts index 7b9c3e50aa3..e1e61abe895 100644 --- a/frontend/packages/console-app/src/consts.ts +++ b/frontend/packages/console-app/src/consts.ts @@ -6,7 +6,6 @@ export const HIDE_USER_WORKLOAD_NOTIFICATIONS_USER_PREFERENCE_KEY = export const FLAG_DEVELOPER_PERSPECTIVE = 'DEVELOPER_PERSPECTIVE'; export const FLAG_CAN_GET_CONSOLE_OPERATOR_CONFIG = 'CAN_GET_CONSOLE_OPERATOR_CONFIG'; export const FLAG_TECH_PREVIEW = 'TECH_PREVIEW'; -export const FLAG_INTEGRATION_TEST = 'INTEGRATION_TEST'; export const FLAG_OPENSHIFT_5 = 'FLAG_OPENSHIFT_5'; export const ACM_PERSPECTIVE_ID = 'acm'; diff --git a/frontend/packages/console-app/src/features/integration-test.ts b/frontend/packages/console-app/src/features/integration-test.ts deleted file mode 100644 index e851f2e5b78..00000000000 --- a/frontend/packages/console-app/src/features/integration-test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { FeatureFlagHandler } from '@console/dynamic-plugin-sdk/src/extensions/feature-flags'; -import { FLAG_INTEGRATION_TEST } from '../consts'; - -const INTEGRATION_TEST_USER_AGENT = 'ConsoleIntegrationTestEnvironment'; - -/** - * Detect Cypress via a custom user agent we set in `cypress.config.js` in - * each cypress run configuration. - * - * This is used to disable certain features during integration tests to - * increase test reliability. - */ -export const handler: FeatureFlagHandler = (callback) => { - const userAgent = window.navigator.userAgent ?? ''; - - // No need to watch this as user agent cannot change during a session - callback(FLAG_INTEGRATION_TEST, userAgent === INTEGRATION_TEST_USER_AGENT); -}; diff --git a/frontend/packages/dev-console/console-extensions.json b/frontend/packages/dev-console/console-extensions.json index 7564f3d3d1d..a4376756714 100644 --- a/frontend/packages/dev-console/console-extensions.json +++ b/frontend/packages/dev-console/console-extensions.json @@ -29,8 +29,7 @@ "tour": { "$codeRef": "getGuidedTour" } }, "flags": { - "required": ["CONSOLE_CAPABILITY_GUIDEDTOUR_IS_ENABLED"], - "disallowed": ["INTEGRATION_TEST"] + "required": ["CONSOLE_CAPABILITY_GUIDEDTOUR_IS_ENABLED"] } },