Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,44 @@ jobs:
- name: Lint markdown
run: pnpm lint:md

- name: Test
run: pnpm test
- name: Test with coverage
run: pnpm test:coverage

- name: Build
run: pnpm build

e2e:
name: End-to-end tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5

- name: Setup pnpm
uses: pnpm/action-setup@v4

- name: Setup Node
uses: actions/setup-node@v5
with:
node-version-file: .nvmrc
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Install Playwright's Chromium
run: pnpm exec playwright install --with-deps chromium

- name: Build
run: pnpm build

- name: Run Playwright tests
run: pnpm test:e2e

- name: Upload Playwright report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7
85 changes: 85 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Contributing

## Getting started

```sh
pnpm install
pnpm dev
```

Bun works too, if you'd rather not install pnpm: `bun install && bunx --bun run dev`.

## TDD

Domain code (`src/domain/**`) is written test-first: red, green, refactor. A behavior
change starts with a failing spec next to the class it exercises (`__spec__/`), then the
smallest change that passes it, then a cleanup pass with the tests as a safety net. Two
concrete examples from this codebase's own history:

- `LocalStorageWelcomeSurveyRepository.getSurvey()` had a cache bug that made it return
`null` forever. The regression spec (`should read a survey that was written to storage
by another instance`) fails against the old code and passes against the fix, so it
cannot silently come back.
- `Product.getLoreLink`/`getVideoUrl` used to have three branches that all returned the
same expression. Giving each knowledge level distinct fixture data in the spec, instead
of one profile reused three times, is what made the dead branches visible.

## The testing trophy

Effort is weighted toward the middle, not the extremes:

```mermaid
flowchart TD
A["Static analysis: TypeScript, Biome"] --> B["Unit: src/domain/**"]
B --> C["Integration: src/ui/**, real components, mocked storage boundaries"]
C --> D["End-to-end: 2 Playwright smoke specs"]
```

- **Static**: `pnpm typecheck` and `pnpm check` catch a whole class of bugs before a test
even runs.
- **Unit** (`vitest --project unit`, node environment): every domain class, one behavior
per test. This is most of the suite, domain logic has no framework to fight.
- **Integration** (`vitest --project integration`, jsdom): renders real components with
`@testing-library/react` and exercises real user flows: add to cart, change quantity,
the welcome survey save/restore. Framework wiring (React, Radix) is real; only the
storage boundary gets mocked when a scenario the real data can't reach needs it (see
`ShoppingCart.freeShipping.spec.tsx`).
- **End-to-end** (`pnpm test:e2e`, Playwright against a built `vite preview`): two smoke
tests, home to shop and add-to-cart to checkout. Enough to catch a broken build, not a
replacement for the layers below.

## Commands

| Command | What it does |
| ----------------------- | ------------------------------------------------ |
| `pnpm test` | Runs the unit and integration Vitest projects |
| `pnpm test:watch` | Same, in watch mode |
| `pnpm test:coverage` | Runs tests with coverage thresholds enforced |
| `pnpm test:e2e` | Builds nothing itself, run `pnpm build` first, then the 2 Playwright specs |
| `pnpm typecheck` | `tsc --noEmit` across the app, tooling, and tests |
| `pnpm check` | Biome lint + format check |
| `pnpm check:fix` | Biome lint + format, writing fixes |

## Object Calisthenics

The rules this repo tries to hold itself to, and where to see them:

1. One level of indentation per method — early returns throughout `src/domain`
2. Max ~50 lines per class — `Cart`, `CartItem`, `CartItems`
3. Wrap primitives — `Money`, `Quantity`, `ProductId`, `ProductName`
4. First-class collections — `CartItems` wraps the cart's `CartItem[]`
5. One dot per line (Law of Demeter) — `Cart.increaseQuantity(itemId)` instead of pulling
a `Product` back out of a `CartItem` to hand it to `Cart`
6. No getters/setters unless necessary — `CartItem.id()`/`name()`/`quantity()` are
necessary, `getProduct()` wasn't and is gone
7. No else — grep the repo, there isn't one
8. Descriptive names, single responsibility, one thing per method — the rest of it

None of this is free: it costs more classes and more indirection than the equivalent
procedural code. The trade is code whose pieces can be understood, tested, and changed
one at a time.

## Commits

Conventional Commits, scope required: `type(scope): subject`. Enforced by commitlint via
a pre-commit hook (Lefthook), not just convention.
18 changes: 18 additions & 0 deletions e2e/add-to-cart.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { expect, test } from "@playwright/test";

test("visitor can add a product to the cart and reach checkout", async ({ page }) => {
await page.goto("shopping-cart/");

// First visit: the knowledge-level survey opens automatically.
await expect(page.getByText("Choose Your Knowledge Level")).toBeVisible();
await page.getByRole("combobox").selectOption("beginner");
await page.getByRole("button", { name: "Continue" }).click();
await expect(page.getByText("Choose Your Knowledge Level")).not.toBeVisible();

await page.getByRole("button", { name: "Add to Cart" }).first().click();
await expect(page.getByText("Your inventory is empty")).not.toBeVisible();

await page.getByRole("button", { name: "Complete Purchase" }).click();

await expect(page.getByText("End of the Demo")).toBeVisible();
});
19 changes: 19 additions & 0 deletions e2e/home-to-shop.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { expect, test } from "@playwright/test";

test("visitor can get from the home page to the shop", async ({ page }) => {
await page.goto("./");

await expect(
page.getByRole("heading", { name: "Maintainable Frontend Architecture with React" }),
).toBeVisible();

await page.getByRole("link", { name: /Try the Shopping Cart Experience/ }).click();
await expect(page).toHaveURL(/\/shopping-cart\/?$/);

// The knowledge-level survey opens automatically on a first visit and hides the rest
// of the page from assistive tech (and from role-based locators) while it is open.
await page.getByRole("combobox").selectOption("beginner");
await page.getByRole("button", { name: "Continue" }).click();

await expect(page.getByRole("heading", { name: "Mystical Inventory" })).toBeVisible();
});
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
"check:fix": "biome check --write .",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"prepare": "lefthook install",
"deploy": "pnpm build && npx gh-pages -d dist"
},
Expand Down Expand Up @@ -66,12 +68,15 @@
"@biomejs/biome": "2.5.11",
"@commitlint/cli": "21.2.2",
"@commitlint/config-conventional": "21.2.2",
"@playwright/test": "1.62.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.6",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"@vitejs/plugin-react": "^6.1.1",
"@vitest/coverage-v8": "^4.1.11",
"jsdom": "^30.0.1",
"lefthook": "2.1.12",
"remark-cli": "12.0.1",
Expand Down
27 changes: 27 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { defineConfig, devices } from "@playwright/test";

const PORT = 4173;
const BASE_PATH = "/react-object-calisthenics/";

export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: "html",
use: {
baseURL: `http://localhost:${PORT}${BASE_PATH}`,
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: {
command: "pnpm run preview",
url: `http://localhost:${PORT}${BASE_PATH}`,
reuseExistingServer: !process.env.CI,
},
});
Loading
Loading