diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ac506e02f..95a7cbca6 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -98,33 +98,111 @@ jobs: if: steps.changed-files.outputs.any_changed == 'true' working-directory: ./smoke-tests/otel-collector run: bats . - app-smoke-test: - name: HyperDX App Smoke Test - runs-on: ubuntu-latest + e2e-tests: + name: End-to-End Tests + runs-on: ubuntu-24.04 + timeout-minutes: 15 + container: + image: mcr.microsoft.com/playwright:v1.55.0-jammy permissions: + contents: read pull-requests: write - contents: write + steps: - name: Checkout - id: checkout uses: actions/checkout@v4 - - name: Waiting for vercel preview to be ready - uses: patrickedqvist/wait-for-vercel-preview@v1.3.2 - id: waitFor200 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache-dependency-path: 'yarn.lock' + cache: 'yarn' + + - name: Install dependencies + run: yarn install + + - name: Build dependencies + run: npx nx run-many -t ci:build + + - name: Run Playwright tests + run: | + cd packages/app + yarn test:e2e + + - name: Upload Playwright report + uses: actions/upload-artifact@v4 + if: always() with: - token: ${{ secrets.GITHUB_TOKEN }} - max_timeout: 1200 - check_interval: 10 - - run: echo ${{steps.waitFor200.outputs.url}} + name: playwright-report + path: packages/app/playwright-report/ + retention-days: 30 - - name: Stably Runner Action - uses: stablyhq/stably-runner-action@v3 + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() with: - test-suite-id: cmc548u5u0001la04q7y8ddj2 - github-token: ${{ secrets.GITHUB_TOKEN }} - api-key: ${{ secrets.STABLY_API_KEY }} - environment: PRODUCTION - variable-overrides: | - { - "SITE_URL": "${{ steps.waitFor200.outputs.url }}" + name: test-results + path: packages/app/test-results/ + retention-days: 30 + + - name: Generate test results message + id: test-results + if: always() && github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + result-encoding: string + script: | + const fs = require('fs'); + const path = require('path'); + + try { + const resultsPath = path.join('packages/app/test-results/results.json'); + if (fs.existsSync(resultsPath)) { + const results = JSON.parse(fs.readFileSync(resultsPath, 'utf8')); + const { stats } = results; + + const failed = stats.unexpected || 0; + const passed = stats.expected || 0; + const flaky = stats.flaky || 0; + const skipped = stats.skipped || 0; + const duration = Math.round((stats.duration || 0) / 1000); + + const summary = failed > 0 + ? `❌ **${failed} test${failed > 1 ? 's' : ''} failed**` + : `✅ **All tests passed**`; + + return `## E2E Test Results + + ${summary} • ${passed} passed • ${skipped} skipped • ${duration}s + + | Status | Count | + |--------|-------| + | ✅ Passed | ${passed} | + | ❌ Failed | ${failed} | + | ⚠️ Flaky | ${flaky} | + | ⏭️ Skipped | ${skipped} | + + [View full report →](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})`; + } else { + return `## E2E Test Results + + ❌ **Test results file not found** + + [View full report →](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})`; + } + } catch (error) { + console.log('Could not parse test results:', error.message); + return `## E2E Test Results + + ❌ **Error reading test results** + + [View full report →](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})`; } + + - name: Comment PR with test results + uses: mshick/add-pr-comment@v2 + if: always() && github.event_name == 'pull_request' + with: + message: ${{ steps.test-results.outputs.result }} + message-id: e2e-test-results diff --git a/.gitignore b/.gitignore index 087234fd4..009925ccb 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,11 @@ e2e/cypress/screenshots/ e2e/cypress/videos/ e2e/cypress/results +# playwright +**/test-results/ +**/playwright-report/ +**/playwright/.cache/ + # scripts scripts/*.csv **/venv diff --git a/Makefile b/Makefile index 1663649d0..5aabc1b8c 100644 --- a/Makefile +++ b/Makefile @@ -59,6 +59,16 @@ dev-unit: ci-unit: npx nx run-many -t ci:unit +.PHONY: e2e +e2e: + @if [ -z "$(tags)" ]; then \ + echo "Running all E2E tests in local mode..."; \ + cd packages/app && yarn test:e2e; \ + else \ + echo "Running E2E tests with tags: $(tags)"; \ + cd packages/app && yarn test:e2e --grep "$(tags)"; \ + fi + # TODO: check db connections before running the migration CLIs .PHONY: dev-migrate-db dev-migrate-db: diff --git a/package.json b/package.json index 22f13b561..6d8e8d869 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,8 @@ "packageManager": "yarn@4.5.1", "resolutions": { "@types/react": "18.3.1", - "@types/react-dom": "18.3.1" + "@types/react-dom": "18.3.1", + "@types/express": "4.17.21", + "@types/express-serve-static-core": "4.17.43" } } diff --git a/packages/api/src/routers/external-api/v2/index.ts b/packages/api/src/routers/external-api/v2/index.ts index 4c02d07e0..2e285a8b2 100644 --- a/packages/api/src/routers/external-api/v2/index.ts +++ b/packages/api/src/routers/external-api/v2/index.ts @@ -9,8 +9,8 @@ import rateLimiter from '@/utils/rateLimiter'; const router = express.Router(); -const rateLimiterKeyGenerator = (req: express.Request) => { - return req.headers.authorization || req.ip; +const rateLimiterKeyGenerator = (req: express.Request): string => { + return req.headers.authorization ?? req.ip ?? 'unknown'; }; const defaultRateLimiter = rateLimiter({ diff --git a/packages/app/.eslintrc.js b/packages/app/.eslintrc.js index 91cfb928d..ee8e1e050 100644 --- a/packages/app/.eslintrc.js +++ b/packages/app/.eslintrc.js @@ -44,5 +44,15 @@ module.exports = { ], }, }, + { + // Disable strict rules for E2E test files + files: ['tests/e2e/**/*.ts', 'tests/e2e/**/*.js'], + rules: { + 'no-console': 'off', + 'no-empty': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@next/next/no-html-link-for-pages': 'off', + }, + }, ], }; diff --git a/packages/app/package.json b/packages/app/package.json index b95632136..4b2c9cbe1 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -17,6 +17,9 @@ "ci:lint": "yarn lint && yarn tsc --noEmit && yarn lint:styles --quiet", "ci:unit": "jest --ci --coverage", "dev:unit": "jest --watchAll --detectOpenHandles", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:debug": "playwright test --debug", "storybook": "storybook dev -p 6006", "storybook:build": "storybook build", "knip": "knip" @@ -104,6 +107,7 @@ "@chromatic-com/storybook": "^1.5.0", "@hookform/devtools": "^4.3.1", "@jedmao/location": "^3.0.0", + "@playwright/test": "^1.47.0", "@storybook/addon-essentials": "^8.1.5", "@storybook/addon-interactions": "^8.1.5", "@storybook/addon-links": "^8.1.5", diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts new file mode 100644 index 000000000..3f1602ee2 --- /dev/null +++ b/packages/app/playwright.config.ts @@ -0,0 +1,59 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * @see https://playwright.dev/docs/test-configuration + */ +export default defineConfig({ + testDir: './tests/e2e', + /* Global setup to ensure server is ready */ + globalSetup: require.resolve('./global-setup.js'), + /* Run tests in files in parallel */ + 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: undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: [ + ['html'], + ['json', { outputFile: 'test-results/results.json' }], + ...(process.env.CI ? [['github', {}] as const] : []), + ], + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8080', + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + /* Take screenshot on failure */ + screenshot: 'only-on-failure', + /* Record video on failure */ + video: 'retain-on-failure', + }, + + /* Global test timeout - increased from default 30s to 60s to reduce flaky test failures */ + timeout: 60 * 1000, + + /* Configure projects for different test environments */ + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: + 'NEXT_PUBLIC_IS_LOCAL_MODE=true NEXT_TELEMETRY_DISABLED=1 yarn run dev', + port: 8080, + reuseExistingServer: !process.env.CI, + timeout: 180 * 1000, + stdout: 'pipe', + stderr: 'pipe', + }, +}); diff --git a/packages/app/src/AutocompleteInput.tsx b/packages/app/src/AutocompleteInput.tsx index 03110a485..4a0914135 100644 --- a/packages/app/src/AutocompleteInput.tsx +++ b/packages/app/src/AutocompleteInput.tsx @@ -25,6 +25,7 @@ export default function AutocompleteInput({ showHotkey, onSubmit, queryHistoryType, + 'data-testid': dataTestId, }: { inputRef: React.RefObject; value?: string; @@ -42,6 +43,7 @@ export default function AutocompleteInput({ language?: 'sql' | 'lucene'; showHotkey?: boolean; queryHistoryType?: string; + 'data-testid'?: string; }) { const suggestionsLimit = 10; @@ -242,6 +244,7 @@ export default function AutocompleteInput({ className="border-0 fs-8" value={value} size={size} + data-testid={dataTestId} onChange={e => onChange(e.target.value)} onFocus={() => { setSelectedAutocompleteIndex(-1); diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index ad656d8b6..638d5cbd4 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -944,6 +944,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { language="lucene" placeholder="Search your events w/ Lucene ex. column:foo" enableHotkey + data-testid="search-input" /> ) } @@ -1047,6 +1048,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { ) : null}