diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9ad623d60..61ebff783 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,12 +1,44 @@ version: 2 updates: + # Frontend npm dependencies - package-ecosystem: 'npm' - directory: '/' + directory: '/frontend' schedule: interval: 'weekly' versioning-strategy: 'increase' + groups: + # Group minor/patch updates to reduce PR noise + minor-and-patch: + patterns: + - '*' + update-types: + - 'minor' + - 'patch' + ignore: + # Ignore major version updates for stability + - dependency-name: '*' + update-types: ['version-update:semver-major'] + + # Backend Go dependencies - package-ecosystem: 'gomod' directory: '/backend' schedule: interval: 'weekly' versioning-strategy: 'increase' + groups: + go-minor-and-patch: + patterns: + - '*' + update-types: + - 'minor' + - 'patch' + + # GitHub Actions dependencies + - package-ecosystem: 'github-actions' + directory: '/' + schedule: + interval: 'weekly' + groups: + actions: + patterns: + - '*' diff --git a/.github/labeler.yml b/.github/labeler.yml index 1b2412d4e..34e5d751e 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -26,6 +26,16 @@ - frontend/tsconfig*.json - frontend/prettier.config.js - frontend/postcss.config.js + - frontend/playwright.config.ts + +# Add 'tests' label for test-related files +'tests': + - frontend/e2e/** + - frontend/tests/** + - frontend/playwright.config.ts + - frontend/playwright.global-setup.ts + - frontend/jest.config.ts + - frontend/jest.setup.ts # Add 'documentation' label for documentation-related files 'documentation': @@ -44,6 +54,10 @@ - backend/Dockerfile - backend/.dockerignore +# Add 'helm' label for Helm chart files +'helm': + - chart/** + # Add 'other' label as a fallback for any other files 'other': - '*' \ No newline at end of file diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index c6f701ea6..edd295be9 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -12,16 +12,20 @@ on: jobs: test: - timeout-minutes: 60 + timeout-minutes: 30 runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - # Run tests on multiple browsers + # Run tests on multiple browsers with sharding for parallelization browser: [chromium, firefox, webkit] + shard: [1/4, 2/4, 3/4, 4/4] env: WARMUP_BROWSER: ${{ matrix.browser }} + # Use 2 workers for stability + PLAYWRIGHT_WORKERS: 2 steps: - uses: actions/checkout@v4 @@ -50,39 +54,75 @@ jobs: - name: Install Playwright Browsers working-directory: ./frontend - run: npx playwright install --with-deps ${{ matrix.browser }} - - - name: Verify Playwright installation - working-directory: ./frontend - run: npx playwright --version - - - name: List available tests - working-directory: ./frontend - run: npx playwright test --list --project=${{ matrix.browser }} > /tmp/playwright_list.txt && head -5 /tmp/playwright_list.txt && rm /tmp/playwright_list.txt - - - name: Build frontend - working-directory: ./frontend - env: - VITE_USE_MSW: 'true' - run: npm run build + run: | + npx playwright install --with-deps ${{ matrix.browser }} + # Ensure all system dependencies are installed for webkit + if [ "${{ matrix.browser }}" = "webkit" ]; then + sudo apt-get update + sudo apt-get install -y libwoff1 libopus0 libwebpdemux2 libharfbuzz-icu0 libenchant-2-2 libsecret-1-0 libhyphen0 libmanette-0.2-0 libflite1 libgles2 gstreamer1.0-libav + fi - name: Run Playwright tests working-directory: ./frontend - run: npx playwright test --project=${{ matrix.browser }} + run: npx playwright test --project=${{ matrix.browser }} --shard=${{ matrix.shard }} env: CI: true VITE_USE_MSW: 'true' - - uses: actions/upload-artifact@v4 + - name: Upload blob report + uses: actions/upload-artifact@v4 if: always() with: - name: playwright-report-${{ matrix.browser }} - path: frontend/playwright-report/ - retention-days: 7 - - - uses: actions/upload-artifact@v4 - if: always() + name: blob-report-${{ matrix.browser }}-${{ strategy.job-index }} + path: frontend/blob-report/ + retention-days: 1 + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: failure() with: - name: test-results-${{ matrix.browser }} + name: test-results-${{ matrix.browser }}-${{ strategy.job-index }} path: frontend/test-results/ retention-days: 7 + + # Merge all sharded reports into a single HTML report per browser + merge-reports: + name: Merge Reports + needs: test + runs-on: ubuntu-latest + if: always() + + strategy: + matrix: + browser: [chromium, firefox, webkit] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + working-directory: ./frontend + run: npm ci + + - name: Download blob reports for ${{ matrix.browser }} + uses: actions/download-artifact@v4 + with: + pattern: blob-report-${{ matrix.browser }}-* + path: frontend/all-blob-reports + merge-multiple: true + + - name: Merge reports + working-directory: ./frontend + run: npx playwright merge-reports --reporter html ./all-blob-reports + + - name: Upload merged HTML report + uses: actions/upload-artifact@v4 + with: + name: playwright-report-${{ matrix.browser }} + path: frontend/playwright-report/ + retention-days: 14 diff --git a/frontend/e2e/CommandPalette.spec.ts b/frontend/e2e/CommandPalette.spec.ts index ff53f1fe3..38f777347 100644 --- a/frontend/e2e/CommandPalette.spec.ts +++ b/frontend/e2e/CommandPalette.spec.ts @@ -7,10 +7,11 @@ test.describe('Command Palette', () => { // Login first to access the command palette await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' }); - // Wait for login page to be ready - await page.waitForSelector('input[type="text"], input[name="username"]', { timeout: 10000 }); + // Wait for login form to be ready using role-based locator (auto-retries) + const usernameInput = page.getByRole('textbox', { name: 'Username' }); + await expect(usernameInput).toBeVisible({ timeout: 15000 }); - await page.getByRole('textbox', { name: 'Username' }).fill('admin'); + await usernameInput.fill('admin'); await page.getByRole('textbox', { name: 'Password' }).fill('admin'); await page.getByRole('button', { name: /Sign In|Sign In to/i }).click(); diff --git a/frontend/e2e/Dashboard.spec.ts b/frontend/e2e/Dashboard.spec.ts index b124be5b0..2a7966934 100644 --- a/frontend/e2e/Dashboard.spec.ts +++ b/frontend/e2e/Dashboard.spec.ts @@ -183,7 +183,7 @@ test.describe('Dashboard Page', () => { const firstCluster = page.getByRole('heading', { name: 'cluster1' }).first(); await firstCluster.click(); - await expect(page.locator('[role="dialog"], .modal')).toBeVisible({ timeout: 2000 }); + await expect(page.locator('[role="dialog"], .modal')).toBeVisible({ timeout: 10000 }); await page.keyboard.press('Escape'); }); diff --git a/frontend/e2e/Login.spec.ts b/frontend/e2e/Login.spec.ts index 4fe8070e7..aed740114 100644 --- a/frontend/e2e/Login.spec.ts +++ b/frontend/e2e/Login.spec.ts @@ -218,37 +218,44 @@ test.describe('Login Page', () => { await loginPage.fillPassword('wrongpassword'); await loginPage.clickSignIn(); - // Wait for loading toast or status indicator to disappear, or error toast/alert to appear - await Promise.race([ - page - .locator('.toast-loading, [role="status"]') + // Wait for error response - either an error message appears or we stay on login page + // The app uses react-hot-toast which renders in a div with specific structure + const errorMessageLocator = page.locator('text=/Invalid|Error|failed|incorrect/i'); + const toastLocator = page.locator( + '[role="alert"], [data-sonner-toast], .toast-error, [class*="toast"]' + ); + + // Try to find error indication with increased timeout + let errorFound = false; + try { + // First wait for the sign-in button to be enabled again (indicates request completed) + await page + .getByRole('button', { name: /Sign In/i }) + .waitFor({ state: 'visible', timeout: 10000 }); + + // Then check for error message or toast + const hasErrorMessage = await errorMessageLocator .first() - .waitFor({ state: 'hidden', timeout: 3000 }), - page - .locator('[role="alert"], .toast-error') + .isVisible() + .catch(() => false); + const hasToast = await toastLocator .first() - .waitFor({ state: 'visible', timeout: 3000 }), - ]); - - // Look for any text containing error messages - const errorTexts = ['Invalid username or password']; + .isVisible() + .catch(() => false); - let errorFound = false; - for (const errorText of errorTexts) { - try { - const errorElement = page.locator(`text=${errorText}`); - await errorElement.waitFor({ timeout: 2000 }); - await expect(errorElement).toBeVisible(); + if (hasErrorMessage || hasToast) { errorFound = true; - break; - } catch { - // Continue to next error text } + } catch { + // Request may still be processing } - // If no specific error text found, check if we're still on login page (which indicates error) + // If no specific error found, verify we're still on login page (which indicates login failed) if (!errorFound) { - await expect(page).toHaveURL(/login/); + await expect(page).toHaveURL(/login/, { timeout: 5000 }); } + + // Final verification: we should still be on login page after failed login + await expect(page).toHaveURL(/login/); }); }); diff --git a/frontend/e2e/Navbar.spec.ts b/frontend/e2e/Navbar.spec.ts index c3458cd1c..f10d75100 100644 --- a/frontend/e2e/Navbar.spec.ts +++ b/frontend/e2e/Navbar.spec.ts @@ -7,10 +7,11 @@ test.describe('Navbar (Header)', () => { // Login first to access the header await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' }); - // Wait for login page to be ready - await page.waitForSelector('input[type="text"], input[name="username"]', { timeout: 10000 }); + // Wait for login form to be ready using role-based locator (auto-retries) + const usernameInput = page.getByRole('textbox', { name: 'Username' }); + await expect(usernameInput).toBeVisible({ timeout: 15000 }); - await page.getByRole('textbox', { name: 'Username' }).fill('admin'); + await usernameInput.fill('admin'); await page.getByRole('textbox', { name: 'Password' }).fill('admin'); await page.getByRole('button', { name: /Sign In|Sign In to/i }).click(); @@ -259,35 +260,5 @@ test.describe('Navbar (Header)', () => { } } }); - - test('navbar has consistent styling', async ({ page }) => { - const header = page.locator('header'); - - // Check if header has some styling applied (background, border, or shadow) - const styles = await header.evaluate(el => { - const computed = window.getComputedStyle(el); - return { - backgroundColor: computed.backgroundColor, - borderBottom: computed.borderBottom, - boxShadow: computed.boxShadow, - position: computed.position, - }; - }); - - // Header should have some styling - at least background color or position - const hasBackground = - styles.backgroundColor && - styles.backgroundColor !== 'rgba(0, 0, 0, 0)' && - styles.backgroundColor !== 'transparent'; - const hasBorder = - styles.borderBottom && - styles.borderBottom !== 'none' && - styles.borderBottom !== '0px none rgba(0, 0, 0, 0)'; - const hasShadow = styles.boxShadow && styles.boxShadow !== 'none'; - const isPositioned = styles.position === 'fixed' || styles.position === 'sticky'; - - // Should have at least one styling feature - expect(hasBackground || hasBorder || hasShadow || isPositioned).toBe(true); - }); }); }); diff --git a/frontend/e2e/ObjectExplorerViewModes.spec.ts b/frontend/e2e/ObjectExplorerViewModes.spec.ts index 77b92ffd2..53982f3f4 100644 --- a/frontend/e2e/ObjectExplorerViewModes.spec.ts +++ b/frontend/e2e/ObjectExplorerViewModes.spec.ts @@ -6,7 +6,7 @@ test.describe('Object Explorer - View Modes and Pagination', () => { let objectExplorerPage: ObjectExplorerPage; let mswHelper: MSWHelper; - test.describe.configure({ timeout: 60000 }); + test.describe.configure({ timeout: 120000 }); test.beforeEach(async ({ page }) => { loginPage = new LoginPage(page); objectExplorerPage = new ObjectExplorerPage(page); diff --git a/frontend/e2e/ProfileSection.spec.ts b/frontend/e2e/ProfileSection.spec.ts index 0bf558ab8..4a0a42b22 100644 --- a/frontend/e2e/ProfileSection.spec.ts +++ b/frontend/e2e/ProfileSection.spec.ts @@ -7,10 +7,11 @@ test.describe('Profile Section', () => { // Login first to access the profile section await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' }); - // Wait for login page to be ready - await page.waitForSelector('input[type="text"], input[name="username"]', { timeout: 10000 }); + // Wait for login form to be ready using role-based locator (auto-retries) + const usernameInput = page.getByRole('textbox', { name: 'Username' }); + await expect(usernameInput).toBeVisible({ timeout: 15000 }); - await page.getByRole('textbox', { name: 'Username' }).fill('admin'); + await usernameInput.fill('admin'); await page.getByRole('textbox', { name: 'Password' }).fill('admin'); await page.getByRole('button', { name: /Sign In|Sign In to/i }).click(); diff --git a/frontend/e2e/ThemeToggle.spec.ts b/frontend/e2e/ThemeToggle.spec.ts index 9012c3a56..f701a1bb3 100644 --- a/frontend/e2e/ThemeToggle.spec.ts +++ b/frontend/e2e/ThemeToggle.spec.ts @@ -7,10 +7,11 @@ test.describe('Theme Toggle Button', () => { // Login first to access the header await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' }); - // Wait for login page to be ready - await page.waitForSelector('input[type="text"], input[name="username"]', { timeout: 10000 }); + // Wait for login form to be ready using role-based locator (auto-retries) + const usernameInput = page.getByRole('textbox', { name: 'Username' }); + await expect(usernameInput).toBeVisible({ timeout: 15000 }); - await page.getByRole('textbox', { name: 'Username' }).fill('admin'); + await usernameInput.fill('admin'); await page.getByRole('textbox', { name: 'Password' }).fill('admin'); await page.getByRole('button', { name: /Sign In|Sign In to/i }).click(); diff --git a/frontend/e2e/pages/LoginPage.ts b/frontend/e2e/pages/LoginPage.ts index 5307eb80f..775e25e0e 100644 --- a/frontend/e2e/pages/LoginPage.ts +++ b/frontend/e2e/pages/LoginPage.ts @@ -340,13 +340,14 @@ export class LoginPage extends BasePage { const options = Array.from(document.querySelectorAll('[role="option"]')); return options.some(opt => opt.textContent?.includes(text)); }, + languageText, - { timeout: 2000 } + { timeout: 5000 } ); // Use a more reliable click approach - wait for element to be actionable - await languageOption.waitFor({ state: 'attached', timeout: 2000 }); - await expect(languageOption).toBeEnabled({ timeout: 2000 }); + await languageOption.waitFor({ state: 'attached', timeout: 5000 }); + await expect(languageOption).toBeEnabled({ timeout: 5000 }); // Click with retry handling try { diff --git a/frontend/e2e/pages/ObjectExplorerPage.ts b/frontend/e2e/pages/ObjectExplorerPage.ts index 717a7a1bf..097732488 100644 --- a/frontend/e2e/pages/ObjectExplorerPage.ts +++ b/frontend/e2e/pages/ObjectExplorerPage.ts @@ -129,8 +129,8 @@ export class ObjectExplorerPage extends BasePage { } async waitForPageLoad() { - await this.pageTitle.waitFor({ state: 'visible', timeout: 10000 }); - await this.kindInput.waitFor({ state: 'visible', timeout: 5000 }); + await this.pageTitle.waitFor({ state: 'visible', timeout: 30000 }); + await this.kindInput.waitFor({ state: 'visible', timeout: 15000 }); } async closeModals() { @@ -313,9 +313,9 @@ export class ObjectExplorerPage extends BasePage { await this.page.waitForTimeout(300); } - async waitForResources(timeout: number = 10000) { + async waitForResources(timeout: number = 20000) { await Promise.race([ - this.resultsCount.waitFor({ state: 'visible', timeout }), + this.resultsCount.waitFor({ state: 'visible', timeout: 20000 }), this.page.waitForTimeout(timeout), ]).catch(() => {}); await this.page.waitForTimeout(1000); diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index e98284d29..dc26864b5 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -4,6 +4,24 @@ import { defineConfig, devices } from '@playwright/test'; const isCI = !!process.env.CI; const baseURL = process.env.VITE_BASE_URL || 'http://localhost:5173'; +/** + * Determine worker count for parallelization + * - CI: Use 50% of available CPUs (or env override) for better resource utilization + * - Local: Use all available CPUs + */ +const getWorkerCount = (): number | string => { + if (!isCI) return '100%'; // Use all CPUs locally + + // Allow CI to override worker count via environment variable + if (process.env.PLAYWRIGHT_WORKERS) { + const workers = parseInt(process.env.PLAYWRIGHT_WORKERS, 10); + if (!isNaN(workers) && workers > 0) return workers; + } + + // Default: 2 workers in CI for stability + return 2; +}; + /** * See https://playwright.dev/docs/test-configuration. */ @@ -17,13 +35,21 @@ export default defineConfig({ forbidOnly: isCI, /* Retry on CI only */ retries: isCI ? 2 : 0, - /* Opt out of parallel tests on CI. */ - workers: isCI ? 1 : undefined, + /* Number of parallel workers - enables parallelization in CI */ + workers: getWorkerCount(), + /* Global timeout for each test */ + timeout: 60000, + /* Expect timeout */ + expect: { + timeout: 10000, + }, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: [ ['html', { outputFolder: 'playwright-report' }], ['json', { outputFile: 'playwright-results.json' }], ['junit', { outputFile: 'playwright-results.xml' }], + // Blob reporter for merging sharded results in CI + ...(isCI ? [['blob', { outputDir: 'blob-report' }] as const] : []), isCI ? ['github'] : ['list'], ], /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ @@ -46,6 +72,12 @@ export default defineConfig({ /* Ignore HTTPS errors */ ignoreHTTPSErrors: true, + /* Action timeout - prevent individual actions from hanging */ + actionTimeout: 15000, + + /* Navigation timeout */ + navigationTimeout: 30000, + /* Extra HTTP headers */ extraHTTPHeaders: { 'Accept-Language': 'en-US,en;q=0.9', @@ -66,7 +98,14 @@ export default defineConfig({ { name: 'webkit', - use: { ...devices['Desktop Safari'] }, + use: { + ...devices['Desktop Safari'], + // Webkit is slower, give it more time + actionTimeout: 20000, + navigationTimeout: 40000, + }, + // More retries for webkit as it can be flaky + retries: isCI ? 3 : 0, }, // Only include branded browsers in local development (not CI) @@ -90,6 +129,8 @@ export default defineConfig({ VITE_DISABLE_CANVAS: 'false', // app handle Firefox detection VITE_USE_MSW: 'true', // Enable MSW for tests }, - timeout: 120 * 1000, + timeout: 180 * 1000, // 3 minutes for server startup in CI + stdout: 'pipe', + stderr: 'pipe', }, }); diff --git a/frontend/playwright.global-setup.ts b/frontend/playwright.global-setup.ts index 1f82cfc63..62c122982 100644 --- a/frontend/playwright.global-setup.ts +++ b/frontend/playwright.global-setup.ts @@ -53,7 +53,10 @@ export default async function globalSetup(config: FullConfig) { const baseURL = config.projects?.[0]?.use?.baseURL || process.env.VITE_BASE_URL || 'http://localhost:5173'; console.log(`🔥 Warming up ${baseURL} with ${browserName}...`); - await page.goto(baseURL, { waitUntil: 'networkidle', timeout: 30000 }); + // Use domcontentloaded instead of networkidle since MSW may keep connections open + await page.goto(baseURL, { waitUntil: 'domcontentloaded', timeout: 30000 }); + // Wait a bit for the app to initialize + await page.waitForTimeout(2000); console.log('✅ Application is ready'); } catch (err) { console.warn('⚠️ Warning: Warmup failed:', err);