diff --git a/.github/actions/e2e-setup/action.yml b/.github/actions/e2e-setup/action.yml index c2d2c80c5b..ba54200ccb 100644 --- a/.github/actions/e2e-setup/action.yml +++ b/.github/actions/e2e-setup/action.yml @@ -36,12 +36,32 @@ runs: - name: Install dependencies shell: bash - run: yarn install + run: yarn install --immutable - name: Build dependencies 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 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 diff --git a/packages/api/jest.setup.ts b/packages/api/jest.setup.ts index 05bb790c6a..272998b094 100644 --- a/packages/api/jest.setup.ts +++ b/packages/api/jest.setup.ts @@ -1,4 +1,14 @@ -jest.retryTimes(1, { logErrorsBeforeRetry: true }); +// Overridable via JEST_RETRY_TIMES (e.g. the nightly zero-retry flake hunt). +// 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/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..b6d8f29353 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -30,10 +30,25 @@ 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, - /* Use multiple workers on CI for faster execution */ - workers: process.env.CI ? 2 : undefined, + /* 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. 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 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 */ reporter: [ ['html'], 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"