Skip to content

Commit 8d9290f

Browse files
committed
Better retrying / add quiet mode and skip bundle
1 parent 484ee15 commit 8d9290f

8 files changed

Lines changed: 239 additions & 30 deletions

‎frontend/e2e/helpers.playwright.ts‎

Lines changed: 90 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import fetch from 'node-fetch';
44
import flagsmith from 'flagsmith/isomorphic';
55
import { IFlagsmith } from 'flagsmith/types';
66

7-
export const LONG_TIMEOUT = 15000;
7+
export const LONG_TIMEOUT = 20000;
88

99
// Browser debugging - console and network logging
1010
export const setupBrowserLogging = (page: Page) => {
@@ -97,7 +97,10 @@ export const setupBrowserLogging = (page: Page) => {
9797
url.includes('/usage-data/') ||
9898
url.includes('/invite-links/') ||
9999
url.includes('/roles/') ||
100-
url.includes('/change-requests/')) {
100+
url.includes('/change-requests/') ||
101+
url.includes('/api-keys/') ||
102+
url.includes('/metrics/') ||
103+
url.includes('/invites/')) {
101104
return;
102105
}
103106
}
@@ -214,11 +217,11 @@ export class E2EHelpers {
214217
}
215218
}
216219

217-
async waitForElementVisible(selector: string) {
220+
async waitForElementVisible(selector: string, timeout: number = LONG_TIMEOUT) {
218221
logUsingLastSection(`Waiting element visible ${selector}`);
219222
await this.page.locator(selector).first().waitFor({
220223
state: 'visible',
221-
timeout: LONG_TIMEOUT
224+
timeout
222225
});
223226
}
224227

@@ -249,9 +252,45 @@ export class E2EHelpers {
249252
await expect(this.page.locator(selector)).toHaveCount(0, { timeout: 10000 });
250253
}
251254

255+
async waitForNetworkIdle() {
256+
await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {
257+
// Silently continue if timeout - page might already be idle
258+
});
259+
}
260+
261+
async waitForDomContentLoaded() {
262+
await this.page.waitForLoadState('domcontentloaded', { timeout: 10000 }).catch(() => {
263+
// Silently continue if timeout - DOM might already be loaded
264+
});
265+
}
266+
267+
async waitForPageFullyLoaded() {
268+
await this.waitForDomContentLoaded();
269+
await this.waitForNetworkIdle();
270+
}
271+
272+
async getInputValue(selector: string): Promise<string> {
273+
const element = this.page.locator(selector).first();
274+
await element.waitFor({ state: 'visible', timeout: LONG_TIMEOUT });
275+
return await element.inputValue();
276+
}
277+
278+
async scrollBy(x: number, y: number) {
279+
await this.page.evaluate(({ x, y }) => {
280+
window.scrollBy(x, y);
281+
}, { x, y });
282+
}
283+
284+
async scrollToBottom() {
285+
await this.page.evaluate(() => {
286+
window.scrollTo(0, document.body.scrollHeight);
287+
});
288+
}
289+
252290
async gotoFeatures() {
253291
await this.click('#features-link');
254292
await this.waitForElementVisible('#show-create-feature-btn');
293+
await this.waitForPageFullyLoaded();
255294
}
256295

257296
async click(selector: string) {
@@ -449,11 +488,11 @@ export const setText = async (page: Page, selector: string, text: string) => {
449488
};
450489

451490
// Wait for an element to be visible
452-
export const waitForElementVisible = async (page: Page, selector: string) => {
491+
export const waitForElementVisible = async (page: Page, selector: string, timeout: number = LONG_TIMEOUT) => {
453492
logUsingLastSection(`Waiting element visible ${selector}`);
454493
await page.locator(selector).first().waitFor({
455494
state: 'visible',
456-
timeout: LONG_TIMEOUT
495+
timeout
457496
});
458497
};
459498

@@ -488,10 +527,52 @@ export const waitForElementNotExist = async (page: Page, selector: string) => {
488527
await expect(page.locator(selector)).toHaveCount(0, { timeout: 10000 });
489528
};
490529

530+
// Wait for network to be idle
531+
export const waitForNetworkIdle = async (page: Page) => {
532+
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {
533+
// Silently continue if timeout - page might already be idle
534+
});
535+
};
536+
537+
// Wait for DOM content to be loaded
538+
export const waitForDomContentLoaded = async (page: Page) => {
539+
await page.waitForLoadState('domcontentloaded', { timeout: 10000 }).catch(() => {
540+
// Silently continue if timeout - DOM might already be loaded
541+
});
542+
};
543+
544+
// Wait for page to be fully loaded (DOM + network)
545+
export const waitForPageFullyLoaded = async (page: Page) => {
546+
await waitForDomContentLoaded(page);
547+
await waitForNetworkIdle(page);
548+
};
549+
550+
// Get input value from an element
551+
export const getInputValue = async (page: Page, selector: string): Promise<string> => {
552+
const element = page.locator(selector).first();
553+
await element.waitFor({ state: 'visible', timeout: LONG_TIMEOUT });
554+
return await element.inputValue();
555+
};
556+
557+
// Scroll the page by x and y pixels
558+
export const scrollBy = async (page: Page, x: number, y: number) => {
559+
await page.evaluate(({ x, y }) => {
560+
window.scrollBy(x, y);
561+
}, { x, y });
562+
};
563+
564+
// Scroll to bottom of page
565+
export const scrollToBottom = async (page: Page) => {
566+
await page.evaluate(() => {
567+
window.scrollTo(0, document.body.scrollHeight);
568+
});
569+
};
570+
491571
// Navigate to features page
492572
export const gotoFeatures = async (page: Page) => {
493573
await click(page, '#features-link');
494574
await waitForElementVisible(page, '#show-create-feature-btn');
575+
await waitForPageFullyLoaded(page);
495576
};
496577

497578
// Click an element
@@ -869,9 +950,7 @@ export const createRemoteConfig = async (
869950
await page.waitForTimeout(100);
870951
}
871952
// Wait for form validation to complete after all variations added
872-
if (mvs.length > 0) {
873-
await page.waitForTimeout(500);
874-
}
953+
await page.waitForTimeout(500);
875954
await click(page, byId('create-feature-btn'));
876955
// Wait for the feature to be created and modal to close
877956
await page.waitForTimeout(2000); // Increased wait for API to process
@@ -989,6 +1068,7 @@ export const createFeature = async (
9891068
if (value) {
9901069
await click(page, byId('toggle-feature-button'));
9911070
}
1071+
await page.waitForTimeout(500);
9921072
await click(page, byId('create-feature-btn'));
9931073
// Wait for feature creation to complete
9941074
await page.waitForTimeout(1500);
@@ -1159,4 +1239,4 @@ export const setUserPermission = async (
11591239
await click(page, byId(`permission-switch-${permission}`));
11601240
}
11611241
await closeModal(page);
1162-
};
1242+
};

‎frontend/e2e/run-with-retry.ts‎

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { execSync } from 'child_process';
2+
import fetch from 'node-fetch';
3+
import Project from '../common/project';
4+
5+
require('dotenv').config();
6+
7+
const RETRIES = parseInt(process.env.E2E_RETRIES || '1', 10);
8+
9+
async function runTeardown() {
10+
console.log('\n\x1b[36m%s\x1b[0m\n', 'Running E2E teardown...');
11+
12+
const e2eTestApi = `${process.env.FLAGSMITH_API_URL || Project.api}e2etests/teardown/`;
13+
const token = process.env.E2E_TEST_TOKEN
14+
? process.env.E2E_TEST_TOKEN
15+
: process.env[`E2E_TEST_TOKEN_${Project.env.toUpperCase()}`];
16+
17+
if (!token) {
18+
console.error('\x1b[31m%s\x1b[0m\n', 'Error: No E2E_TEST_TOKEN found');
19+
return false;
20+
}
21+
22+
try {
23+
const res = await fetch(e2eTestApi, {
24+
body: JSON.stringify({}),
25+
headers: {
26+
'Accept': 'application/json',
27+
'Content-Type': 'application/json',
28+
'X-E2E-Test-Auth-Token': token.trim(),
29+
},
30+
method: 'POST',
31+
});
32+
33+
if (res.ok) {
34+
console.log('\x1b[32m%s\x1b[0m\n', '✓ E2E teardown successful');
35+
return true;
36+
} else {
37+
console.error('\x1b[31m%s\x1b[0m\n', `✗ E2E teardown failed: ${res.status}`);
38+
return false;
39+
}
40+
} catch (error) {
41+
console.error('\x1b[31m%s\x1b[0m\n', `✗ E2E teardown error: ${error}`);
42+
return false;
43+
}
44+
}
45+
46+
function runPlaywright(args: string[], quietMode: boolean): boolean {
47+
try {
48+
// Quote arguments that contain spaces
49+
const quotedArgs = args.map(arg => arg.includes(' ') ? `"${arg}"` : arg);
50+
const playwrightCmd = ['npx', 'cross-env', 'NODE_ENV=production', 'E2E=true', 'playwright', 'test', ...quotedArgs];
51+
if (!quietMode) console.log('Running:', playwrightCmd.join(' '));
52+
execSync(playwrightCmd.join(' '), {
53+
stdio: 'inherit',
54+
env: process.env,
55+
shell: true,
56+
});
57+
return true;
58+
} catch (error) {
59+
return false;
60+
}
61+
}
62+
63+
async function main() {
64+
let attempt = 0;
65+
66+
// Get additional args passed to the script (e.g., test file names, -g patterns)
67+
const extraArgs = process.argv.slice(2);
68+
const quietMode = process.env.QUIET === '1';
69+
70+
while (attempt <= RETRIES) {
71+
if (attempt > 0) {
72+
if (!quietMode) {
73+
console.log('\n==========================================');
74+
console.log(`Test attempt ${attempt} failed, running teardown and retrying failed tests only...`);
75+
console.log('==========================================\n');
76+
}
77+
await runTeardown();
78+
}
79+
80+
const playwrightArgs = attempt > 0 ? ['--last-failed', ...extraArgs] : extraArgs;
81+
82+
// Add --quiet flag if QUIET is set
83+
if (quietMode && !playwrightArgs.includes('--quiet')) {
84+
playwrightArgs.push('--quiet');
85+
}
86+
87+
// First attempt: build bundle and run tests
88+
if (attempt === 0 && !process.env.SKIP_BUNDLE) {
89+
if (!quietMode) console.log('Building test bundle...');
90+
try {
91+
execSync('npm run test:bundle', { stdio: quietMode ? 'ignore' : 'inherit' });
92+
} catch (error) {
93+
console.error('Failed to build test bundle');
94+
process.exit(1);
95+
}
96+
} else if (attempt === 0 && process.env.SKIP_BUNDLE) {
97+
if (!quietMode) console.log('Skipping bundle build (SKIP_BUNDLE=1)');
98+
}
99+
100+
if (!quietMode) console.log(attempt > 0 ? 'Running failed tests...' : 'Running all tests...');
101+
const success = runPlaywright(playwrightArgs, quietMode);
102+
103+
if (success) {
104+
if (!quietMode) {
105+
console.log('\n==========================================');
106+
console.log(attempt > 0
107+
? `Tests passed on attempt ${attempt} (after retrying failed tests)`
108+
: `Tests passed on attempt ${attempt}`);
109+
console.log('==========================================\n');
110+
}
111+
process.exit(0);
112+
}
113+
114+
attempt++;
115+
}
116+
117+
if (!quietMode) {
118+
console.log('\n==========================================');
119+
console.log(`Tests failed after ${RETRIES} retries`);
120+
console.log('==========================================\n');
121+
}
122+
process.exit(1);
123+
}
124+
125+
main();

‎frontend/e2e/tests/flag-tests.pw.ts‎

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ import {
1010
log,
1111
login,
1212
parseTryItResults,
13+
scrollBy,
1314
toggleFeature,
1415
waitForElementVisible,
16+
waitForNetworkIdle,
1517
createHelpers,
1618
} from '../helpers.playwright';
1719
import { E2E_USER, PASSWORD } from '../config';
@@ -35,15 +37,11 @@ test.describe('Flag Tests', () => {
3537

3638
log('Create Short Life Feature')
3739
await createFeature(page, 3, 'short_life_feature', false)
38-
await page.evaluate(() => {
39-
window.scrollBy(0, 15000)
40-
})
40+
await scrollBy(page, 0, 15000)
4141

4242
log('Delete Short Life Feature')
4343
await deleteFeature(page, 3, 'short_life_feature')
44-
await page.evaluate(() => {
45-
window.scrollBy(0, 30000)
46-
})
44+
await scrollBy(page, 0, 30000)
4745

4846
log('Toggle Feature')
4947
await toggleFeature(page, 0, true)
@@ -80,10 +78,8 @@ test.describe('Flag Tests', () => {
8078
log('Switch environment')
8179
// Navigate back to features list so environment switcher is visible in navbar
8280
await helpers.gotoFeatures()
83-
// Add a wait to ensure the page has fully loaded and environment switcher is ready
84-
await page.waitForTimeout(1500)
85-
await helpers.waitForElementVisible(byId('switch-environment-production'))
86-
// Additional wait to ensure element is interactive
81+
await waitForNetworkIdle(page)
82+
await waitForElementVisible(page, byId('switch-environment-production'))
8783
await page.waitForTimeout(500)
8884
await helpers.click(byId('switch-environment-production'))
8985

@@ -95,4 +91,4 @@ test.describe('Flag Tests', () => {
9591
await deleteFeature(page, 1, 'header_size')
9692
await deleteFeature(page, 0, 'header_enabled')
9793
});
98-
});
94+
});

‎frontend/e2e/tests/invite-test.pw.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { test } from '../test-setup';
2-
import { assertTextContent, byId, createHelpers, log } from '../helpers.playwright';
2+
import { assertTextContent, byId, createHelpers, getInputValue, log } from '../helpers.playwright';
33
import { E2E_CHANGE_MAIL, E2E_USER, PASSWORD } from '../config';
44

55
const invitePrefix = `flagsmith${new Date().valueOf()}`
@@ -14,9 +14,9 @@ test.describe('Invite Tests', () => {
1414
await helpers.click(byId('organisation-link'))
1515
await helpers.waitForElementVisible(byId('org-settings-link'))
1616
await helpers.click(byId('org-settings-link'))
17-
await page.locator(byId('organisation-name')).inputValue()
17+
await getInputValue(page, byId('organisation-name'))
1818
await helpers.click(byId('users-and-permissions'))
19-
const inviteLink = await page.locator(byId('invite-link')).inputValue()
19+
const inviteLink = await getInputValue(page, byId('invite-link'))
2020
log('Accept invite')
2121
await page.goto(inviteLink)
2222
await helpers.setText('[name="email"]', inviteEmail)

‎frontend/e2e/tests/organisation-test.pw.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,7 @@ test.describe('Organisation Tests', () => {
117117

118118
log('Test empty organisation name validation')
119119
await helpers.waitForElementVisible("[data-test='organisation-name']")
120-
const orgNameInput = page.locator("[data-test='organisation-name']")
121-
const originalName = await orgNameInput.inputValue()
120+
const originalName = await helpers.getInputValue("[data-test='organisation-name']")
122121

123122
log('Clear organisation name')
124123
await helpers.setText("[data-test='organisation-name']", '')

0 commit comments

Comments
 (0)