Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions frontend/.claude/context/e2e.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
277 changes: 276 additions & 1 deletion frontend/e2e/helpers/e2e-helpers.playwright.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading