diff --git a/frontend/.claude/context/e2e.md b/frontend/.claude/context/e2e.md index c9e3721c6925..aef1eb02df44 100644 --- a/frontend/.claude/context/e2e.md +++ b/frontend/.claude/context/e2e.md @@ -126,6 +126,32 @@ Tests that fail initially but pass on retry are FLAKY and MUST be investigated, - If the failure is in application code (not test code), report it as a bug but don't try to fix it - Always explain what fixes you're attempting and why +## CRITICAL: Use Helpers, Not Raw Page Methods + +**NEVER use `page.waitForTimeout()` or raw `page.locator()` methods.** Always use the helper functions instead: + +### Wait Helpers (Use These Instead of waitForTimeout) +- `waitForElementVisible(selector)` - Wait for element to be visible +- `waitForElementClickable(selector)` - Wait for element to be clickable +- `waitForToast()` - Wait for toast notification +- `waitAndRefresh()` - Wait and refresh page state +- `waitForFeatureSwitch(name, state)` - Wait for feature switch state +- `waitForUserFeatureSwitch(name, state)` - Wait for user feature switch state + +### Click Helpers (Use These Instead of page.locator().click()) +- `click(selector)` - Click element (handles wait, scroll, enabled check) +- `clickByText(text, element)` - Click element by text content +- `clickUserFeature(name)` - Click user feature +- `clickUserFeatureSwitch(name, state)` - Click user feature switch + +### Other Helpers +- `setText(selector, value)` - Set input text +- `closeModal()` - Close modal (instead of Escape key) +- `assertInputValue(selector, value)` - Assert input value +- `gotoFeatures()`, `gotoFeature(name)`, etc. - Navigation helpers + +**Why?** Helpers include proper waiting, error handling, and scrolling. Raw page methods lead to flaky tests. + ## Test Infrastructure ### Playwright Configuration diff --git a/frontend/e2e/helpers/e2e-helpers.playwright.ts b/frontend/e2e/helpers/e2e-helpers.playwright.ts index 0c0b604666d7..f28a57619ddf 100644 --- a/frontend/e2e/helpers/e2e-helpers.playwright.ts +++ b/frontend/e2e/helpers/e2e-helpers.playwright.ts @@ -110,7 +110,7 @@ export class E2EHelpers { async clickByText(text: string, element: string = 'button') { logUsingLastSection(`Click by text ${text} ${element}`); - const selector = this.page.locator(element).filter({ hasText: text }); + const selector = this.page.locator(element).filter({ hasText: text }).first(); await selector.scrollIntoViewIfNeeded(); await expect(selector).toBeEnabled({ timeout: 5000 }); await selector.hover(); @@ -816,6 +816,281 @@ export class E2EHelpers { } await this.closeModal(); } + + // Create a tag + async createTag(label: string, color: string = '#FF6B6B') { + logUsingLastSection(`Creating tag: ${label}`); + // Open a feature modal to access tag creation + await this.click('#show-create-feature-btn'); + await this.waitForElementVisible('#create-feature-modal'); + + // Click the "Add Tag" button to open tag interface + const addTagButton = this.page.locator('button').filter({ hasText: 'Add Tag' }); + await addTagButton.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + await addTagButton.scrollIntoViewIfNeeded(); + await addTagButton.click(); + + // Wait for either the create tag modal or the "Add New Tag" button + const addNewTagButton = this.page.locator('button').filter({ hasText: 'Add New Tag' }); + const tagLabelInput = this.page.locator(byId('tag-label')); + + // Wait for one of them to appear + await Promise.race([ + addNewTagButton.waitFor({ state: 'visible', timeout: 3000 }).catch(() => {}), + tagLabelInput.waitFor({ state: 'visible', timeout: 3000 }).catch(() => {}) + ]); + + // If "Add New Tag" button is visible, click it + const hasAddNewTagButton = await addNewTagButton.isVisible().catch(() => false); + if (hasAddNewTagButton) { + await addNewTagButton.click(); + } + + // Fill in tag details + await this.setText(byId('tag-label'), label); + await this.page.waitForTimeout(300); + + // Click the first available color + const firstColor = this.page.locator('.tag--select').first(); + await firstColor.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + await firstColor.click(); + await this.page.waitForTimeout(300); + + // Save the tag + const saveButton = this.page.locator('button').filter({ hasText: 'Save Tag' }); + await saveButton.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + await expect(saveButton).toBeEnabled({ timeout: LONG_TIMEOUT }); + await saveButton.click(); + await this.page.waitForTimeout(1000); + + // Close the modals by clicking outside + await this.closeModal(); + } + + // Add a tag to a feature (must be called when feature modal is open) + async addTagToFeature(tagLabel: string) { + logUsingLastSection(`Adding tag to feature: ${tagLabel}`); + + // Wait for feature modal to be visible + await this.waitForElementVisible('#create-feature-modal'); + + // Navigate to Settings tab + const settingsTab = this.page.locator('[data-test="settings"]'); + const isSettingsVisible = await settingsTab.isVisible().catch(() => false); + if (isSettingsVisible) { + await settingsTab.click(); + await this.page.waitForTimeout(500); + } + + // Click the "Add Tag" button to open tag selection + const addTagButton = this.page.locator('button').filter({ hasText: 'Add Tag' }); + await addTagButton.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + await addTagButton.scrollIntoViewIfNeeded(); + await addTagButton.click(); + + // Wait for tag list to appear + const tagList = this.page.locator('.tag-list'); + await tagList.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + + // Find and click the tag using JavaScript to bypass visibility checks + await this.page.evaluate((label) => { + const tagList = document.querySelector('.tag-list'); + if (!tagList) return false; + + // Find the element containing the tag text + const elements = Array.from(tagList.querySelectorAll('*')); + const tagElement = elements.find(el => + el.textContent?.trim() === label || el.textContent?.includes(label) + ); + + if (tagElement) { + // Scroll it into view within the container + tagElement.scrollIntoView({ block: 'center', behavior: 'auto' }); + + // Find the clickable parent (usually has cursor:pointer or is a checkbox) + let clickable = tagElement; + let current = tagElement; + while (current && current !== tagList) { + const style = window.getComputedStyle(current); + if (style.cursor === 'pointer' || current.tagName === 'INPUT') { + clickable = current; + break; + } + current = current.parentElement; + } + + // Click it + clickable.click(); + return true; + } + return false; + }, tagLabel); + } + + // Archive a feature (must be called when feature modal is open) + async archiveFeature() { + logUsingLastSection('Archiving feature'); + + // Wait for feature modal to be visible + await this.waitForElementVisible('#create-feature-modal'); + + // Navigate to Settings tab if not already there + const settingsTab = this.page.locator('[data-test="settings"]'); + const isVisible = await settingsTab.isVisible().catch(() => false); + if (isVisible) { + await settingsTab.click(); + await this.page.waitForTimeout(500); + } + + // Find the switch button with role="switch" near the "Archived" text + const archiveSwitch = this.page.locator('button[role="switch"]').filter({ + has: this.page.locator('text=/Archived/i') + }).or( + this.page.locator('.setting').filter({ hasText: /Archived/i }).locator('button[role="switch"]') + ).first(); + + await archiveSwitch.scrollIntoViewIfNeeded(); + await archiveSwitch.click(); + + // Save the feature settings - use the visible Update button + const updateButton = this.page.locator(byId('update-feature-btn')).filter({ hasText: 'Update Settings' }); + await updateButton.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + await updateButton.click(); + } + + // Navigate to a project by name + // Navigate to change requests page + async gotoChangeRequests() { + log('Navigate to change requests'); + await this.click('#change-requests-link'); + } + + // Create a change request from feature modal + async createChangeRequest(title: string, description: string) { + log(`Create change request: ${title}`); + + // Click the update/create change request button + // When 4-eyes is enabled, this button says "Create Change Request" + await this.click('#update-feature-btn'); + await this.page.waitForTimeout(1000); + + // Fill in title using placeholder + const titleField = this.page.locator('input[placeholder="My Change Request"]'); + await titleField.waitFor({ state: 'visible' }); + await titleField.fill(title); + + // Fill in description using placeholder + const descField = this.page.locator('textarea[placeholder="Add an optional description..."]'); + await descField.fill(description); + + // The date picker needs to be set - click on it to trigger current date/time + // Find the date input and click it + const dateInput = this.page.locator('.react-datepicker__input-container input').first(); + await dateInput.click(); + await this.page.waitForTimeout(300); + + // Click "Now" or today's date to set it + // The datepicker should appear - click on today + const todayButton = this.page.locator('.react-datepicker__today-button, .react-datepicker__day--today').first(); + await todayButton.click(); + await this.page.waitForTimeout(500); + + // Click create/save button - look for enabled button + const saveButton = this.page.locator('button').filter({ hasText: /Save|Create/ }).filter({ hasNotText: 'Cancel' }).last(); + await saveButton.waitFor({ state: 'visible' }); + + // Wait for button to be enabled + await expect(saveButton).toBeEnabled({ timeout: 5000 }); + await saveButton.click(); + + await this.waitForToast(); + } + + // Open a change request from the list + async openChangeRequest(index: number = 0) { + log(`Open change request at index ${index}`); + await this.waitForElementVisible('.list-item.clickable'); + const changeRequestItem = this.page.locator('.list-item.clickable').nth(index); + await changeRequestItem.click(); + } + + // Approve a change request + async approveChangeRequest() { + log('Approve change request'); + await this.click(byId('approve-change-request-btn')); + // Wait for button state to change to verify approval + await this.page.locator('button:has-text("Approved")').waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + } + + // Publish a change request + async publishChangeRequest() { + log('Publish change request'); + await this.click(byId('publish-change-request-btn')); + await this.click('#confirm-btn-yes'); // Confirm publish + // Wait for "Committed at" text to appear to verify publish succeeded + await this.page.locator('text=/Committed at/').waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + } + + // Enable change requests for an environment + async enableChangeRequests(minimumApprovals: number = 1) { + log(`Enable change requests with ${minimumApprovals} approval(s) for environment`); + + // Navigate to environment settings + await this.click('#env-settings-link'); + await this.page.waitForTimeout(500); + + // Wait for the settings page to load + await this.waitForElementVisible('h5:has-text("Feature Change Requests")'); + + // Get all visible switches - Feature Change Requests should be the last visible one + const allSwitches = this.page.locator('button[role="switch"]:visible'); + const switchCount = await allSwitches.count(); + log(`Found ${switchCount} visible switches on page`); + + const changeRequestToggle = allSwitches.last(); + + // Check if it's already on by checking aria-checked attribute + const isChecked = await changeRequestToggle.getAttribute('aria-checked'); + log(`Change request toggle aria-checked: "${isChecked}"`); + + // Click if it's off + if (isChecked !== 'true') { + log('Clicking change request toggle to turn ON'); + await changeRequestToggle.scrollIntoViewIfNeeded(); + await changeRequestToggle.click({ force: true }); + await this.page.waitForTimeout(2000); + + // Log new state + const newChecked = await changeRequestToggle.getAttribute('aria-checked'); + log(`After click, aria-checked: "${newChecked}"`); + } else { + log('Toggle already ON, skipping click'); + } + + // Set minimum approvals - the input appears after toggling on + const approvalInput = this.page.locator('input[placeholder="Minimum number of approvals"]'); + await approvalInput.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + await approvalInput.fill(minimumApprovals.toString()); + + // Save environment settings + await this.click('#save-env-btn'); + await this.waitForToast(); + await this.page.waitForTimeout(1000); + } + + // Verify change request count + async assertChangeRequestCount(count: number) { + log(`Assert change request count: ${count}`); + if (count === 0) { + await this.page.waitForTimeout(1000); + const changeRequests = this.page.locator('.change-request-item'); + await expect(changeRequests).toHaveCount(0); + } else { + await this.waitForElementVisible('.change-request-item'); + const changeRequests = this.page.locator('.change-request-item'); + await expect(changeRequests).toHaveCount(count); + } + } } // Export a factory function to create helpers for a page diff --git a/frontend/e2e/tests/change-request-test.pw.ts b/frontend/e2e/tests/change-request-test.pw.ts new file mode 100644 index 000000000000..562e0a8d1407 --- /dev/null +++ b/frontend/e2e/tests/change-request-test.pw.ts @@ -0,0 +1,143 @@ +import { test, expect } from '../test-setup' +import { byId, getFlagsmith, log, createHelpers } from '../helpers' +import { + E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, + E2E_TEST_PROJECT, + E2E_USER, + PASSWORD, +} from '../config' + +test.describe('Change Request Tests', () => { + test('Change requests can be created, approved, and published with 4-eyes approval @enterprise', async ({ + page, + }) => { + const { + assertChangeRequestCount, + approveChangeRequest, + assertInputValue, + closeModal, + createChangeRequest, + createEnvironment, + createRemoteConfig, + enableChangeRequests, + gotoChangeRequests, + gotoFeature, + gotoFeatures, + gotoProject, + login, + logout, + openChangeRequest, + parseTryItResults, + publishChangeRequest, + setText, + setUserPermission, + waitForElementVisible, + } = createHelpers(page) + + const flagsmith = await getFlagsmith() + const hasChangeRequests = flagsmith.hasFeature('segment_change_requests') + + if (!hasChangeRequests) { + log('Skipping change request test, feature not enabled.') + test.skip() + return + } + + const projectName = E2E_TEST_PROJECT + const environmentName = 'CR_Test_Env' + const featureName = 'cr_test_feature' + + log('Login as admin') + await login(E2E_USER, PASSWORD) + + log('Navigate to test project') + await gotoProject(projectName) + + log('Create test environment') + await page.click('text="Create Environment"') + await createEnvironment(environmentName) + + log('Enable change requests for test environment') + await enableChangeRequests(1) + + log('Create initial feature') + await createRemoteConfig({ + name: featureName, + value: 'initial_value', + }) + + log('Verify initial value via API') + await page.click('#try-it-btn') + let json = await parseTryItResults() + expect(json[featureName].value).toBe('initial_value') + + log('Create change request by editing feature value') + await gotoFeatures() + await gotoFeature(featureName) + await setText(byId('featureValue'), 'updated_value') + + await createChangeRequest( + 'Update feature value', + 'Updating value from initial_value to updated_value', + ) + + log('Verify change request was created') + await closeModal() + + log('Verify value has NOT changed yet (change request not approved)') + await page.click('#try-it-btn') + json = await parseTryItResults() + expect(json[featureName].value).toBe('initial_value') // Still old value + + log('Grant approver project ADMIN permission') + await page.click('a:has-text("Bullet Train Ltd")') + await setUserPermission( + E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, + 'ADMIN', + projectName, + 'project' + ) + + log('Logout and login as approver') + await logout() + await login(E2E_NON_ADMIN_USER_WITH_PROJECT_PERMISSIONS, PASSWORD) + + log('Navigate to project') + await gotoProject(projectName) + + log(`Switch to ${environmentName} environment`) + await page.click(`text="${environmentName}"`) + + log('Go to change requests') + await gotoChangeRequests() + + log('Open change request') + await openChangeRequest(0) + + log('Approve change request') + await approveChangeRequest() + + log('Publish change request') + await publishChangeRequest() + + log('Close modal') + await closeModal() + + log('Verify value has NOW changed (change request published)') + await gotoFeatures() + await gotoFeature(featureName) + await assertInputValue(byId('featureValue'), 'updated_value') + await closeModal() + + log('Verify value via API') + await page.click('#try-it-btn') + json = await parseTryItResults() + expect(json[featureName].value).toBe('updated_value') + + log('Verify change request is no longer in list') + await gotoChangeRequests() + await assertChangeRequestCount(0) + + log('Change request test completed successfully') + }) +}) diff --git a/frontend/e2e/tests/flag-tests.pw.ts b/frontend/e2e/tests/flag-tests.pw.ts index 370d5cdd1fc4..79e6fb481dee 100644 --- a/frontend/e2e/tests/flag-tests.pw.ts +++ b/frontend/e2e/tests/flag-tests.pw.ts @@ -111,4 +111,86 @@ test.describe('Flag Tests', () => { await deleteFeature('header_size') await deleteFeature('header_enabled') }); + + test('Feature flags can have tags added and be archived @oss', async ({ page }) => { + const { + addTagToFeature, + archiveFeature, + click, + clickByText, + closeModal, + createFeature, + createTag, + deleteFeature, + gotoFeature, + gotoFeatures, + gotoProject, + login, + waitForToast, + } = createHelpers(page); + + log('Login') + await login(E2E_USER, PASSWORD) + await gotoProject(E2E_TEST_PROJECT) + + log('Create Tags') + // Navigate to features first to ensure we're in the right context + await gotoFeatures() + + // Create first tag + await createTag('bug', '#FF6B6B') + + // Create second tag + await createTag('feature-request', '#4ECDC4') + + log('Create Feature with Settings') + await createFeature({ name: 'test_flag_with_tags', value: true, description: 'Test flag for tag and archive operations' }) + + log('Create additional feature to keep filters visible') + await createFeature({ name: 'keep_filters_visible', value: false }) + + log('Open Feature and Add Tags') + await gotoFeature('test_flag_with_tags') + await addTagToFeature('bug') + await addTagToFeature('feature-request') + + // Save the feature settings + await clickByText('Update Settings'); + await closeModal() + + log('Archive Feature') + await gotoFeature('test_flag_with_tags') + await archiveFeature() + await waitForToast() + + log('Verify Archive') + await closeModal() + + // Verify the feature can be filtered as archived + await gotoFeatures() + + // Verify archived feature is not visible by default + const archivedFeatureHidden = await page.locator('[data-test^="feature-item-"]').filter({ + has: page.locator(`span:text-is("test_flag_with_tags")`) + }).count() + expect(archivedFeatureHidden).toBe(0) + + log('Enable archived filter') + // Click on Tags filter button + await click(byId('table-filter-tags')) + + // Click on archived filter option + await clickByText(/^archived/, '.table-filter-item') + + // Close the filter dropdown + await click(byId('table-filter-tags')) + + log('Verify archived feature is now visible') + // Wait for the features list to update and verify archived feature appears + const archivedFeature = page.locator('[data-test^="feature-item-"]').filter({ + has: page.locator(`span:text-is("test_flag_with_tags")`) + }).first() + await archivedFeature.waitFor({ state: 'visible', timeout: 5000 }) + + }); }); diff --git a/frontend/e2e/tests/project-test.pw.ts b/frontend/e2e/tests/project-test.pw.ts index 7f9e177218ae..fe33570f947a 100644 --- a/frontend/e2e/tests/project-test.pw.ts +++ b/frontend/e2e/tests/project-test.pw.ts @@ -12,6 +12,7 @@ test.describe('Project Tests', () => { setText, waitForElementNotExist, waitForElementVisible, + waitForToast, } = createHelpers(page); const flagsmith = await getFlagsmith() const hasSegmentChangeRequests = flagsmith.hasFeature('segment_change_requests') @@ -47,8 +48,7 @@ test.describe('Project Tests', () => { log('Test 2: Change minimum approvals to 3 (manual save)') await setText('[name="env-name"]', '3') await click('#save-env-btn') - // Wait for save to complete - await page.waitForTimeout(1000) + await waitForToast() log('Verify value 3 persisted after navigation') await click('#features-link') await click('#project-settings-link') diff --git a/frontend/e2e/tests/segment-test.pw.ts b/frontend/e2e/tests/segment-test.pw.ts index 1ccb852db67b..0d78415c991c 100644 --- a/frontend/e2e/tests/segment-test.pw.ts +++ b/frontend/e2e/tests/segment-test.pw.ts @@ -280,9 +280,11 @@ test('Segment test 3 - Test user-specific feature overrides @oss', async ({ page click, clickUserFeature, clickUserFeatureSwitch, + closeModal, createFeature, createRemoteConfig, deleteFeature, + gotoFeature, gotoFeatures, gotoProject, goToUser, @@ -317,7 +319,27 @@ test('Segment test 3 - Test user-specific feature overrides @oss', async ({ page await waitAndRefresh() // wait and refresh to avoid issues with data sync from UK -> US in github workflows await assertUserFeatureValue(REMOTE_CONFIG_FEATURE, '"small"') + log('Verify identity override appears in feature modal') + await gotoFeatures() + await gotoFeature(REMOTE_CONFIG_FEATURE) + await click('[data-test="identity_overrides"]') + await page.waitForTimeout(1000) // Wait for identity overrides to load + + // Check that the test identity appears in the list + const identityRow = page.locator('[id="users-list"]').locator('.list-item').filter({ + hasText: E2E_TEST_IDENTITY + }) + await expect(identityRow).toBeVisible() + + // Check that the override value is displayed correctly + const valueInList = identityRow.locator('.table-column').filter({ hasText: 'small' }) + await expect(valueInList).toBeVisible() + + log('Close modal') + await closeModal() + log('Toggle flag for user again') + await goToUser(E2E_TEST_IDENTITY) await clickUserFeatureSwitch(FLAG_FEATURE, 'off'); await click('#confirm-toggle-feature-btn'); await waitAndRefresh(); // wait and refresh to avoid issues with data sync from UK -> US in github workflows diff --git a/frontend/e2e/tests/versioning-tests.pw.ts b/frontend/e2e/tests/versioning-tests.pw.ts index 1aa2fe056f92..7f8ee499c900 100644 --- a/frontend/e2e/tests/versioning-tests.pw.ts +++ b/frontend/e2e/tests/versioning-tests.pw.ts @@ -89,9 +89,7 @@ test('Versioning tests - Create, edit, and compare feature versions @oss', async // Verify: API returns correct state (feature disabled) log('Verify API returns disabled state') - await page.waitForTimeout(500) await click('#try-it-btn') - await page.waitForTimeout(500) let json = await parseTryItResults() expect(json.c.enabled).toBe(false) @@ -121,8 +119,6 @@ test('Versioning tests - Create, edit, and compare feature versions @oss', async await click('#try-it-btn') await responsePromise - // Additional wait for UI to update with results - await page.waitForTimeout(1000) json = await parseTryItResults() expect(json.c.enabled).toBe(true) diff --git a/frontend/web/components/navigation/navbars/EnvironmentNavbar.tsx b/frontend/web/components/navigation/navbars/EnvironmentNavbar.tsx index 79768603f6c8..505ccb73327f 100644 --- a/frontend/web/components/navigation/navbars/EnvironmentNavbar.tsx +++ b/frontend/web/components/navigation/navbars/EnvironmentNavbar.tsx @@ -79,7 +79,7 @@ const EnvironmentNavbar: FC = ({ Features diff --git a/frontend/web/components/pages/ChangeRequestDetailPage.tsx b/frontend/web/components/pages/ChangeRequestDetailPage.tsx index 69510ced5971..90a90f76075c 100644 --- a/frontend/web/components/pages/ChangeRequestDetailPage.tsx +++ b/frontend/web/components/pages/ChangeRequestDetailPage.tsx @@ -700,6 +700,7 @@ export const ChangeRequestPageInner: FC = ({ 'Approve Change Requests', ),