diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000..4102267 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,85 @@ +name: playwright + +on: + pull_request: + paths: + - "ui/**" + - ".github/workflows/playwright.yml" + push: + branches: [main] + paths: + - "ui/**" + - ".github/workflows/playwright.yml" + +permissions: read-all + +concurrency: + group: playwright-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + e2e: + name: e2e (chromium) + runs-on: ubuntu-latest + permissions: + contents: read + timeout-minutes: 15 + defaults: + run: + working-directory: ui + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: ui/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Derive Playwright version + id: pw + run: | + set -eu + ver=$(node -p "require('@playwright/test/package.json').version") + echo "version=$ver" >> "$GITHUB_OUTPUT" + + - name: Cache Playwright browsers + id: pw-cache + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ steps.pw.outputs.version }} + + - name: Install Playwright browser (chromium) + if: steps.pw-cache.outputs.cache-hit != 'true' + run: npx playwright install --with-deps chromium + + - name: Install system deps for cached browser + if: steps.pw-cache.outputs.cache-hit == 'true' + run: npx playwright install-deps chromium + + - name: Run Playwright tests + env: + CI: "1" + run: npm run e2e + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: playwright-report + path: ui/playwright-report + retention-days: 7 + if-no-files-found: ignore + + - name: Upload failure traces + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: playwright-traces + path: ui/test-results + retention-days: 7 + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 3d45ddc..b04805f 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,12 @@ ui/.vite/ ui/coverage/ ui/*.tsbuildinfo +# Frontend — Playwright E2E artifacts (generated, never committed) +ui/playwright-report/ +ui/test-results/ +ui/blob-report/ +ui/playwright/.cache/ + # Environment — never commit secrets .env .env.* diff --git a/ui/e2e/fixtures.ts b/ui/e2e/fixtures.ts new file mode 100644 index 0000000..e95b5c7 --- /dev/null +++ b/ui/e2e/fixtures.ts @@ -0,0 +1,47 @@ +import { test as base, expect, type Page } from "@playwright/test"; + +// Match only backend paths — anchored to pathname start so Vite's dev-server +// module requests under src/hooks/api/** are not stubbed. +const API_PATH = /^\/api\//; +const MCP_PATH = /^\/mcp\//; + +async function stubApi(page: Page) { + await page.route( + (url) => API_PATH.test(url.pathname), + async (route) => { + const path = new URL(route.request().url()).pathname; + + if (/\/notes(\?|$)/.test(path) || /\/documents(\?|$)/.test(path) || /\/activity(\?|$)/.test(path)) { + return route.fulfill({ status: 200, contentType: "application/json", body: "[]" }); + } + if (/\/stats$/.test(path)) { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ notes: 0, documents: 0, entities: 0, relationships: 0 }), + }); + } + if (/\/search/.test(path)) { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ notes: [], docs: [] }), + }); + } + return route.fulfill({ status: 200, contentType: "application/json", body: "{}" }); + }, + ); + await page.route( + (url) => MCP_PATH.test(url.pathname), + (route) => route.fulfill({ status: 200, contentType: "application/json", body: "{}" }), + ); +} + +export const test = base.extend<{ stubbedPage: Page }>({ + stubbedPage: async ({ page }, use) => { + await stubApi(page); + await use(page); + }, +}); + +export { expect }; diff --git a/ui/e2e/smoke.spec.ts b/ui/e2e/smoke.spec.ts new file mode 100644 index 0000000..cb6414e --- /dev/null +++ b/ui/e2e/smoke.spec.ts @@ -0,0 +1,50 @@ +import { test, expect } from "./fixtures"; + +// The Shell renders the primary landmark as `
`; selecting by id +// avoids strict-mode ambiguity with any other
rendered by shadcn sidebar. +const main = (page: import("@playwright/test").Page) => page.locator("main#main"); + +test.describe("smoke", () => { + test("home renders shell and main landmark", async ({ stubbedPage: page }) => { + await page.goto("/"); + await expect(main(page)).toBeVisible(); + await expect(page).toHaveTitle(/docsiq/i); + }); + + test("command palette opens with Ctrl+K and closes with Escape", async ({ stubbedPage: page }) => { + await page.goto("/"); + await main(page).waitFor(); + await page.keyboard.press("ControlOrMeta+k"); + const palette = page.getByPlaceholder(/search notes, docs, entities/i); + await expect(palette).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(palette).not.toBeVisible(); + }); + + test("command palette navigates to Documents", async ({ stubbedPage: page }) => { + await page.goto("/"); + await main(page).waitFor(); + await page.keyboard.press("ControlOrMeta+k"); + await page.getByPlaceholder(/search notes, docs, entities/i).waitFor(); + await page.getByRole("option", { name: /^documents$/i }).click(); + await expect(page).toHaveURL(/\/docs$/); + }); + + test("chord hotkey g,g navigates to Graph", async ({ stubbedPage: page }) => { + await page.goto("/"); + await main(page).waitFor(); + await page.keyboard.press("g"); + await page.keyboard.press("g"); + await expect(page).toHaveURL(/\/graph$/); + }); + + test("theme toggle switches to light and persists on reload", async ({ stubbedPage: page }) => { + await page.goto("/"); + await main(page).waitFor(); + await page.getByRole("button", { name: /change theme/i }).click(); + await page.getByRole("menuitem", { name: /^light$/i }).click(); + await expect(page.locator("html")).not.toHaveClass(/dark/); + await page.reload(); + await expect(page.locator("html")).not.toHaveClass(/dark/); + }); +}); diff --git a/ui/package-lock.json b/ui/package-lock.json index 39e94b7..9f3faa9 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -55,6 +55,7 @@ "zustand": "^5.0.2" }, "devDependencies": { + "@playwright/test": "^1.59.1", "@tailwindcss/vite": "^4.0.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -1613,6 +1614,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -7407,6 +7424,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": 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==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.10", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", diff --git a/ui/package.json b/ui/package.json index 46f939d..994d721 100644 --- a/ui/package.json +++ b/ui/package.json @@ -11,7 +11,10 @@ "test:watch": "vitest", "test:coverage": "vitest run --coverage", "typecheck": "tsc -b --noEmit", - "lint": "eslint src" + "lint": "eslint src", + "e2e": "playwright test", + "e2e:ui": "playwright test --ui", + "e2e:install": "playwright install --with-deps chromium" }, "dependencies": { "@codemirror/commands": "^6.7.0", @@ -61,6 +64,7 @@ "zustand": "^5.0.2" }, "devDependencies": { + "@playwright/test": "^1.59.1", "@tailwindcss/vite": "^4.0.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts new file mode 100644 index 0000000..c47723f --- /dev/null +++ b/ui/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from "@playwright/test"; + +const PORT = Number(process.env.PLAYWRIGHT_PORT ?? 5173); +const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? `http://localhost:${PORT}`; +const isCI = !!process.env.CI; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + forbidOnly: isCI, + retries: isCI ? 1 : 0, + workers: isCI ? 2 : undefined, + reporter: isCI ? [["github"], ["html", { open: "never" }]] : "list", + timeout: 30_000, + expect: { timeout: 5_000 }, + use: { + baseURL: BASE_URL, + trace: "on-first-retry", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + projects: [ + { name: "chromium", use: { ...devices["Desktop Chrome"] } }, + ], + webServer: process.env.PLAYWRIGHT_SKIP_WEBSERVER + ? undefined + : { + command: `npm run dev -- --port ${PORT} --strictPort`, + url: BASE_URL, + reuseExistingServer: !isCI, + timeout: 60_000, + stdout: "ignore", + stderr: "pipe", + }, +}); diff --git a/ui/tsconfig.e2e.json b/ui/tsconfig.e2e.json new file mode 100644 index 0000000..ee9d3ba --- /dev/null +++ b/ui/tsconfig.e2e.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["node"], + "strict": true, + "skipLibCheck": true, + "isolatedModules": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "noEmit": true, + "paths": { "@/*": ["./src/*"] } + }, + "include": ["e2e/**/*.ts", "playwright.config.ts"] +} diff --git a/ui/tsconfig.json b/ui/tsconfig.json index 0ad4751..0b34e9b 100644 --- a/ui/tsconfig.json +++ b/ui/tsconfig.json @@ -2,7 +2,8 @@ "files": [], "references": [ { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.e2e.json" } ], "compilerOptions": { "baseUrl": ".", diff --git a/ui/vitest.config.ts b/ui/vitest.config.ts index a5a3b57..370ad40 100644 --- a/ui/vitest.config.ts +++ b/ui/vitest.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ environment: "jsdom", globals: true, setupFiles: ["./src/setupTests.ts"], + exclude: ["node_modules/**", "dist/**", "e2e/**"], coverage: { reporter: ["text", "html"], include: [