From 80b74017a79ec0a9cc6a6a4bee69d69cb6faff98 Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Thu, 16 Jul 2026 11:27:35 +1000 Subject: [PATCH 1/9] test: remove blind sleeps and dead skips; make retry counts overridable --- packages/api/jest.setup.ts | 4 +- packages/app/package.json | 1 - packages/app/playwright.config.ts | 9 +- packages/app/src/__tests__/timeQuery.test.tsx | 360 ------------------ packages/app/src/fixtures.ts | 53 --- packages/app/src/timeQuery.ts | 6 - .../e2e/components/TimePickerComponent.ts | 15 +- .../app/tests/e2e/core/navigation.spec.ts | 28 -- .../app/tests/e2e/features/dashboard.spec.ts | 8 +- .../app/tests/e2e/features/kubernetes.spec.ts | 4 +- yarn.lock | 8 - 11 files changed, 26 insertions(+), 470 deletions(-) delete mode 100644 packages/app/src/__tests__/timeQuery.test.tsx delete mode 100644 packages/app/src/fixtures.ts diff --git a/packages/api/jest.setup.ts b/packages/api/jest.setup.ts index 05bb790c6a..3a8f717a2f 100644 --- a/packages/api/jest.setup.ts +++ b/packages/api/jest.setup.ts @@ -1,4 +1,6 @@ -jest.retryTimes(1, { logErrorsBeforeRetry: true }); +jest.retryTimes(Number(process.env.JEST_RETRY_TIMES ?? '1'), { + logErrorsBeforeRetry: true, +}); // http-proxy-middleware v4 is ESM-only and Jest's CJS module loader cannot // load ESM packages. Auto-mock since no test exercises the proxy directly. diff --git a/packages/app/package.json b/packages/app/package.json index e5b30daa15..dd25147df5 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -104,7 +104,6 @@ "@chromatic-com/storybook": "^4.1.3", "@eslint-react/eslint-plugin": "^3.0.0", "@eslint/compat": "^2.0.0", - "@jedmao/location": "^3.0.0", "@next/eslint-plugin-next": "^16.0.10", "@playwright/test": "^1.57.0", "@storybook/addon-docs": "^10.1.4", diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts index d03d278af9..2554061831 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -30,8 +30,13 @@ export default defineConfig({ fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, - /* Retry on CI only */ - retries: process.env.CI ? 2 : 1, + /* Retry on CI only. Overridable via E2E_RETRIES (e.g. the nightly zero-retry flake hunt). */ + retries: + process.env.E2E_RETRIES !== undefined + ? Number(process.env.E2E_RETRIES) + : process.env.CI + ? 2 + : 1, /* Use multiple workers on CI for faster execution */ workers: process.env.CI ? 2 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ diff --git a/packages/app/src/__tests__/timeQuery.test.tsx b/packages/app/src/__tests__/timeQuery.test.tsx deleted file mode 100644 index 0ed10c254b..0000000000 --- a/packages/app/src/__tests__/timeQuery.test.tsx +++ /dev/null @@ -1,360 +0,0 @@ -/* eslint-disable @eslint-react/no-create-ref */ -import * as React from 'react'; -import { useImperativeHandle } from 'react'; -import { useRouter } from 'next/router'; -import { LocationMock } from '@jedmao/location'; -import { render } from '@testing-library/react'; - -import { TestRouter } from '@/fixtures'; -import { - getLiveTailTimeRange, - useNewTimeQuery, - type UseTimeQueryInputType, - type UseTimeQueryReturnType, -} from '@/timeQuery'; -import { useUserPreferences } from '@/useUserPreferences'; - -// Setting a fixed time of 10/03/23 at 12pm EDT -const INITIAL_DATE_STRING = - 'Tue Oct 03 2023 12:00:00 GMT-0400 (Eastern Daylight Time)'; - -jest.mock('next/router', () => ({ - useRouter: jest.fn(), -})); - -function TestWrapper({ - children, - isUTC, -}: { - children: React.ReactNode; - isUTC?: boolean; -}) { - const { setUserPreference } = useUserPreferences(); - - React.useEffect(() => { - setUserPreference({ isUTC }); - }, [setUserPreference, isUTC]); - return <>{children}; -} - -const TestComponent = React.forwardRef(function Component( - timeQueryInput: UseTimeQueryInputType, - ref: React.Ref, -) { - const timeQueryVal = useNewTimeQuery(timeQueryInput); - - useImperativeHandle(ref, () => timeQueryVal); - - return null; -}); - -const { location: savedLocation } = window; - -// TODO: Issues with testing nuqs :( -// https://github.com/47ng/nuqs/issues/259 -describe.skip('useTimeQuery tests', () => { - let testRouter: TestRouter; - let locationMock: LocationMock; - - beforeAll(() => { - // @ts-ignore - This complains because we can only delete optional operands - delete window.location; - }); - - beforeEach(() => { - jest.resetAllMocks(); - locationMock = new LocationMock('https://www.hyperdx.io/'); - testRouter = new TestRouter(locationMock); - // @ts-ignore - this is a mock - window.location = locationMock; - - (useRouter as jest.Mock).mockReturnValue(testRouter); - - jest.useFakeTimers().setSystemTime(new Date(INITIAL_DATE_STRING)); - }); - - afterAll(() => { - // @ts-ignore - this is a mock - window.location = savedLocation; - }); - - it('initializes successfully to a non-UTC time', async () => { - const timeQueryRef = React.createRef(); - - render( - - - , - ); - - // The live tail time range is 15 mins - expect(timeQueryRef.current?.displayedTimeInputValue).toMatchInlineSnapshot( - `"Oct 3 11:45:00 - Oct 3 12:00:00"`, - ); - }); - - it('initializes successfully to a UTC time', async () => { - const timeQueryRef = React.createRef(); - - render( - - - , - ); - - // The live tail time range is 15 mins - expect(timeQueryRef.current?.displayedTimeInputValue).toMatchInlineSnapshot( - `"Oct 3 15:45:00 - Oct 3 16:00:00"`, - ); - }); - - it('can be overridden by `tq` url param', async () => { - const timeQueryRef = React.createRef(); - testRouter.replace('/search?tq=Last+4H'); - - const { rerender } = render( - - - , - ); - jest.runAllTimers(); - - rerender( - - - , - ); - jest.runAllTimers(); - - // Once the hook runs, it will unset the `tq` param and replace it with - // a `from` and `to` - expect(locationMock.searchParams.get('tq')).toBeNull(); - // `From` should be 10/03/23 at 8:00am EDT - expect(locationMock.searchParams.get('from')).toBe('1696334400000'); - // `To` should be 10/03/23 at 12:00pm EDT - expect(locationMock.searchParams.get('to')).toBe('1696348800000'); - expect(timeQueryRef.current?.displayedTimeInputValue).toMatchInlineSnapshot( - `"Oct 3 08:00:00 - Oct 3 12:00:00"`, - ); - }); - - it('browser navigation of from/to qparmas updates the searched time range', async () => { - const timeQueryRef = React.createRef(); - testRouter.setIsReady(false); - testRouter.replace('/search'); - - const result = render( - - - , - ); - jest.runAllTimers(); - - testRouter.setIsReady(true); - - result.rerender( - - - , - ); - - expect(timeQueryRef.current?.displayedTimeInputValue).toMatchInlineSnapshot( - `"Past 1h"`, - ); - expect(timeQueryRef.current?.searchedTimeRange).toMatchInlineSnapshot(` - Array [ - 2023-10-03T15:45:00.000Z, - 2023-10-03T16:00:00.000Z, - ] - `); - - // 10/03/23 from 04:00am EDT to 08:00am EDT - testRouter.replace('/search?from=1696320000000&to=1696334400000'); - - result.rerender( - - - , - ); - - result.rerender( - - - , - ); - - expect(timeQueryRef.current?.displayedTimeInputValue).toMatchInlineSnapshot( - `"Oct 3 04:00:00 - Oct 3 08:00:00"`, - ); - expect(timeQueryRef.current?.searchedTimeRange).toMatchInlineSnapshot(` - Array [ - 2023-10-03T08:00:00.000Z, - 2023-10-03T12:00:00.000Z, - ] - `); - }); - - it('overrides initial value with async updated `from` and `to` params', async () => { - const timeQueryRef = React.createRef(); - // 10/03/23 from 04:00am EDT to 08:00am EDT - testRouter.setIsReady(false); - testRouter.replace('/search'); - - const result = render( - - - , - ); - jest.runAllTimers(); - - testRouter.replace('/search?from=1696320000000&to=1696334400000'); - testRouter.setIsReady(true); - - result.rerender( - - - , - ); - - expect(timeQueryRef.current?.displayedTimeInputValue).toMatchInlineSnapshot( - `"Oct 3 04:00:00 - Oct 3 08:00:00"`, - ); - expect(timeQueryRef.current?.searchedTimeRange).toMatchInlineSnapshot(` - Array [ - 2023-10-03T08:00:00.000Z, - 2023-10-03T12:00:00.000Z, - ] - `); - }); - - it('accepts `from` and `to` url params', async () => { - const timeQueryRef = React.createRef(); - // 10/03/23 from 04:00am EDT to 08:00am EDT - testRouter.replace('/search?from=1696320000000&to=1696334400000'); - - render( - - - , - ); - jest.runAllTimers(); - - expect(timeQueryRef.current?.displayedTimeInputValue).toMatchInlineSnapshot( - `"Oct 3 04:00:00 - Oct 3 08:00:00"`, - ); - }); - - it('handles bad input in `from` and `to` url params', async () => { - const timeQueryRef = React.createRef(); - testRouter.replace('/search?from=abc&to=def'); - - render( - - - , - ); - jest.runAllTimers(); - - // Should initialize to the initial time range 11:45am - 12:00pm - expect(timeQueryRef.current?.displayedTimeInputValue).toMatchInlineSnapshot( - `"Oct 3 11:45:00 - Oct 3 12:00:00"`, - ); - }); - - it('prefers `tq` param over `from` and `to` params', async () => { - const timeQueryRef = React.createRef(); - // 10/03/23 from 04:00am EDT to 08:00am EDT, tq says last 1 hour - testRouter.replace( - '/search?from=1696320000000&to=1696334400000&tq=Past+1h', - ); - - const result = render( - - - , - ); - jest.runAllTimers(); - - result.rerender( - - - , - ); - jest.runAllTimers(); - - // The time range should be the last 1 hour even though the `from` and `to` - // params are passed in. - expect(timeQueryRef.current?.displayedTimeInputValue).toMatchInlineSnapshot( - `"Oct 3 11:00:00 - Oct 3 12:00:00"`, - ); - }); - - it('enables custom display value', async () => { - const timeQueryRef = React.createRef(); - testRouter.replace('/search'); - const initialDisplayValue = 'Live Tail'; - - render( - - - , - ); - jest.runAllTimers(); - - expect(timeQueryRef.current?.displayedTimeInputValue).toBe( - initialDisplayValue, - ); - }); -}); diff --git a/packages/app/src/fixtures.ts b/packages/app/src/fixtures.ts deleted file mode 100644 index 76e3729ef5..0000000000 --- a/packages/app/src/fixtures.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Router } from 'next/router'; -import type { UrlObject } from 'url'; -import { LocationMock } from '@jedmao/location'; - -type PartialRouter = Partial; - -const BASE_URL = 'https://www.hyperdx.io'; - -/** - * A Router to be used for testing which provides the bare minimum needed - * for the useQueryParam(s) hook and NextAdapter to work. - */ -export class TestRouter implements PartialRouter { - isReady = true; - pathname = '/'; - private currentUrl = ''; - private history: string[] = []; - - constructor(private locationMock: LocationMock) {} - - replace = (url: string | UrlObject) => { - this.locationMock.assign(`${BASE_URL}${url}`); - this.currentUrl = TestRouter.getURLString(url); - this.locationMock.assign(`${BASE_URL}${this.currentUrl}`); - return Promise.resolve(true); - }; - - push = (url: string | UrlObject) => { - this.history.push(this.currentUrl); - this.currentUrl = TestRouter.getURLString(url); - this.locationMock.assign(`${BASE_URL}${this.currentUrl}`); - return Promise.resolve(true); - }; - - setIsReady = (isReady: boolean) => { - this.isReady = isReady; - }; - - get asPath() { - return this.pathname; - } - - static getURLString(url: string | UrlObject): string { - if (typeof url === 'string') { - return url; - } - return `${url.pathname}${url.search}`; - } - - getParams(): URLSearchParams { - return new URL(`${BASE_URL}${this.currentUrl}`).searchParams; - } -} diff --git a/packages/app/src/timeQuery.ts b/packages/app/src/timeQuery.ts index 7c9c2a499f..d2b80446f8 100644 --- a/packages/app/src/timeQuery.ts +++ b/packages/app/src/timeQuery.ts @@ -521,9 +521,3 @@ export function useNewTimeQuery({ ), }; } - -export function getLiveTailTimeRange(): [Date, Date] { - // eslint-disable-next-line no-restricted-syntax - const end = startOfSecond(new Date()); - return [sub(end, { minutes: 15 }), end]; -} diff --git a/packages/app/tests/e2e/components/TimePickerComponent.ts b/packages/app/tests/e2e/components/TimePickerComponent.ts index 860689f9d2..ca21875aab 100644 --- a/packages/app/tests/e2e/components/TimePickerComponent.ts +++ b/packages/app/tests/e2e/components/TimePickerComponent.ts @@ -114,12 +114,15 @@ export class TimePickerComponent { */ private async isPopoverStablyOpen(): Promise { if (!(await this.pickerPopover.isVisible())) return false; - // Dwell long enough to outlast the post-apply / post-select re-render - // cascade (URL update + results reload) that can close a just-opened - // popover. Sample twice so a mid-cascade flicker is caught. - await this.page.waitForTimeout(250); - if (!(await this.pickerPopover.isVisible())) return false; - await this.page.waitForTimeout(250); + // A just-opened popover can be closed again by the post-apply / + // post-select re-render cascade (URL update + results reload). Rather than + // blindly sleeping to outlast it, wait for that reload's network activity + // to go idle — the observable end of the cascade — then confirm the + // popover survived. Bounded + swallowed so a perpetually-busy page (e.g. + // live tail) can't hang here; on timeout we simply re-check visibility. + await this.page + .waitForLoadState('networkidle', { timeout: 2000 }) + .catch(() => {}); return await this.pickerPopover.isVisible(); } diff --git a/packages/app/tests/e2e/core/navigation.spec.ts b/packages/app/tests/e2e/core/navigation.spec.ts index 69465399d6..2fd576e8ee 100644 --- a/packages/app/tests/e2e/core/navigation.spec.ts +++ b/packages/app/tests/e2e/core/navigation.spec.ts @@ -159,31 +159,3 @@ test.describe('Navigation', { tag: ['@core'] }, () => { }); }); }); - -// Full-server tests that require authentication and backend services -test.skip('Navigation - Full Server Features', { tag: ['@core'] }, () => { - test.beforeEach(async ({ page }) => { - await page.goto('/search'); - }); - - test('should show full server navigation links', async ({ page }) => { - await test.step('Verify team settings link is visible', async () => { - const teamSettingsLink = page.locator( - '[data-testid="nav-link-team-settings"]', - ); - await expect(teamSettingsLink).toBeVisible(); - await expect(teamSettingsLink).toHaveAttribute('href', '/team-settings'); - }); - - await test.step('Verify alerts link functionality', async () => { - const alertsLink = page.locator('[data-testid="nav-link-alerts"]'); - await expect(alertsLink).toBeVisible(); - await expect(alertsLink).toHaveAttribute('href', '/alerts'); - - // In full-server mode, we can actually navigate to alerts - await alertsLink.click(); - await page.waitForURL('**/alerts**'); - await expect(page).toHaveURL(/.*\/alerts/); - }); - }); -}); diff --git a/packages/app/tests/e2e/features/dashboard.spec.ts b/packages/app/tests/e2e/features/dashboard.spec.ts index 6a7927c0d3..cee4762504 100644 --- a/packages/app/tests/e2e/features/dashboard.spec.ts +++ b/packages/app/tests/e2e/features/dashboard.spec.ts @@ -838,8 +838,12 @@ test.describe('Dashboard', { tag: ['@dashboard'] }, () => { await dashboardPage.saveQueryAndFiltersAsDefault(); - // Wait for save confirmation - await dashboardPage.page.waitForTimeout(1000); + // Wait for the save success notification rather than a blind sleep, so + // we only read the URL once the save has actually landed. + const notification = dashboardPage.page.locator( + 'text=/Filter query and dropdown values/i', + ); + await expect(notification).toBeVisible({ timeout: 5000 }); // Extract dashboard ID const url = dashboardPage.page.url(); diff --git a/packages/app/tests/e2e/features/kubernetes.spec.ts b/packages/app/tests/e2e/features/kubernetes.spec.ts index 690820184e..d765b86397 100644 --- a/packages/app/tests/e2e/features/kubernetes.spec.ts +++ b/packages/app/tests/e2e/features/kubernetes.spec.ts @@ -151,9 +151,7 @@ test.describe('Kubernetes Dashboard', { tag: ['@kubernetes'] }, () => { }); test.describe('Pods Table Sorting', () => { - // Currently the data sources all have 0 restarts, so this test fails. - // eslint-disable-next-line playwright/no-skipped-test - test.skip('should sort by restarts column', async () => { + test('should sort by restarts column', async () => { const podsTable = k8sPage.getPodsTable(); await expect(podsTable.locator('tbody tr').first()).toBeVisible(); diff --git a/yarn.lock b/yarn.lock index 23f5a9fede..a3e34e2a87 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4535,7 +4535,6 @@ __metadata: "@hyperdx/browser": "npm:^0.22.1" "@hyperdx/common-utils": "npm:^0.22.0" "@hyperdx/node-opentelemetry": "npm:^0.9.0" - "@jedmao/location": "npm:^3.0.0" "@mantine/core": "npm:^9.0.0" "@mantine/dates": "npm:^9.0.0" "@mantine/dropzone": "npm:^9.0.0" @@ -5194,13 +5193,6 @@ __metadata: languageName: node linkType: hard -"@jedmao/location@npm:^3.0.0": - version: 3.0.0 - resolution: "@jedmao/location@npm:3.0.0" - checksum: 10c0/dded42a7991aaf625c9696519aec188834b205b066e1696cd42280b70dff080d85f79353c3c21dd9ec8e9c122f193080c5a552d0916368dc13e1b03b11507184 - languageName: node - linkType: hard - "@jest/console@npm:30.2.0": version: 30.2.0 resolution: "@jest/console@npm:30.2.0" From f2620e9cef22d03d57f550abacb7eba091cc7a1f Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Thu, 16 Jul 2026 12:11:23 +1000 Subject: [PATCH 2/9] test: guard retry-count env overrides against NaN E2E_RETRIES and JEST_RETRY_TIMES were passed straight through Number(), so a non-numeric value (e.g. E2E_RETRIES=yes) silently became NaN. Guard both with Number.isNaN and fall back to the existing default, while still honouring an explicit 0 so the nightly zero-retry flake hunt keeps working. --- packages/api/jest.setup.ts | 6 +++++- packages/app/playwright.config.ts | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/api/jest.setup.ts b/packages/api/jest.setup.ts index 3a8f717a2f..1404e5f171 100644 --- a/packages/api/jest.setup.ts +++ b/packages/api/jest.setup.ts @@ -1,4 +1,8 @@ -jest.retryTimes(Number(process.env.JEST_RETRY_TIMES ?? '1'), { +// Overridable via JEST_RETRY_TIMES (e.g. the nightly zero-retry flake hunt). +// Fall back to the default of 1 if the env var is unset or non-numeric, but +// still honour an explicit 0 so retries can be forced off. +const jestRetryTimes = Number(process.env.JEST_RETRY_TIMES ?? '1'); +jest.retryTimes(Number.isNaN(jestRetryTimes) ? 1 : jestRetryTimes, { logErrorsBeforeRetry: true, }); diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts index 2554061831..359d5e3a28 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -32,7 +32,8 @@ export default defineConfig({ forbidOnly: !!process.env.CI, /* Retry on CI only. Overridable via E2E_RETRIES (e.g. the nightly zero-retry flake hunt). */ retries: - process.env.E2E_RETRIES !== undefined + process.env.E2E_RETRIES !== undefined && + !Number.isNaN(Number(process.env.E2E_RETRIES)) ? Number(process.env.E2E_RETRIES) : process.env.CI ? 2 From 3770d9a213631e7da9388ffb322f40bab6058053 Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Thu, 16 Jul 2026 14:44:22 +1000 Subject: [PATCH 3/9] test(e2e): raise Playwright workers to 4 on CI (with E2E_WORKERS override) --- packages/app/playwright.config.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts index d03d278af9..6b240324b1 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -32,8 +32,14 @@ export default defineConfig({ forbidOnly: !!process.env.CI, /* Retry on CI only */ retries: process.env.CI ? 2 : 1, - /* Use multiple workers on CI for faster execution */ - workers: process.env.CI ? 2 : undefined, + /* Use multiple workers on CI for faster execution. CI runners have 4 vCPUs; + * override with E2E_WORKERS if this over-subscribes the shared app/CH stack. */ + workers: + process.env.E2E_WORKERS !== undefined + ? Number(process.env.E2E_WORKERS) + : process.env.CI + ? 4 + : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: [ ['html'], From 459c75adf5888167099da4bcffde6104d667566f Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Thu, 16 Jul 2026 14:47:54 +1000 Subject: [PATCH 4/9] test(e2e): harden E2E_WORKERS parsing against empty/non-numeric values --- packages/app/playwright.config.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts index 6b240324b1..45bf51297a 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -34,12 +34,11 @@ export default defineConfig({ retries: process.env.CI ? 2 : 1, /* Use multiple workers on CI for faster execution. CI runners have 4 vCPUs; * override with E2E_WORKERS if this over-subscribes the shared app/CH stack. */ - workers: - process.env.E2E_WORKERS !== undefined - ? Number(process.env.E2E_WORKERS) - : process.env.CI - ? 4 - : undefined, + workers: (() => { + const parsed = Number.parseInt(process.env.E2E_WORKERS ?? '', 10); + if (!Number.isNaN(parsed)) return parsed; + return process.env.CI ? 4 : undefined; + })(), /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: [ ['html'], From 7baab6641d8405c230a8604cd060154cb6e25ddc Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Thu, 16 Jul 2026 15:11:41 +1000 Subject: [PATCH 5/9] ci(e2e): run shards on 8-core-playwright runners for the 4-worker bump --- .github/workflows/e2e-tests.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 9cd34cb1fd..54a835e157 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -5,7 +5,10 @@ on: jobs: e2e-tests: name: E2E Tests - Shard ${{ matrix.shard }} - runs-on: ubuntu-24.04 + # 8-core runner so the 4 Playwright workers (see playwright.config.ts) have + # CPU headroom alongside the app + ClickHouse + MongoDB — 4 workers + # over-subscribed the 4-vCPU ubuntu-24.04 runner and caused timeouts. + runs-on: 8-core-playwright timeout-minutes: 15 permissions: contents: read From 94f4e1acc355cf604e5fb1055ee9f5cd78d073a6 Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Thu, 16 Jul 2026 15:20:28 +1000 Subject: [PATCH 6/9] ci(e2e): cache the Chromium binary keyed on the Playwright version --- .github/actions/e2e-setup/action.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/actions/e2e-setup/action.yml b/.github/actions/e2e-setup/action.yml index c2d2c80c5b..b42b8b4236 100644 --- a/.github/actions/e2e-setup/action.yml +++ b/.github/actions/e2e-setup/action.yml @@ -42,6 +42,26 @@ runs: shell: bash run: npx nx run-many -t ci:build + # Cache the Chromium binary (~120MB) keyed on the resolved Playwright + # version, so it isn't re-downloaded every shard/run. Browser versions are + # pinned to the Playwright package version, so the version is the correct + # cache key. On a hit, `playwright install` finds the browser present and + # only installs OS deps. + - name: Resolve Playwright version + id: playwright-version + shell: bash + run: + echo "version=$(cd packages/app && node -p + "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT" + + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: + ${{ runner.os }}-playwright-${{ + steps.playwright-version.outputs.version }} + - name: Install Playwright browsers shell: bash run: cd packages/app && npx playwright install --with-deps chromium From 9407cc7121064991b42a30f9bf35c96c9576e3a2 Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Thu, 16 Jul 2026 15:50:21 +1000 Subject: [PATCH 7/9] test: validate E2E_RETRIES/JEST_RETRY_TIMES as non-negative ints, else default --- packages/api/jest.setup.ts | 16 ++++++++++------ packages/app/playwright.config.ts | 17 +++++++++-------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/packages/api/jest.setup.ts b/packages/api/jest.setup.ts index 1404e5f171..272998b094 100644 --- a/packages/api/jest.setup.ts +++ b/packages/api/jest.setup.ts @@ -1,10 +1,14 @@ // Overridable via JEST_RETRY_TIMES (e.g. the nightly zero-retry flake hunt). -// Fall back to the default of 1 if the env var is unset or non-numeric, but -// still honour an explicit 0 so retries can be forced off. -const jestRetryTimes = Number(process.env.JEST_RETRY_TIMES ?? '1'); -jest.retryTimes(Number.isNaN(jestRetryTimes) ? 1 : jestRetryTimes, { - logErrorsBeforeRetry: true, -}); +// Honours an explicit 0 so retries can be forced off; an unset/blank/negative/ +// non-integer value falls back to the default of 1 rather than coercing to 0. +const rawJestRetries = process.env.JEST_RETRY_TIMES; +const parsedJestRetries = rawJestRetries ? Number(rawJestRetries) : NaN; +jest.retryTimes( + Number.isInteger(parsedJestRetries) && parsedJestRetries >= 0 + ? parsedJestRetries + : 1, + { logErrorsBeforeRetry: true }, +); // http-proxy-middleware v4 is ESM-only and Jest's CJS module loader cannot // load ESM packages. Auto-mock since no test exercises the proxy directly. diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts index 359d5e3a28..db43cbfd0e 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -30,14 +30,15 @@ export default defineConfig({ fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, - /* Retry on CI only. Overridable via E2E_RETRIES (e.g. the nightly zero-retry flake hunt). */ - retries: - process.env.E2E_RETRIES !== undefined && - !Number.isNaN(Number(process.env.E2E_RETRIES)) - ? Number(process.env.E2E_RETRIES) - : process.env.CI - ? 2 - : 1, + /* Retry on CI only. Overridable via E2E_RETRIES (e.g. the nightly zero-retry + * flake hunt). Honours an explicit 0; an unset/blank/negative/non-integer + * value falls back to the default rather than silently coercing to 0. */ + retries: (() => { + const raw = process.env.E2E_RETRIES; + const parsed = raw ? Number(raw) : NaN; + if (Number.isInteger(parsed) && parsed >= 0) return parsed; + return process.env.CI ? 2 : 1; + })(), /* Use multiple workers on CI for faster execution */ workers: process.env.CI ? 2 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ From f11a1b6dcded1039678c9923396db562e16eb0d5 Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Thu, 16 Jul 2026 15:54:23 +1000 Subject: [PATCH 8/9] test(e2e): tighten E2E_WORKERS to a positive integer; fix stale vCPU comment --- packages/app/playwright.config.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts index 45bf51297a..5c571d578b 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -32,11 +32,14 @@ export default defineConfig({ forbidOnly: !!process.env.CI, /* Retry on CI only */ retries: process.env.CI ? 2 : 1, - /* Use multiple workers on CI for faster execution. CI runners have 4 vCPUs; - * override with E2E_WORKERS if this over-subscribes the shared app/CH stack. */ + /* Use multiple workers on CI for faster execution. CI e2e shards run on + * 8-core runners (see .github/workflows/e2e-tests.yml), so 4 workers leave + * headroom for the app + ClickHouse + Mongo. Override with E2E_WORKERS + * (a positive integer); anything else falls back to the default. */ workers: (() => { - const parsed = Number.parseInt(process.env.E2E_WORKERS ?? '', 10); - if (!Number.isNaN(parsed)) return parsed; + const raw = process.env.E2E_WORKERS; + const parsed = raw ? Number(raw) : NaN; + if (Number.isInteger(parsed) && parsed >= 1) return parsed; return process.env.CI ? 4 : undefined; })(), /* Reporter to use. See https://playwright.dev/docs/test-reporters */ From 90bf0c4e3554946aa9e01a16b4c5ab17faa01a3d Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Thu, 16 Jul 2026 16:11:02 +1000 Subject: [PATCH 9/9] ci(e2e): enforce immutable install in e2e-setup (from folded #2662) --- .github/actions/e2e-setup/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/e2e-setup/action.yml b/.github/actions/e2e-setup/action.yml index b42b8b4236..ba54200ccb 100644 --- a/.github/actions/e2e-setup/action.yml +++ b/.github/actions/e2e-setup/action.yml @@ -36,7 +36,7 @@ runs: - name: Install dependencies shell: bash - run: yarn install + run: yarn install --immutable - name: Build dependencies shell: bash