From 08f0cb6402670fc123ba4af69bb7b71e960368d8 Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Date: Wed, 8 Apr 2026 02:38:17 +0200 Subject: [PATCH] feat: comprehensive testing infrastructure - Add vitest with solid plugin, coverage, jsdom environment - Create MSW mocks for API responses in test setup - Add unit test for ProfessionAdminListPage - Add Playwright accessibility and visual regression configs - Add sample accessibility and visual tests - Add ESLint + Prettier configs with SolidJS rules - Update scripts: test, test:coverage, test:accessibility, test:visual - Add .gitignore entries for coverage, test-results, playwright-report, .vitest - Install required dev dependencies: vitest, @solidjs/testing-library, msw, eslint, prettier, typescript, @axe-core/playwright, etc. - Create .github/workflows/ci.yml with lint, test, coverage, e2e, accessibility, visual checks This sets up full testing pipeline for admin frontend. --- .eslintrc.cjs | 68 +++++++ .github/workflows/ci.yml | 91 +++++++++ .gitignore | 2 + .prettierrc | 10 + package.json | 16 +- playwright.a11y.config.ts | 23 +++ playwright.visual.config.ts | 21 ++ src/test/setup.ts | 64 ++++++ tests/e2e/accessibility.spec.ts | 21 ++ tests/e2e/visual/pages.spec.ts | 16 ++ .../ProfessionAdminListPage.test.tsx | 182 ++++++++++++++++++ vitest.config.ts | 11 +- 12 files changed, 519 insertions(+), 6 deletions(-) create mode 100644 .eslintrc.cjs create mode 100644 .github/workflows/ci.yml create mode 100644 .prettierrc create mode 100644 playwright.a11y.config.ts create mode 100644 playwright.visual.config.ts create mode 100644 src/test/setup.ts create mode 100644 tests/e2e/accessibility.spec.ts create mode 100644 tests/e2e/visual/pages.spec.ts create mode 100644 tests/vitest/components/ProfessionAdminListPage.test.tsx diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..002e016 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,68 @@ +/* eslint-env node */ +require("@typescript-eslint/eslint-recommended"); +require("@typescript-eslint/parser"); +require("eslint-plugin-solid"); + +module.exports = { + root: true, + parser: "@typescript-eslint/parser", + plugins: ["solid"], + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:solid/recommended", + ], + overrides: [ + { + files: ["**/*.tsx"], + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + settings: { + solid: { + // SolidJS specific options + noImportReact: true, + }, + }, + rules: { + "solid/hyperscript": "off", + "solid/jsx-no-undef": "error", + "solid/jsx-pseudo-element": "error", + "solid/jsx-single-root-elem": "error", + "solid/no-dynamic-mount": "error", + "solid/no-hyphen-in-props": "error", + "solid/no-leaked-event-handlers": "error", + "solid/no-react-unknown-property": "error", + "solid/no-unknown-property": "error", + "solid/no-useless-fragment": "error", + "solid/prefer-classlist": "error", + "solid/prefer-destructuring": "warn", + "solid/prefer-innerhtml": "warn", + }, + }, + ], + rules: { + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], + "@typescript-eslint/no-explicit-any": "warn", + "arrow-body-style": ["warn", "as-needed"], + " curly": ["error", "multi-line"], + "no-console": "off", + "no-debugger": "warn", + "no-unused-vars": "off", + "prefer-const": "error", + }, + env: { + browser: true, + }, + ignorePatterns: [ + "dist", + ".output", + "node_modules", + "coverage", + "test-results", + "playwright-report", + ], +}; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6a04c1a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,91 @@ +name: Admin Frontend CI + +on: + pull_request: + branches: [high-performance] + push: + branches: [high-performance] + +jobs: + lint-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Run ESLint + run: npx eslint . --ext .ts,.tsx + + - name: Check Prettier formatting + run: npx prettier --check . + + - name: TypeScript type check + run: npx tsc --noEmit + + - name: Run Vitest unit tests + run: npm run test:coverage + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + files: coverage/lcov.info + fail_ci_if_error: false + + - name: Build application + run: npm run build + env: + NODE_ENV: production + + e2e-tests: + runs-on: ubuntu-latest + needs: lint-and-test + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Build app for E2E + run: npm run build + env: + NODE_ENV: production + + - name: Start server (preview) + run: npm run start:3000 & + env: + HOST: 0.0.0.0 + PORT: 3000 + + - name: Wait for server + run: npx wait-on http://localhost:3000 + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Run Playwright accessibility tests + run: npm run test:accessibility + + - name: Run Playwright visual regression + run: npm run test:visual + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report-admin + path: playwright-report/ + retention-days: 14 diff --git a/.gitignore b/.gitignore index 6b71ec9..6adfad5 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,5 @@ storybook-static playwright-report test-results tests/visual-artifacts +coverage +.vitest diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..4e083b2 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,10 @@ +{ + "semi": true, + "singleQuote": false, + "tabWidth": 2, + "trailingComma": "es5", + "useTabs": false, + "printWidth": 100, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/package.json b/package.json index c4f33bc..f349409 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,9 @@ "admin:stop:3000": "bash ./scripts/admin-3000-service.sh stop", "admin:status:3000": "bash ./scripts/admin-3000-service.sh status", "admin:start:3000": "bash ./scripts/admin-3000-service.sh start", - "test": "node --test --experimental-strip-types src/lib/**/*.test.ts", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:visual": "playwright test tests/e2e/admin-visual.spec.ts --reporter=list --workers=1", @@ -19,7 +21,8 @@ "test:external:flow": "playwright test -c playwright.external.config.ts tests/e2e/external-user-flow.spec.ts --reporter=list --workers=1", "test:external:roles": "playwright test -c playwright.external.config.ts tests/e2e/external-roles-onboarding-dashboard.spec.ts --reporter=list --workers=1", "test:external:screenshots": "playwright test -c playwright.external.config.ts tests/e2e/external-role-screenshots.spec.ts --reporter=list --workers=1", - "test:vitest": "vitest run", + "test:accessibility": "playwright test --config=playwright.a11y.config.ts", + "test:visual:regression": "playwright test --config=playwright.visual.config.ts", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", "qa:visbug": "bash ./scripts/visbug-storybook.sh", @@ -41,19 +44,26 @@ "node": ">=20" }, "devDependencies": { + "@axe-core/playwright": "^1.7.0", "@chromatic-com/storybook": "^5.1.0", "@playwright/test": "^1.58.2", + "@solidjs/testing-library": "^0.8.0", "@storybook/addon-a11y": "^10.3.3", "@storybook/addon-docs": "^10.3.3", "@storybook/addon-vitest": "^10.3.3", + "@testing-library/jest-dom": "^6.6.3", "@vitest/browser-playwright": "^4.1.1", "@vitest/coverage-v8": "^4.1.1", + "jsdom": "^25.0.1", + "msw": "^2.7.3", "pixelmatch": "^7.1.0", "playwright": "^1.58.2", "pngjs": "^7.0.0", "storybook": "^10.3.3", "storybook-solidjs-vite": "^10.0.11", "visbug": "^0.1.14", - "vitest": "^4.1.1" + "vitest": "^4.1.1", + "vitest-plugin-solid": "^0.2.0", + "typescript": "^5.5.0" } } diff --git a/playwright.a11y.config.ts b/playwright.a11y.config.ts new file mode 100644 index 0000000..7e00921 --- /dev/null +++ b/playwright.a11y.config.ts @@ -0,0 +1,23 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 4 : undefined, + reporter: "html", + use: { + baseURL: "http://localhost:3000", + trace: "on-first-retry", + screenshot: "on", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + // Run accessibility tests + // Use `npx playwright test --config=playwright.a11y.config.ts` +}); diff --git a/playwright.visual.config.ts b/playwright.visual.config.ts new file mode 100644 index 0000000..088991f --- /dev/null +++ b/playwright.visual.config.ts @@ -0,0 +1,21 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/e2e/visual", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 4 : undefined, + reporter: "list", + use: { + baseURL: "http://localhost:3000", + screenshot: "only-on-failure", + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 0000000..34899be --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1,64 @@ +import '@testing-library/jest-dom'; +import { beforeAll, afterEach, afterAll } from 'vitest'; +import { setupServer } from 'msw/node'; +import { rest } from 'msw'; + +// Mock API responses globally for frontend tests +const server = setupServer( + rest.get('/api/admin/companies', (req, res, ctx) => { + return res.once(200, ctx.json([ + { id: '1', company_name: 'Test Co', status: 'ACTIVE' } + ])); + }), + rest.get('/api/admin/users', (req, res, ctx) => { + return res.once(200, ctx.json([ + { id: '1', full_name: 'Admin User', email: 'admin@example.com', status: 'ACTIVE' } + ])); + }), + rest.get('/api/admin/jobs', (req, res, ctx) => { + return res.once(200, ctx.json([ + { id: '1', title: 'Developer', status: 'OPEN' } + ])); + }), + rest.get('/api/admin/leads', (req, res, ctx) => { + return res.once(200, ctx.json([ + { id: '1', title: 'Need a developer', status: 'PENDING' } + ])); + }) +); + +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +// Mock window.matchMedia +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation(query => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}); + +// Mock IntersectionObserver +global.IntersectionObserver = class IntersectionObserver { + constructor() {} + disconnect() {} + observe() {} + takeRecords() { return []; } + trigger() {} +}; + +// Mock ResizeObserver +global.ResizeObserver = class ResizeObserver { + constructor() {} + observe() {} + unobserve() {} + disconnect() {} +}; diff --git a/tests/e2e/accessibility.spec.ts b/tests/e2e/accessibility.spec.ts new file mode 100644 index 0000000..48ca3f4 --- /dev/null +++ b/tests/e2e/accessibility.spec.ts @@ -0,0 +1,21 @@ +import { test, expect } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; + +test.describe("Accessibility Tests", () => { + test("login page should have no accessibility violations", async ({ page }) => { + await page.goto("/admin/login"); + const results = await new AxeBuilder({ page }).analyze(); + expect(results.violations).toEqual([]); + }); + + test("dashboard page should be accessible after login", async ({ page }) => { + // Mock login (or use real credentials via env) + await page.goto("/admin/login"); + await page.fill('input[type="email"]', "admin@example.com"); + await page.fill('input[type="password"]', "password"); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL("/admin"); + const results = await new AxeBuilder({ page }).analyze(); + expect(results.violations).toEqual([]); + }); +}); diff --git a/tests/e2e/visual/pages.spec.ts b/tests/e2e/visual/pages.spec.ts new file mode 100644 index 0000000..844c126 --- /dev/null +++ b/tests/e2e/visual/pages.spec.ts @@ -0,0 +1,16 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Visual Regression - Admin Pages", () => { + test("company management page should match baseline", async ({ page }) => { + await page.goto("/admin/company"); + // Wait for table to load + await expect(page.locator("table")).toBeVisible({ timeout: 10000 }); + await expect(page).toHaveScreenshot({ maxDiffPixelRatio: 0.1 }); + }); + + test("jobs management page should match baseline", async ({ page }) => { + await page.goto("/admin/jobs"); + await expect(page.locator("table")).toBeVisible({ timeout: 10000 }); + await expect(page).toHaveScreenshot({ maxDiffPixelRatio: 0.1 }); + }); +}); diff --git a/tests/vitest/components/ProfessionAdminListPage.test.tsx b/tests/vitest/components/ProfessionAdminListPage.test.tsx new file mode 100644 index 0000000..302fa86 --- /dev/null +++ b/tests/vitest/components/ProfessionAdminListPage.test.tsx @@ -0,0 +1,182 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@solidjs/testing-library"; +import { createSignal } from "solid-js"; +import ProfessionAdminListPage from "~/components/admin/ProfessionAdminListPage"; + +// Mock fetch globally +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +describe("ProfessionAdminListPage", () => { + beforeEach(() => { + mockFetch.mockClear(); + }); + + it("renders page title and subtitle", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => [ + { + id: "1", + first_name: "John", + last_name: "Doe", + email: "john@example.com", + phone: "1234567890", + status: "ACTIVE", + created_at: "2024-01-01T00:00:00Z", + }, + ], + }); + + render(() => + ProfessionAdminListPage({ + endpoint: "/api/admin/test", + title: "Test Management", + subtitle: "Manage test records", + emptyLabel: "No test records found.", + viewHref: (id) => `/admin/test/${id}`, + }) + ); + + await waitFor(() => { + expect(screen.getByText("Test Management")).toBeInTheDocument(); + expect(screen.getByText("Manage test records")).toBeInTheDocument(); + }); + }); + + it("filters by search query", async () => { + const mockData = [ + { + id: "1", + first_name: "Alice", + last_name: "Smith", + email: "alice@example.com", + phone: "", + status: "ACTIVE", + created_at: "2024-01-01T00:00:00Z", + }, + { + id: "2", + first_name: "Bob", + last_name: "Jones", + email: "bob@example.com", + phone: "", + status: "INACTIVE", + created_at: "2024-01-02T00:00:00Z", + }, + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockData, + }); + + render(() => + ProfessionAdminListPage({ + endpoint: "/api/admin/test", + title: "Test", + subtitle: "", + emptyLabel: "Empty", + viewHref: (id) => `/admin/test/${id}`, + }) + ); + + await waitFor(() => { + expect(screen.getByText("Alice Smith")).toBeInTheDocument(); + expect(screen.getByText("Bob Jones")).toBeInTheDocument(); + }); + + const input = screen.getByPlaceholderText("Search by name or email..."); + fireEvent.input(input, { target: { value: "alice" } }); + + await waitFor(() => { + expect(screen.getByText("Alice Smith")).toBeInTheDocument(); + expect(screen.queryByText("Bob Jones")).not.toBeInTheDocument(); + }); + }); + + it("shows empty state when no data", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => [], + }); + + render(() => + ProfessionAdminListPage({ + endpoint: "/api/admin/test", + title: "Test", + subtitle: "", + emptyLabel: "No records.", + viewHref: (id) => `/admin/test/${id}`, + }) + ); + + await waitFor(() => { + expect(screen.getByText("No records.")).toBeInTheDocument(); + }); + }); + + it("handles fetch error gracefully", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + }); + + render(() => + ProfessionAdminListPage({ + endpoint: "/api/admin/test", + title: "Test", + subtitle: "", + emptyLabel: "Empty", + viewHref: (id) => `/admin/test/${id}`, + }) + ); + + await waitFor(() => { + expect(screen.getByText(/Failed to load/)).toBeInTheDocument(); + }); + }); + + it("export button calls exportCsv and downloads file", async () => { + const mockData = [ + { + id: "1", + first_name: "Alice", + last_name: "Smith", + email: "alice@example.com", + phone: "123", + status: "ACTIVE", + created_at: "2024-01-01T00:00:00Z", + }, + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockData, + }); + + // Mock URL.createObjectURL and link.click + const mockClick = vi.fn(); + global.URL.createObjectURL = vi.fn(() => "blob:test"); + global.document.createElement = vi.fn(() => ({ click: mockClick, href: "" })); + + render(() => + ProfessionAdminListPage({ + endpoint: "/api/admin/test", + title: "Test", + subtitle: "", + emptyLabel: "Empty", + viewHref: (id) => `/admin/test/${id}`, + }) + ); + + await waitFor(() => { + expect(screen.getByText("Alice Smith")).toBeInTheDocument(); + }); + + const exportBtn = screen.getByText("Export"); + fireEvent.click(exportBtn); + + expect(mockClick).toHaveBeenCalled(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 1687280..5f3ea3a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,13 +1,18 @@ import { defineConfig } from 'vitest/config'; +import solid from 'vitest-plugin-solid'; export default defineConfig({ + plugins: [solid()], test: { - include: ['tests/vitest/**/*.spec.ts'], - environment: 'node', + globals: true, + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + include: ['src/**/*.{test,spec}.{js,mjs,ts,tsx}', 'tests/vitest/**/*.spec.ts'], coverage: { provider: 'v8', - reporter: ['text', 'html'], + reporter: ['text', 'json', 'html', 'lcov'], reportsDirectory: './test-results/vitest-coverage', + exclude: ['node_modules', 'dist', '.output', '**/*.d.ts'], }, }, });