diff --git a/web/.github/workflows/playwright.yml b/web/.github/workflows/playwright.yml new file mode 100644 index 000000000..3eb13143c --- /dev/null +++ b/web/.github/workflows/playwright.yml @@ -0,0 +1,27 @@ +name: Playwright Tests +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: lts/* + - name: Install dependencies + run: npm ci + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run Playwright tests + run: npx playwright test + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/web/.gitignore b/web/.gitignore index 12acbd6e1..170b485ab 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -47,3 +47,10 @@ next-env.d.ts *storybook.log /storybook-static + +# Playwright +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/web/jest.config.mjs b/web/jest.config.mjs index a99624008..ecb4ae435 100644 --- a/web/jest.config.mjs +++ b/web/jest.config.mjs @@ -17,7 +17,9 @@ const config = { '^@/(.*)$': '/src/$1' }, // Test files can be next to components with .test.tsx or .spec.tsx extensions - testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'] + testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'], + // Exclude Playwright tests from Jest + testPathIgnorePatterns: ['/node_modules/', '/.next/', '/playwright-tests/'] }; // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async diff --git a/web/package-lock.json b/web/package-lock.json index cedcfc082..91875aa58 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -94,6 +94,7 @@ "@chromatic-com/storybook": "^3.2.6", "@eslint/eslintrc": "^3", "@next/bundle-analyzer": "^15.3.1", + "@playwright/test": "^1.52.0", "@storybook/addon-controls": "^8.6.12", "@storybook/addon-essentials": "^8.6.12", "@storybook/addon-interactions": "^8.6.12", @@ -104,6 +105,7 @@ "@tailwindcss/postcss": "4.1.4", "@testing-library/react": "^16.3.0", "@types/canvas-confetti": "^1.9.0", + "@types/dotenv": "^6.1.1", "@types/js-cookie": "^3.0.6", "@types/js-yaml": "^4.0.9", "@types/lodash": "^4.17.16", @@ -114,6 +116,7 @@ "@types/react-dom": "^18", "@types/react-scroll-to-bottom": "^4.2.5", "@types/react-syntax-highlighter": "^15.5.13", + "dotenv": "^16.5.0", "eslint": "^9", "eslint-config-next": "15.3.1", "eslint-config-prettier": "^10.1.2", @@ -4582,6 +4585,22 @@ "node": ">=0.10" } }, + "node_modules/@playwright/test": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz", + "integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@pmmmwh/react-refresh-webpack-plugin": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.15.tgz", @@ -7180,6 +7199,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/dotenv": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@types/dotenv/-/dotenv-6.1.1.tgz", + "integrity": "sha512-ftQl3DtBvqHl9L16tpqqzA4YzCSXZfi7g8cQceTz5rOlYtk/IZbFjAv3mLOQlNIgOaylCQWQoBdDQHPgEBJPHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -10797,6 +10826,19 @@ "tslib": "^2.0.3" } }, + "node_modules/dotenv": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", + "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dt-sql-parser": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/dt-sql-parser/-/dt-sql-parser-4.1.1.tgz", @@ -17758,6 +17800,52 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz", + "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz", + "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", diff --git a/web/package.json b/web/package.json index f3515d58d..6ab35e1ef 100644 --- a/web/package.json +++ b/web/package.json @@ -11,7 +11,11 @@ "test:watch": "jest --watch", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", - "analyze": "ANALYZE=true next build" + "analyze": "ANALYZE=true next build", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:debug": "playwright test --debug", + "test:e2e:codegen": "playwright codegen" }, "engines": { "node": ">=22.9.0" @@ -103,6 +107,7 @@ "@chromatic-com/storybook": "^3.2.6", "@eslint/eslintrc": "^3", "@next/bundle-analyzer": "^15.3.1", + "@playwright/test": "^1.52.0", "@storybook/addon-controls": "^8.6.12", "@storybook/addon-essentials": "^8.6.12", "@storybook/addon-interactions": "^8.6.12", @@ -113,6 +118,7 @@ "@tailwindcss/postcss": "4.1.4", "@testing-library/react": "^16.3.0", "@types/canvas-confetti": "^1.9.0", + "@types/dotenv": "^6.1.1", "@types/js-cookie": "^3.0.6", "@types/js-yaml": "^4.0.9", "@types/lodash": "^4.17.16", @@ -123,6 +129,7 @@ "@types/react-dom": "^18", "@types/react-scroll-to-bottom": "^4.2.5", "@types/react-syntax-highlighter": "^15.5.13", + "dotenv": "^16.5.0", "eslint": "^9", "eslint-config-next": "15.3.1", "eslint-config-prettier": "^10.1.2", diff --git a/web/playwright-tests/example.spec.ts b/web/playwright-tests/example.spec.ts new file mode 100644 index 000000000..54a906a4e --- /dev/null +++ b/web/playwright-tests/example.spec.ts @@ -0,0 +1,18 @@ +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/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 000000000..03feaa7a8 --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,80 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +import dotenv from 'dotenv'; +dotenv.config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './playwright-tests', + /* 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 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* 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://localhost:3000', + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + /* Capture screenshot on failure */ + screenshot: 'only-on-failure' + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] } + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] } + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] } + } + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'npm run dev', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000 // 120 seconds + } +}); diff --git a/web/src/layouts/ChatLayout/ChatLayoutContext/useGetChatParams.test.tsx b/web/src/layouts/ChatLayout/ChatLayoutContext/useGetChatParams.test.tsx index d15730df1..3076d43a5 100644 --- a/web/src/layouts/ChatLayout/ChatLayoutContext/useGetChatParams.test.tsx +++ b/web/src/layouts/ChatLayout/ChatLayoutContext/useGetChatParams.test.tsx @@ -8,7 +8,8 @@ jest.mock('next/navigation', () => ({ useParams: jest.fn(), useSearchParams: jest.fn(() => ({ get: jest.fn() - })) + })), + usePathname: jest.fn() })); jest.mock('@/context/BusterAppLayout', () => ({ @@ -26,6 +27,7 @@ describe('useGetChatParams', () => { get: jest.fn().mockReturnValue(null) })); mockUseAppLayoutContextSelector.mockReturnValue('default-route'); + (navigation.usePathname as jest.Mock).mockReturnValue('/'); }); test('returns undefined values when no params are provided', () => { @@ -43,7 +45,7 @@ describe('useGetChatParams', () => { messageId: undefined, metricVersionNumber: undefined, dashboardVersionNumber: undefined, - currentRoute: 'default-route', + currentRoute: '/', secondaryView: null }); }); @@ -132,14 +134,6 @@ describe('useGetChatParams', () => { expect(result.current.datasetId).toBe('dataset-456'); }); - test('preserves current route from app layout context', () => { - mockUseAppLayoutContextSelector.mockReturnValue('custom-route'); - - const { result } = renderHook(() => useGetChatParams()); - - expect(result.current.currentRoute).toBe('custom-route'); - }); - test('returns consistent values on multiple renders without param changes', () => { mockUseParams.mockReturnValue({ chatId: 'chat-123',