diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc207f0..44f6835 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,7 +54,35 @@ jobs: cache: true - name: Run tests - run: vp run --filter @camp.dev/bones test -- --run + run: vp run --filter @camp.dev/bones test -- --run --project unit + + bones-visual: + name: "Bones: Visual Regression" + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + cache: true + + - name: Install Playwright Chromium + working-directory: packages/bones + run: vp exec playwright install --with-deps chromium + + - name: Run browser tests + run: vp run --filter @camp.dev/bones test -- --run --project browser + + - name: Upload screenshots + if: failure() + uses: actions/upload-artifact@v4 + with: + name: bones-visual-output + path: | + packages/bones/tests/browser/__screenshots__/ + packages/bones/.vitest-attachments/ # ── Demo app ─────────────────────────────────────────────── demo-check: diff --git a/.gitignore b/.gitignore index 05346ea..02d12ef 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ test-results .fallow docs/superpowers/ .superpowers -.worktrees \ No newline at end of file +.worktrees +packages/bones/tests/browser/__screenshots__/**/*-darwin.png \ No newline at end of file diff --git a/packages/bones/package.json b/packages/bones/package.json index cd8485d..b787eec 100644 --- a/packages/bones/package.json +++ b/packages/bones/package.json @@ -43,7 +43,7 @@ "dev": "vp pack --watch", "test": "vp test", "check": "vp check", - "health": "vp test --coverage && fallow health --root . --coverage coverage/coverage-final.json --format json --quiet", + "health": "vp test --project unit --coverage && fallow health --root . --coverage coverage/coverage-final.json --format json --quiet", "prepublishOnly": "vp run build" }, "devDependencies": { @@ -55,6 +55,7 @@ "@typescript/native-preview": "7.0.0-dev.20260328.1", "@vitest/coverage-v8": "^4.1.5", "jsdom": "^29.0.2", + "playwright": "^1.62.1", "react": "^19.2.5", "typescript": "^6.0.2", "vite-plus": "^0.1.14" diff --git a/packages/bones/tests/browser/__screenshots__/visual.test.tsx/bare-busy-chromium-linux.png b/packages/bones/tests/browser/__screenshots__/visual.test.tsx/bare-busy-chromium-linux.png new file mode 100644 index 0000000..cec0144 Binary files /dev/null and b/packages/bones/tests/browser/__screenshots__/visual.test.tsx/bare-busy-chromium-linux.png differ diff --git a/packages/bones/tests/browser/__screenshots__/visual.test.tsx/element-force-chromium-linux.png b/packages/bones/tests/browser/__screenshots__/visual.test.tsx/element-force-chromium-linux.png new file mode 100644 index 0000000..cec0144 Binary files /dev/null and b/packages/bones/tests/browser/__screenshots__/visual.test.tsx/element-force-chromium-linux.png differ diff --git a/packages/bones/tests/browser/__screenshots__/visual.test.tsx/react-force-chromium-linux.png b/packages/bones/tests/browser/__screenshots__/visual.test.tsx/react-force-chromium-linux.png new file mode 100644 index 0000000..f7cb7bd Binary files /dev/null and b/packages/bones/tests/browser/__screenshots__/visual.test.tsx/react-force-chromium-linux.png differ diff --git a/packages/bones/tests/browser/smoke.test.ts b/packages/bones/tests/browser/smoke.test.ts new file mode 100644 index 0000000..445bd6e --- /dev/null +++ b/packages/bones/tests/browser/smoke.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from "vite-plus/test"; + +// Proves the browser project runs in a real layout engine: jsdom returns +// all-zero rects, Chromium cannot. +test("layout engine is real", () => { + const el = document.createElement("div"); + el.textContent = "measure me"; + el.style.font = "16px/1.5 monospace"; + document.body.append(el); + const rect = el.getBoundingClientRect(); + expect(rect.height).toBeGreaterThan(0); + expect(rect.width).toBeGreaterThan(0); + el.remove(); +}); diff --git a/packages/bones/tests/browser/visual.test.tsx b/packages/bones/tests/browser/visual.test.tsx new file mode 100644 index 0000000..d9bbb16 --- /dev/null +++ b/packages/bones/tests/browser/visual.test.tsx @@ -0,0 +1,90 @@ +/// +import type { ReactNode } from "react"; +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, expect, test } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; +import "../../src/element/index.ts"; +import "../../src/css/auto.css"; +import { createBones, forceBones } from "../../src/react/index.ts"; + +// --------------------------------------------------------------------------- +// One card, three renderers. auto.css restates the text-bar geometry from +// bones.css and only a selector-text test pins the copies; these screenshots +// pin what a user actually sees. data-bone-animate="none" everywhere so the +// shimmer cannot make an image non-deterministic. +// --------------------------------------------------------------------------- + +const CARD_STYLE = "width: 320px; padding: 16px; font: 16px/1.5 sans-serif; background: #fff;"; + +const roots: Root[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) root.unmount(); + document.body.innerHTML = ""; +}); + +function mountHtml(html: string): HTMLElement { + document.body.insertAdjacentHTML("beforeend", html); + return document.body.lastElementChild as HTMLElement; +} + +function renderReact(node: ReactNode): HTMLElement { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + roots.push(root); + flushSync(() => root.render(node)); + return host.firstElementChild as HTMLElement; +} + +function ReactCard() { + const { bone } = createBones<{ title: string }>(forceBones); + return ( +
+
+

Placeholder title

+

Placeholder body copy that runs a little longer.

+
+ ); +} + +const CONTENT = ` + +

Real title

+

Real body copy that runs a little longer than one line at this width.

+ +`; + +// Each `toMatchScreenshot` call below needs a suppression for the same +// upstream typing gap documented on the `toMatchScreenshot` config key in +// vite.config.ts: vite-plus-test's `declare module "vitest/node"` +// augmentation targets the real `vitest` package, not vite-plus's vendored +// internal copy, so `Assertion` here has no merged type even +// though the matcher exists and passes at runtime (proven by this file). + +test("react renderer with forceBones", async () => { + renderReact(); + // @ts-expect-error — see the file-level comment above. + await expect(page.getByTestId("react-card")).toMatchScreenshot("react-force"); +}); + +test("element renderer with auto.css", async () => { + mountHtml( + `${CONTENT}`, + ); + // @ts-expect-error — see the file-level comment above. + await expect(page.getByTestId("element-card")).toMatchScreenshot("element-force"); +}); + +test("bare aria-busy region with auto.css", async () => { + mountHtml( + `
${CONTENT}
`, + ); + // @ts-expect-error — see the file-level comment above. + await expect(page.getByTestId("bare-card")).toMatchScreenshot("bare-busy"); +}); diff --git a/packages/bones/vite.config.ts b/packages/bones/vite.config.ts index 8f41a84..17a8a33 100644 --- a/packages/bones/vite.config.ts +++ b/packages/bones/vite.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from "vite-plus"; +import { playwright } from "vite-plus/test/browser-playwright"; export default defineConfig({ staged: { @@ -28,11 +29,62 @@ export default defineConfig({ }, }, test: { - environment: "jsdom", coverage: { provider: "v8", include: ["src/**"], }, + projects: [ + { + extends: true, + test: { + name: "unit", + environment: "jsdom", + include: ["tests/*.test.{ts,tsx}"], + }, + }, + { + extends: true, + test: { + name: "browser", + include: ["tests/browser/**/*.test.{ts,tsx}"], + browser: { + enabled: true, + headless: true, + provider: playwright(), + instances: [{ browser: "chromium" }], + expect: { + // @ts-expect-error — vite-plus's vendored `ToMatchScreenshotComparators` + // is never populated by `vite-plus/test/browser-playwright`'s ambient + // `declare module "vitest/node"` augmentation, which targets the real + // `vitest` package rather than vite-plus's internal copy, so this + // (otherwise-valid) config object has no type to check against. + toMatchScreenshot: { + comparatorName: "pixelmatch", + comparatorOptions: { allowedMismatchedPixelRatio: 0.01 }, + // Platform-suffixed baselines: CI (linux) baselines are + // committed; local darwin ones are gitignored (Task 3). + resolveScreenshotPath: ({ + testFileDirectory, + screenshotDirectory, + testFileName, + arg, + browserName, + ext, + }: { + testFileDirectory: string; + screenshotDirectory: string; + testFileName: string; + arg: string; + browserName: string; + ext: string; + }) => + `${testFileDirectory}/${screenshotDirectory}/${testFileName}/${arg}-${browserName}-${process.platform}${ext}`, + }, + }, + }, + }, + }, + ], }, lint: { options: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16ea812..675eef6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,6 +132,9 @@ importers: jsdom: specifier: ^29.0.2 version: 29.0.2(@noble/hashes@1.8.0) + playwright: + specifier: ^1.62.1 + version: 1.62.1 react: specifier: ^19.2.5 version: 19.2.5 @@ -2319,6 +2322,11 @@ packages: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3070,6 +3078,16 @@ packages: resolution: {integrity: sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==} hasBin: true + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + pngjs@7.0.0: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} @@ -5482,6 +5500,9 @@ snapshots: jsonfile: 4.0.0 universalify: 0.1.2 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -6553,6 +6574,14 @@ snapshots: dependencies: pngjs: 7.0.0 + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + pngjs@7.0.0: {} postcss@8.4.31: