diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..c59d2a8 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,67 @@ +/* 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: { + 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/.gitignore b/.gitignore index 364f984..fd88a74 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ dist/ *storybook.log storybook-static +coverage +test-results +playwright-report +.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 8c5a0ff..15c86ef 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,10 @@ "start": "vinxi start", "test": "vitest run", "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "test:e2e": "playwright test", + "test:accessibility": "playwright test --config=playwright.a11y.config.ts", + "test:visual": "playwright test --config=playwright.visual.config.ts", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build" }, @@ -20,24 +24,34 @@ "vinxi": "^0.5.7" }, "devDependencies": { + "@axe-core/playwright": "^1.8.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", - "@storybook/cli": "^10.3.3", - "@tailwindcss/vite": "^4.2.2", + "@testing-library/jest-dom": "^6.6.3", "@vitest/browser": "^3.2.4", "@vitest/coverage-v8": "^3.2.4", "eslint": "^10.1.0", + "jsdom": "^25.0.1", "loki": "^0.35.1", + "msw": "^2.7.3", + "@mswjs/data": "^0.16.2", "pixelmatch": "^7.1.0", "playwright": "^1.58.2", + "pngjs": "^7.0.0", "storybook": "^10.3.3", "storybook-solidjs-vite": "^10.0.11", "tailwindcss": "^4.2.2", "visbug": "^0.1.14", - "vitest": "^3.2.4" + "vitest": "^4.1.1", + "vitest-plugin-solid": "^0.2.0", + "@typescript-eslint/parser": "^7.0.0", + "@typescript-eslint/eslint-plugin": "^7.0.0", + "eslint-plugin-solid": "^1.8.0", + "prettier": "^3.0.0" }, "engines": { "node": ">=20" diff --git a/playwright.a11y.config.ts b/playwright.a11y.config.ts new file mode 100644 index 0000000..16b3702 --- /dev/null +++ b/playwright.a11y.config.ts @@ -0,0 +1,21 @@ +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:5173", + trace: "on-first-retry", + screenshot: "on", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/playwright.visual.config.ts b/playwright.visual.config.ts new file mode 100644 index 0000000..2e2b8a4 --- /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:5173", + 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..bfd9152 --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1,60 @@ +import "@testing-library/jest-dom"; +import { beforeAll, afterEach, afterAll } from "vitest"; +import { setupServer } from "msw/node"; +import { rest } from "msw"; + +// Mock API responses +const server = setupServer( + rest.get("/api/users/public", (req, res, ctx) => { + return res.once( + 200, + ctx.json([{ id: "1", name: "Public User", email: "user@example.com" }]), + ); + }), + rest.get("/api/jobs", (req, res, ctx) => { + return res.once( + 200, + ctx.json({ + jobs: [{ id: "1", title: "Developer", status: "OPEN" }], + }), + ); + }), +); + +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..3d3ac00 --- /dev/null +++ b/tests/e2e/accessibility.spec.ts @@ -0,0 +1,16 @@ +import { test, expect } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; + +test.describe("Public Frontend Accessibility", () => { + test("homepage should have no accessibility violations", async ({ page }) => { + await page.goto("/"); + const results = await new AxeBuilder({ page }).analyze(); + expect(results.violations).toEqual([]); + }); + + test("jobs listing page should be accessible", async ({ page }) => { + await page.goto("/jobs"); + 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..4a3a078 --- /dev/null +++ b/tests/e2e/visual/pages.spec.ts @@ -0,0 +1,15 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Visual Regression - Public Pages", () => { + test("homepage should match baseline", async ({ page }) => { + await page.goto("/"); + await expect(page.locator("main")).toBeVisible({ timeout: 10000 }); + await expect(page).toHaveScreenshot({ maxDiffPixelRatio: 0.1 }); + }); + + test("jobs page should match baseline", async ({ page }) => { + await page.goto("/jobs"); + await expect(page.locator("main")).toBeVisible({ timeout: 10000 }); + await expect(page).toHaveScreenshot({ maxDiffPixelRatio: 0.1 }); + }); +}); diff --git a/tests/vitest/components/PublicFooter.test.tsx b/tests/vitest/components/PublicFooter.test.tsx new file mode 100644 index 0000000..3b354ec --- /dev/null +++ b/tests/vitest/components/PublicFooter.test.tsx @@ -0,0 +1,23 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@solidjs/testing-library"; +import PublicFooter from "~/components/PublicFooter"; + +// Mock any external dependencies if needed + +describe("PublicFooter", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders footer with copyright text", () => { + render(() => PublicFooter()); + expect(screen.getByText(/© \d{4} NXTGAUGE/)).toBeInTheDocument(); + }); + + it("renders links", () => { + render(() => PublicFooter()); + expect(screen.getByRole("link", { name: /terms/i })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /privacy/i })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /contact/i })).toBeInTheDocument(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..fb18ba4 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "vitest/config"; +import solid from "vitest-plugin-solid"; + +export default defineConfig({ + plugins: [solid()], + test: { + globals: true, + environment: "jsdom", + setupFiles: ["./src/test/setup.ts"], + include: ["src/**/*.{test,spec}.{js,mjs,ts,tsx}"], + coverage: { + provider: "v8", + reporter: ["text", "json", "html", "lcov"], + reportsDirectory: "./test-results/vitest-coverage", + exclude: ["node_modules", "dist", ".output", "**/*.d.ts"], + }, + }, +});