diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 775c7a020..0e272f1c6 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -1,9 +1,15 @@ name: Playwright Tests + on: push: - branches: [ main, master ] + branches: + - main + - release-* pull_request: - branches: [ main, master ] + branches: + - main + - release-* + jobs: test: timeout-minutes: 60 @@ -11,34 +17,31 @@ jobs: defaults: run: working-directory: gcs - + steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: lts/* - - - name: Install dependencies - working-directory: gcs - run: npm install -g yarn && yarn - - - name: Install Playwright Browsers - working-directory: gcs - run: yarn playwright install --with-deps - - - name: Run FGCS frontend - run: yarn dev:test & - - - name: Run Playwright tests - uses: coactions/setup-xvfb@v1 - with: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: lts/* + + - name: Install dependencies working-directory: gcs + run: npm install -g yarn && yarn + + - name: Install Playwright Browsers + working-directory: gcs + run: yarn playwright install --with-deps + + - name: Run FGCS frontend + run: yarn dev:test & + + - name: Run Playwright tests run: yarn playwright test - - - uses: actions/upload-artifact@v4 - if: always() - with: - name: playwright-report - path: playwright-report/ - retention-days: 30 + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/gcs/playwright.config.ts b/gcs/playwright.config.ts index 31eccc10c..6c9b8984a 100644 --- a/gcs/playwright.config.ts +++ b/gcs/playwright.config.ts @@ -25,7 +25,7 @@ export default defineConfig({ /* 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: 'http://127.0.0.1:3000', + baseURL: 'http://127.0.0.1:5173', /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on-first-retry', @@ -36,18 +36,23 @@ export default defineConfig({ projects: [ { name: 'chromium', - use: { ...devices['Desktop Chrome'] }, + use: { + ...devices['Desktop Chrome'], + // Use system Chrome browser in local dev, fall back to Chromium in CI + ...(process.env.CI ? {} : { channel: 'chrome' }) + }, }, - { - name: 'firefox', - use: { ...devices['Desktop Firefox'] }, - }, + // Disable other browsers for now to focus on Chrome + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, - { - name: 'webkit', - use: { ...devices['Desktop Safari'] }, - }, + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, /* Test against mobile viewports. */ // { @@ -72,8 +77,9 @@ export default defineConfig({ /* Run your local dev server before starting the tests */ // webServer: { - // command: 'yarn dev', + // command: 'yarn dev:test &', // url: 'http://127.0.0.1:5173', // reuseExistingServer: !process.env.CI, + // timeout: 120 * 1000, // }, }); diff --git a/gcs/tests/dashboard.spec.ts b/gcs/tests/dashboard.spec.ts deleted file mode 100644 index 19ee4d470..000000000 --- a/gcs/tests/dashboard.spec.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { test, expect } from "@playwright/test" - -// Ignore tests for now, will add at a later date -// test("example test", async ({ page }) => { -// await page.goto("http://localhost:5173") -// await expect(1).toEqual(1) -// }) \ No newline at end of file diff --git a/gcs/tests/error-handling.spec.ts b/gcs/tests/error-handling.spec.ts new file mode 100644 index 000000000..2749010ca --- /dev/null +++ b/gcs/tests/error-handling.spec.ts @@ -0,0 +1,201 @@ +import { test, expect } from '@playwright/test'; + +test.describe('GCS Error Handling and Application Stability Tests', () => { + test('should load application without critical JavaScript errors', async ({ page }) => { + const consoleErrors: string[] = []; + page.on('console', (msg) => { + if (msg.type() === 'error') { + consoleErrors.push(msg.text()); + } + }); + + await page.goto('/'); + + // Wait for the application to load + await page.waitForLoadState('networkidle'); + await expect(page.locator('#root')).toBeVisible(); + + // Filter out known/acceptable errors (if any) + const criticalErrors = consoleErrors.filter(error => + !error.includes('Failed to load resource') && // Network errors are expected when no server + !error.includes('WebSocket') && // WebSocket connection errors are expected + !error.includes('favicon') // Favicon errors are not critical + ); + + // Should not have critical JavaScript errors + expect(criticalErrors.length).toBe(0); + }); + + test('should display proper disconnection messages when no drone is connected', async ({ page }) => { + // Test each page that should show disconnection messages + const pagesWithDisconnectionMessages = [ + { path: '/#/config', expectedMessage: 'Not connected to drone. Please connect to view config' }, + { path: '/#/missions', expectedMessage: 'Not connected to drone. Please connect to view missions' }, + { path: '/#/graphs', expectedMessage: 'Not connected to drone. Please connect to view graphs' }, + { path: '/#/params', expectedMessage: 'Not connected to drone. Please connect to view params' } + ]; + + for (const pageInfo of pagesWithDisconnectionMessages) { + await page.goto(pageInfo.path); + await expect(page.locator('#root')).toBeVisible(); + + // Look for the specific full disconnection message + const disconnectionMessage = page.locator(`text=${pageInfo.expectedMessage}`); + await expect(disconnectionMessage).toBeVisible(); + } + }); + + test('should handle navigation errors gracefully without breaking application structure', async ({ page }) => { + const consoleErrors: string[] = []; + page.on('console', (msg) => { + if (msg.type() === 'error') { + consoleErrors.push(msg.text()); + } + }); + + // Test navigation to all main pages + const pages = ['/', '/#/config', '/#/missions', '/#/graphs', '/#/params', '/#/fla']; + + for (const pageUrl of pages) { + await page.goto(pageUrl); + await expect(page.locator('#root')).toBeVisible(); + + // Check for error boundaries or critical UI failures + const errorBoundary = page.locator('[data-testid="error-boundary"], .error-boundary'); + if (await errorBoundary.count() > 0) { + await expect(errorBoundary.first()).not.toBeVisible(); + } + + // Verify the application's core layout components are still present + const layout = page.locator('nav, [data-testid="navbar"], .navbar'); + if (await layout.count() > 0) { + await expect(layout.first()).toBeVisible(); + } + } + + // Filter critical errors + const criticalErrors = consoleErrors.filter(error => + !error.includes('Failed to load resource') && + !error.includes('WebSocket') && + !error.includes('favicon') + ); + + expect(criticalErrors.length).toBe(0); + }); + + test('should handle page refresh without breaking application functionality', async ({ page }) => { + // Go to dashboard + await page.goto('/'); + await expect(page.locator('#root')).toBeVisible(); + + // Refresh the page and verify core elements are still present + await page.reload(); + await expect(page.locator('#root')).toBeVisible(); + + // Go to a different page and refresh + await page.goto('/#/config'); + await expect(page.locator('#root')).toBeVisible(); + + await page.reload(); + await expect(page.locator('#root')).toBeVisible(); + + // After refresh, should show disconnection message + const configMessage = page.locator('text=Not connected to drone. Please connect to view config'); + await expect(configMessage).toBeVisible(); + }); + + test('should handle browser back/forward navigation without errors', async ({ page }) => { + // Navigate through several pages + await page.goto('/'); + await expect(page.locator('#root')).toBeVisible(); + + await page.goto('/#/config'); + await expect(page.locator('#root')).toBeVisible(); + + await page.goto('/#/missions'); + await expect(page.locator('#root')).toBeVisible(); + + // Use browser back button + await page.goBack(); + await expect(page.locator('#root')).toBeVisible(); + + await page.goBack(); + await expect(page.locator('#root')).toBeVisible(); + + // Use browser forward button + await page.goForward(); + await expect(page.locator('#root')).toBeVisible(); + + await page.goForward(); + await expect(page.locator('#root')).toBeVisible(); + }); + + test('should maintain application stability during rapid navigation cycles', async ({ page }) => { + // Start at dashboard + await page.goto('/'); + await expect(page.locator('#root')).toBeVisible(); + + // Navigate to different pages rapidly + const pages = ['/#/config', '/#/missions', '/#/graphs', '/#/params', '/#/fla', '/']; + + for (let i = 0; i < 3; i++) { // Do this cycle multiple times + for (const pageUrl of pages) { + await page.goto(pageUrl); + await expect(page.locator('#root')).toBeVisible(); + + // Verify basic application structure remains intact + const appContainer = page.locator('#root'); + await expect(appContainer).toBeVisible(); + + // Ensure no critical error messages appear + const criticalErrorMessages = page.locator('text=/error|exception|failed|crash/i'); + const visibleErrors = await criticalErrorMessages.filter({ hasText: /critical|fatal|crash/i }).count(); + expect(visibleErrors).toBe(0); + } + } + }); + + test('should handle invalid routes gracefully and allow recovery', async ({ page }) => { + // Try to navigate to a non-existent route + await page.goto('/#/invalid-route'); + + // Should still show the app container (React Router should handle this) + await expect(page.locator('#root')).toBeVisible(); + + // Try another invalid route + await page.goto('/#/does-not-exist'); + await expect(page.locator('#root')).toBeVisible(); + + // Should be able to navigate back to valid routes + await page.goto('/'); + await expect(page.locator('#root')).toBeVisible(); + + // Verify we can still navigate to other valid routes after invalid ones + await page.goto('/#/config'); + await expect(page.locator('#root')).toBeVisible(); + }); + + test('should handle window resize without breaking layout', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('#root')).toBeVisible(); + + // Test different viewport sizes + const viewports = [ + { width: 1920, height: 1080 }, + { width: 1280, height: 720 }, + { width: 800, height: 600 } + ]; + + for (const viewport of viewports) { + await page.setViewportSize(viewport); + await page.waitForTimeout(500); // Allow layout to adjust + + // Verify the app is still visible and functional + await expect(page.locator('#root')).toBeVisible(); + + // Test navigation still works at different screen sizes + await page.goto('/#/config'); + await expect(page.locator('#root')).toBeVisible(); + } + }); +}); \ No newline at end of file diff --git a/gcs/tests/example.spec.ts b/gcs/tests/example.spec.ts deleted file mode 100644 index 54a906a4e..000000000 --- a/gcs/tests/example.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test('has title', async ({ page }) => { - await page.goto('https://playwright.dev/'); - - // Expect a title "to contain" a substring. - await expect(page).toHaveTitle(/Playwright/); -}); - -test('get started link', async ({ page }) => { - await page.goto('https://playwright.dev/'); - - // Click the get started link. - await page.getByRole('link', { name: 'Get started' }).click(); - - // Expects page to have a heading with the name of Installation. - await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible(); -}); diff --git a/gcs/tests/navigation.spec.ts b/gcs/tests/navigation.spec.ts new file mode 100644 index 000000000..7b308c94f --- /dev/null +++ b/gcs/tests/navigation.spec.ts @@ -0,0 +1,139 @@ +import { test, expect } from '@playwright/test'; + +test.describe('GCS Navigation and Connection State Tests', () => { + test('should navigate to dashboard page and load correctly', async ({ page }) => { + await page.goto('/'); + + // Test that the page loads with correct title + await expect(page).toHaveTitle('FGCS'); + + // Check that the root element is visible + await expect(page.locator('#root')).toBeVisible(); + }); + + test('should navigate to config page and show disconnection message', async ({ page }) => { + await page.goto('/#/config'); + + await expect(page.locator('#root')).toBeVisible(); + + // Should show the no drone connected message + const noDroneMessage = page.locator('text=Not connected to drone. Please connect to view config'); + await expect(noDroneMessage).toBeVisible(); + }); + + test('should navigate to missions page and show disconnection message', async ({ page }) => { + await page.goto('/#/missions'); + + await expect(page.locator('#root')).toBeVisible(); + + // Should show the no drone connected message + const noDroneMessage = page.locator('text=Not connected to drone. Please connect to view missions'); + await expect(noDroneMessage).toBeVisible(); + }); + + test('should navigate to graphs page and show disconnection message', async ({ page }) => { + await page.goto('/#/graphs'); + + await expect(page.locator('#root')).toBeVisible(); + + // Should show the no drone connected message + const noDroneMessage = page.locator('text=Not connected to drone. Please connect to view graphs'); + await expect(noDroneMessage).toBeVisible(); + }); + + test('should navigate to parameters page and show disconnection message', async ({ page }) => { + await page.goto('/#/params'); + + await expect(page.locator('#root')).toBeVisible(); + + // Should show the no drone connected message + const noDroneMessage = page.locator('text=Not connected to drone. Please connect to view params'); + await expect(noDroneMessage).toBeVisible(); + }); + + test('should navigate to FLA page and show specific content', async ({ page }) => { + await page.goto('/#/fla'); + + await expect(page.locator('#root')).toBeVisible(); + + // FLA page should show file selection or log analysis interface + // Look for file button or log-related content + const fileButton = page.locator('button:has-text("Select"), button:has-text("File"), button:has-text("Load")'); + const logText = page.locator('text=/log/i, text=/analyser/i, text=/falcon/i'); + + // At least one of these should be visible on the FLA page + const hasFileButton = await fileButton.count() > 0; + const hasLogText = await logText.count() > 0; + + expect(hasFileButton || hasLogText).toBeTruthy(); + }); + + test('should handle navigation using browser back/forward', async ({ page }) => { + // Start at dashboard + await page.goto('/'); + await expect(page.locator('#root')).toBeVisible(); + + // Navigate to config + await page.goto('/#/config'); + await expect(page.locator('#root')).toBeVisible(); + + // Navigate to missions + await page.goto('/#/missions'); + await expect(page.locator('#root')).toBeVisible(); + + // Go back to config + await page.goBack(); + await expect(page.locator('#root')).toBeVisible(); + + // Go back to dashboard + await page.goBack(); + await expect(page.locator('#root')).toBeVisible(); + + // Go forward to config + await page.goForward(); + await expect(page.locator('#root')).toBeVisible(); + }); + + test('should show consistent disconnected state across all pages', async ({ page }) => { + const pages = [ + { path: '/#/config', name: 'config' }, + { path: '/#/missions', name: 'missions' }, + { path: '/#/graphs', name: 'graphs' }, + { path: '/#/params', name: 'params' } + ]; + + for (const pageInfo of pages) { + await page.goto(pageInfo.path); + await expect(page.locator('#root')).toBeVisible(); + + // Check for the "Not connected to drone" message + const noDroneMessage = page.locator(`text=Not connected to drone. Please connect to view ${pageInfo.name}`); + await expect(noDroneMessage).toBeVisible(); + + // Verify page loads without critical errors + const criticalErrors = page.locator('[data-testid="error"], .error-boundary, .critical-error'); + if (await criticalErrors.count() > 0) { + await expect(criticalErrors.first()).not.toBeVisible(); + } + } + }); + + test('should maintain consistent navigation state during rapid page switching', async ({ page }) => { + const pages = ['/', '/#/config', '/#/missions', '/#/graphs', '/#/params', '/#/fla']; + + // Navigate through pages multiple times rapidly + for (let cycle = 0; cycle < 2; cycle++) { + for (const pagePath of pages) { + await page.goto(pagePath); + await expect(page.locator('#root')).toBeVisible(); + + // Verify basic application structure remains intact + const appContainer = page.locator('#root'); + await expect(appContainer).toBeVisible(); + + // Brief wait to ensure page is stable + await page.waitForTimeout(100); + } + } + }); +}); \ No newline at end of file