diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d73ed7..82de292 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,8 +29,22 @@ jobs: - name: Build run: pnpm build - - name: Test - run: pnpm test - - name: Check doc freshness run: pnpm check:docs + + - name: Install Playwright browsers + run: pnpm exec playwright install --with-deps chromium + + - name: Harness test + run: pnpm harness:test + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + playwright-report/ + test-results/ + .harness/ + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index e093553..b2f6f97 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ node_modules/ dist/ +.harness/ +playwright-report/ +test-results/ .env .env.local *.generated.* diff --git a/AGENTS.md b/AGENTS.md index 7c891da..6e76e0e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,36 +11,33 @@ This is a TypeScript monorepo using pnpm workspaces. The application follows a d | What | Where | |------|-------| | Architecture & dependency rules | [ARCHITECTURE.md](./ARCHITECTURE.md) | +| Testing & harness procedures | [docs/testing.md](./docs/testing.md) | | Design documents | [docs/design/](./docs/design/) | | Core beliefs & principles | [docs/beliefs.md](./docs/beliefs.md) | | Quality tracking | [docs/quality.md](./docs/quality.md) | | Documentation catalog | [docs/catalog.md](./docs/catalog.md) | -| Active plans | [plans/active/](./plans/active/) | -| Completed plans | [plans/completed/](./plans/completed/) | -| Technical debt | [plans/debt.md](./plans/debt.md) | ## Stack -pnpm · TypeScript · Fastify + React/Vite · PostgreSQL + Drizzle · Zod · Vitest · Biome · GitHub Actions · OTel + Pino · Docker Compose +pnpm · TypeScript · Fastify + React/Vite · PostgreSQL + Drizzle · Zod · Vitest · Playwright · Biome · GitHub Actions · Pino · Docker Compose ## Key Rules 1. **Layered architecture is law.** Each domain follows: Types → Config → Repo → Service → Runtime → UI. Dependencies flow forward only. See [ARCHITECTURE.md](./ARCHITECTURE.md). 2. **Parse at the boundary.** All external data (API inputs, DB rows, env vars) must be validated with Zod schemas before entering the domain. 3. **Structured logging only.** Use the Pino logger from `src/providers/telemetry`. No `console.log`. -4. **Cross-cutting via Providers.** Auth, telemetry, feature flags enter through `src/providers/`. No direct imports of cross-cutting concerns in domain code. -5. **Tests are required.** Every module must have co-located tests. Run `pnpm test` before opening a PR. -6. **Plans live in the repo.** No external docs. If it's not in `plans/` or `docs/`, it doesn't exist. +4. **Cross-cutting via Providers.** Database, telemetry, auth, and feature flags enter through `src/providers/`. No direct imports of cross-cutting concerns in domain code. +5. **Tests are required.** Every module must have co-located tests. Use `pnpm harness:test` for full-stack changes. +6. **Docs live in the repo.** No external docs. If it's not in `docs/`, it doesn't exist. ## Before You Start a Task 1. Read this file (you're here) -2. Check [plans/active/](./plans/active/) for related work -3. Read the relevant domain's types layer first -4. Check [docs/quality.md](./docs/quality.md) for known gaps in the area you're touching +2. Read the relevant domain's types layer first +3. Check [docs/quality.md](./docs/quality.md) and [docs/testing.md](./docs/testing.md) for known gaps and validation procedures in the area you're touching ## When You're Done -1. Run `pnpm lint && pnpm test` +1. Run `pnpm lint && pnpm test`; run `pnpm harness:test` for API, database, UI, or e2e changes 2. Update [docs/quality.md](./docs/quality.md) if you improved coverage or fixed gaps 3. If you made architectural decisions, document them in [docs/design/](./docs/design/) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 167ac71..c8ed1fa 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -21,12 +21,13 @@ Types → Config → Repo → Service → Runtime → UI ### Cross-Cutting Concerns (Providers) -Auth, telemetry, feature flags, and shared connectors (database, cache, queue) live in `src/providers/`. Any layer may import from providers — this is the **only** exception to the forward-only rule. +Database, telemetry, auth, feature flags, and shared connectors (cache, queue) live in `src/providers/`. Any layer may import from providers — this is the **only** exception to the forward-only rule. ``` src/providers/ +├── database/ # Postgres client and lifecycle +├── telemetry/ # Structured Pino logging; metrics/traces are future work ├── auth/ # Authentication & authorization -├── telemetry/ # Logging (Pino), tracing (OTel), metrics └── feature-flags/ # Feature flag evaluation ``` @@ -38,17 +39,21 @@ These rules are enforced by the custom linter at `lints/check-deps.ts`: 2. **No cross-domain imports at lower layers.** `domainA/repo` cannot import `domainB/repo`. Cross-domain communication happens at the `service` layer or above. 3. **No direct cross-cutting imports.** Use `src/providers/`, not raw `pino` or `@opentelemetry/*` imports in domain code. 4. **UI only imports types and client-safe config.** No server-side code in the UI layer. +5. **Co-located tests are required.** Source modules must have adjacent unit or integration tests unless they are approved entrypoints or barrel files. +6. **Structured logging only.** Application code must not use `console.*`; use providers so harness logs stay queryable. ### Adding a New Domain 1. Create `src/domains//` with all six layer directories 2. Add types and Zod schemas first (types layer is the foundation) 3. Register routes in the runtime layer -4. Update [docs/catalog.md](./docs/catalog.md) +4. Add co-located tests for every source module +5. Add browser e2e coverage when the domain exposes user-visible flows +6. Update [docs/catalog.md](./docs/catalog.md) or domain-specific docs when behavior changes ### File Conventions - One export per file preferred (agents navigate better) -- Co-locate tests: `foo.ts` → `foo.test.ts` +- Co-locate tests: `foo.ts` → `foo.test.ts`; database tests use `foo.integration.test.ts` - Max file size: 300 lines (enforced by linter) - Schemas named `Schema`, types inferred as `type Thing = z.infer` diff --git a/README.md b/README.md index 93d58ef..3e79fcb 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,12 @@ Based on principles from [Harness Engineering](https://openai.com/index/harness- ```bash pnpm install -pnpm dev # Start dev servers +pnpm harness:boot # Start Docker Compose Postgres, API, and web for this worktree pnpm test # Run tests pnpm lint # Biome + architectural linting pnpm check:docs # Verify doc freshness +pnpm harness:test # Boot Docker Compose Postgres, run migrations, seed, test, e2e, and tear down +pnpm harness:down # Stop the local harness and Docker Compose resources ``` ## Architecture @@ -24,16 +26,15 @@ Each business domain follows a strict layered model: Types → Config → Repo → Service → Runtime → UI ``` -Dependencies flow forward only. Cross-cutting concerns (auth, logging, feature flags) go through `src/providers/`. +Dependencies flow forward only. Cross-cutting concerns (database, logging, auth, feature flags) go through `src/providers/`. ## For Agents -Start with [AGENTS.md](./AGENTS.md) — it's your map to the codebase. +Start with [AGENTS.md](./AGENTS.md) — it's your map to the codebase. Use [docs/testing.md](./docs/testing.md) for the full harness and testing procedure. ## For Humans Your job is to: 1. Define intent (what should the system do?) -2. Write plans (in `plans/active/`) -3. Review agent output -4. Encode taste into linters and docs +2. Review agent output +3. Encode taste into linters and docs diff --git a/biome.json b/biome.json index 37a406f..82d4ce6 100644 --- a/biome.json +++ b/biome.json @@ -11,6 +11,13 @@ "lineWidth": 100 }, "files": { - "ignore": ["node_modules", "dist", "*.generated.*"] + "ignore": [ + "node_modules", + "dist", + ".harness", + "test-results", + "playwright-report", + "*.generated.*" + ] } } diff --git a/docker-compose.yml b/docker-compose.yml index d640e39..b4e7f44 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,12 @@ services: POSTGRES_USER: app POSTGRES_PASSWORD: localdev ports: - - "5432:5432" + - "${POSTGRES_PORT:-5432}:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U app -d app"] + interval: 2s + timeout: 2s + retries: 30 volumes: - pgdata:/var/lib/postgresql/data diff --git a/docs/catalog.md b/docs/catalog.md index e0fa566..833d6ec 100644 --- a/docs/catalog.md +++ b/docs/catalog.md @@ -6,29 +6,22 @@ All documentation in this repository, indexed for discoverability. | Document | Path | Status | Last Verified | |----------|------|--------|---------------| -| Agent instructions | [AGENTS.md](../AGENTS.md) | ✅ Current | YYYY-MM-DD | -| Architecture overview | [ARCHITECTURE.md](../ARCHITECTURE.md) | ✅ Current | YYYY-MM-DD | -| Core beliefs | [docs/beliefs.md](./beliefs.md) | ✅ Current | YYYY-MM-DD | -| Quality tracking | [docs/quality.md](./quality.md) | ✅ Current | YYYY-MM-DD | +| Agent instructions | [AGENTS.md](../AGENTS.md) | Current | 2026-05-03 | +| Architecture overview | [ARCHITECTURE.md](../ARCHITECTURE.md) | Current | 2026-05-03 | +| Core beliefs | [docs/beliefs.md](./beliefs.md) | Current | 2026-05-03 | +| Quality tracking | [docs/quality.md](./quality.md) | Current | 2026-05-03 | +| Testing and harness procedures | [docs/testing.md](./testing.md) | Current | 2026-05-03 | ## Design Documents | Document | Path | Status | Last Verified | |----------|------|--------|---------------| -| *(none yet)* | | | | - -## Plans - -| Plan | Path | Status | -|------|------|--------| -| Technical debt | [plans/debt.md](../plans/debt.md) | Active | - ---- +| Harness engineering readiness | [docs/design/harness-engineering-readiness.md](./design/harness-engineering-readiness.md) | Current | 2026-05-03 | ### Freshness Rules -- **✅ Current** — Verified to match actual code behavior -- **⚠️ Stale** — May not reflect current implementation -- **❌ Obsolete** — Scheduled for removal or rewrite +- **Current** — Verified to match actual code behavior +- **Stale** — May not reflect current implementation +- **Obsolete** — Scheduled for removal or rewrite -Documents should be re-verified at least every 2 weeks. The doc-gardening CI job flags stale entries. +Documents should be re-verified at least every 2 weeks. The current CI check validates catalog links and `Last Verified` dates. diff --git a/docs/design/harness-engineering-readiness.md b/docs/design/harness-engineering-readiness.md new file mode 100644 index 0000000..29f7b92 --- /dev/null +++ b/docs/design/harness-engineering-readiness.md @@ -0,0 +1,100 @@ +# Harness Engineering Readiness + +Last verified: 2026-05-03 + +This document tracks the repository against the agent-first harness model described in OpenAI's Harness Engineering article: https://openai.com/index/harness-engineering/ + +The target state is not only "agents can edit code." The target is a repository where agents can understand the product, boot isolated full-stack environments, inspect browser behavior, query runtime signals, run meaningful checks, and turn repeated review feedback into durable guardrails. + +## Implemented Harness + +| Capability | Evidence | Assessment | +|------------|----------|------------| +| Small agent map | `AGENTS.md` points to architecture, testing, docs, and quality | Good shape for progressive disclosure | +| Layered domain model | `ARCHITECTURE.md` defines Types -> Config -> Repo -> Service -> Runtime -> UI | Strong foundation | +| Mechanical guardrails | `lints/check-deps.ts` runs through `pnpm lint` | Enforces layer shape, dependency direction, co-located tests, structured logging, and no app `console.*` | +| Docker Compose database | `docker-compose.yml`, `src/providers/database/`, `migrations/`, `scripts/db-migrate.ts` | Postgres is the full-stack database layer | +| Deterministic harness | `scripts/harness/` and `pnpm harness:test` | Boots per-worktree DB/API/web, migrates, seeds, tests, runs e2e, tears down, and keeps artifacts | +| Browser e2e | `playwright.config.ts`, `tests/e2e/item-flow.spec.ts` | Validates item create/reload/delete and API failure UI behavior | +| Agent-queryable logs | `pnpm harness:logs` and `.harness//logs/` | API logs include request ID, method, URL, status, and duration | +| CI artifacts | `.github/workflows/ci.yml` | Runs harness tests and uploads Playwright and harness artifacts | +| Repository-local docs | `docs/catalog.md`, `docs/testing.md`, and `docs/quality.md` | Testing procedures and quality state are versioned in repo | + +## Remaining Gaps + +| Gap | Evidence | Why It Matters For Agents | +|-----|----------|---------------------------| +| No metrics/traces backend | `docs/quality.md` tracks this as a known gap | Logs and Playwright traces cover many failures, but performance analysis would benefit from metrics and service traces | +| No production deployment config | `docs/quality.md` tracks this as a known gap | The harness validates local full-stack behavior, not production release mechanics | + +## Implemented Backlog + +### 1. Deterministic App Harness + +Implemented as `scripts/harness/` with commands for `boot`, `health`, `seed`, `test`, `logs`, and `down`. + +Implemented behavior: + +- Allocate stable per-worktree ports for API, web, and database. +- Start Postgres, API, and Vite in the background. +- Wait for `/healthz` and the web root before returning. +- Write process IDs, ports, and log paths under `.harness//`. +- Provide teardown that works even after failed runs. + +### 2. Browser-Legible E2E Testing + +Implemented Playwright tests that exercise the current item flow from the browser: + +- Empty state renders. +- Creating an item through the UI persists through API reload. +- Deleting an item removes it from the UI. +- Failed API responses produce visible error states. + +CI uploads Playwright HTML reports, traces, screenshots, videos, and harness artifacts on every run. + +### 3. Persistent Example Domain + +Drizzle and Postgres are wired into the example domain: + +- Schema and migration files are present. +- A database provider lives under `src/providers/database/`. +- The repo layer parses database rows with Zod before returning domain values. +- Integration tests run against an isolated Docker Compose test database when `DATABASE_URL` is present. + +### 4. Agent-Queryable Runtime Signals + +Local development now has an inspectable observability path: + +- Structured Pino logs are written to per-harness log files. +- Request IDs and route timing are logged for HTTP requests. +- `pnpm harness:logs -- --query ...` provides lightweight local log filtering. +- Metrics and service traces remain future work behind providers. + +### 5. Mechanical Guardrails + +Custom checks now enforce: + +- Validate required domain layer directories. +- Enforce co-located tests for non-test modules. +- Enforce no `console.*` outside approved scripts. +- Enforce docs catalog `Last verified` dates. +- Enforce that active and completed plan directories exist through tracked `.gitkeep` files. + +### 6. Agent Review And Garbage Collection Loops + +Recurring cleanup has an initial repo-local command: + +- `pnpm quality:audit` reports layer source and test counts from code evidence. +- `pnpm check:docs` checks stale docs and broken catalog links. +- Keep durable cleanup guidance in `docs/quality.md` and focused design docs instead of relying on memory. + +## Success Criteria + +This repo is a full-stack agent harness because a fresh agent can run one documented command that: + +1. Boots an isolated app stack for the current worktree. +2. Applies migrations and seeds known test data. +3. Runs unit, integration, and browser e2e tests. +4. Captures logs, screenshots, videos, and browser traces as local artifacts. +5. Tears down cleanly. +6. Leaves enough evidence for another agent to diagnose any failure without asking a human to reproduce it manually. diff --git a/docs/quality.md b/docs/quality.md index 5ce4c09..c1e08c1 100644 --- a/docs/quality.md +++ b/docs/quality.md @@ -14,23 +14,22 @@ Track the health of each domain and architectural layer. Update this when you im | Domain | Types | Config | Repo | Service | Runtime | UI | Overall | Notes | |--------|-------|--------|------|---------|---------|----|---------|----| -| example | B | B | C | C | C | D | C | Scaffold only, needs real implementation | +| example | B | B | B | B | B | B | B | Docker Compose-backed full-stack example with unit, integration, and e2e coverage | ## Cross-Cutting | Provider | Grade | Notes | |----------|-------|-------| | auth | D | Placeholder | -| telemetry | B | Pino + OTel wired up | +| database | B | Postgres provider wired through Docker Compose harness | +| telemetry | B | Pino logger, request IDs, route timings, and per-harness queryable logs are wired; metrics/traces are future work | | feature-flags | D | Placeholder | ## Known Gaps -- [ ] No integration tests yet -- [ ] Database migrations not wired up -- [ ] CI pipeline incomplete +- [ ] Telemetry does not yet include a metrics/traces backend beyond structured logs and Playwright traces - [ ] No production deployment config --- -*Last updated: YYYY-MM-DD* +*Last updated: 2026-05-03* diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..7c70180 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,82 @@ +# Testing And Harness Procedures + +Last verified: 2026-05-03 + +This repository uses tests as the main feedback loop for agents. New work should add the narrowest test that proves the behavior, then use the full harness when the change crosses API, database, browser, or runtime boundaries. + +## Test Layers + +| Layer | Command | Where Tests Live | Use When | +|-------|---------|------------------|----------| +| Unit | `pnpm test` | Co-located `*.test.ts` or `*.test.tsx` under `src/` | Pure domain rules, schemas, mapping, app factories, small UI render checks | +| Integration | `DATABASE_URL=... pnpm test` | Co-located `*.integration.test.ts` under `src/` | Repository, service, and runtime behavior that requires Postgres | +| Browser e2e | `pnpm e2e` | `tests/e2e/*.spec.ts` | User-visible flows, API/UI coordination, error states | +| Full harness | `pnpm harness:test` | Runs unit, integration, and e2e | PR validation and agent end-to-end confidence | + +## Docker Compose Database + +Postgres is the database layer. Agents should not replace it with in-memory substitutes for full-stack behavior. + +Use the harness for a reproducible database: + +```bash +pnpm harness:boot +pnpm harness:seed +pnpm harness:health +pnpm harness:logs -- --service api --lines 80 +pnpm harness:down +``` + +`pnpm harness:boot` starts Docker Compose with a per-worktree project name and a per-worktree Postgres port, runs migrations, starts the API and web app, and writes metadata under `.harness//metadata.json`. `pnpm harness:seed` writes deterministic example data for exploratory testing. + +The metadata includes `DATABASE_URL`, API URL, web URL, process IDs, and log file paths. Harness teardown stops processes and Docker resources but keeps logs and test artifacts for later inspection. + +## Writing Unit Tests + +- Place tests beside the source file: `foo.ts` gets `foo.test.ts`. +- Test domain schemas with valid and invalid input. +- Test row mappers and boundary parsers with realistic external shapes. +- Keep tests deterministic. Do not depend on test order or existing database state. +- Prefer app factories and injected dependencies over importing long-running entrypoints. + +## Writing Integration Tests + +- Name database tests `*.integration.test.ts`. +- Use `DATABASE_URL` from harness metadata or `pnpm harness:test`. +- Reset touched tables in `beforeEach`. +- Close database clients in `afterAll`. +- Parse database rows before asserting domain values. +- Skip only when `DATABASE_URL` is absent so local unit-only runs still work. + +## Writing Browser E2E Tests + +- Install browsers with `pnpm exec playwright install chromium` when running e2e locally for the first time. CI installs Chromium before `pnpm harness:test`. +- Put Playwright specs in `tests/e2e/`. +- Use `WEB_URL` and `API_ORIGIN`; the harness provides both. +- Reset state through API setup steps before each browser journey. +- Cover the user-visible success path and at least one user-visible failure state for new workflows. +- Let Playwright retain traces, screenshots, and video on failure; CI uploads those artifacts. + +## Agent Procedure + +For source-only changes: + +```bash +pnpm lint +pnpm test +pnpm build +``` + +For full-stack changes: + +```bash +pnpm harness:test +pnpm check:docs +``` + +When a harness run fails: + +1. Inspect `.harness//metadata.json`. +2. Query recent API logs with `pnpm harness:logs -- --service api --lines 120`. +3. Inspect Playwright traces, screenshots, and videos under `test-results/` or `playwright-report/`. +4. Fix the missing capability, test, or guardrail before rerunning. diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..549f75b --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + schema: "./src/domains/example/repo/item-table.ts", + out: "./migrations/generated", + dialect: "postgresql", + dbCredentials: { + url: process.env.DATABASE_URL ?? "postgres://app:localdev@localhost:5432/app", + }, +}); diff --git a/lints/check-deps.ts b/lints/check-deps.ts index 75e5f0b..3c81287 100644 --- a/lints/check-deps.ts +++ b/lints/check-deps.ts @@ -11,8 +11,8 @@ * 4. Max file size: 300 lines */ -import { readFileSync, readdirSync, statSync } from "node:fs"; -import { join, relative, sep } from "node:path"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { basename, dirname, extname, join, relative, sep } from "node:path"; const LAYER_ORDER = ["types", "config", "repo", "service", "runtime", "ui"] as const; type Layer = (typeof LAYER_ORDER)[number]; @@ -26,6 +26,8 @@ const BANNED_DIRECT_IMPORTS = [ "@opentelemetry", // Use @providers/telemetry ]; +const ENTRYPOINT_FILES = new Set(["src/server.ts", "src/app/main.tsx", "src/app/vite.config.ts"]); + interface Violation { file: string; line: number; @@ -92,8 +94,28 @@ function checkFile(filePath: string) { const sourceLayer = getLayer(filePath); const sourceDomain = getDomain(filePath); + if (isSourceModuleRequiringTest(rel) && !hasColocatedTest(filePath)) { + violations.push({ + file: rel, + line: 1, + rule: "co-located-test-required", + message: "Source module does not have a co-located test.", + fix: "Add a focused foo.test.ts, foo.test.tsx, or foo.integration.test.ts next to this file. Tests are part of the agent feedback harness.", + }); + } + for (let i = 0; i < lines.length; i++) { const line = lines[i]; + if (isAppSource(rel) && /\bconsole\.(log|info|warn|error|debug|trace)\b/.test(line)) { + violations.push({ + file: rel, + line: i + 1, + rule: "no-console", + message: "Application code must not write directly to console.", + fix: "Use the structured logger from src/providers/telemetry so logs are queryable by the harness.", + }); + } + const importMatch = line.match(/(?:import|from)\s+['"]([^'"]+)['"]/); if (!importMatch) continue; @@ -148,9 +170,59 @@ function checkFile(filePath: string) { } } +function isTestFile(rel: string) { + return /\.(test|integration\.test)\.tsx?$/.test(rel); +} + +function isAppSource(rel: string) { + return rel.startsWith("src/") && !isTestFile(rel); +} + +function isSourceModuleRequiringTest(rel: string) { + if (!isAppSource(rel)) return false; + if (ENTRYPOINT_FILES.has(rel)) return false; + if (basename(rel) === "index.ts") return false; + return /\.(ts|tsx)$/.test(rel); +} + +function hasColocatedTest(filePath: string) { + const dir = dirname(filePath); + const ext = extname(filePath); + const stem = basename(filePath, ext); + const candidates = [ + `${stem}.test.ts`, + `${stem}.test.tsx`, + `${stem}.integration.test.ts`, + `${stem}.integration.test.tsx`, + ]; + return candidates.some((candidate) => existsSync(join(dir, candidate))); +} + +function checkDomainShape(srcDir: string) { + const domainsDir = join(srcDir, "domains"); + if (!existsSync(domainsDir)) return; + + for (const entry of readdirSync(domainsDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + for (const layer of LAYER_ORDER) { + const layerDir = join(domainsDir, entry.name, layer); + if (!existsSync(layerDir)) { + violations.push({ + file: relative(process.cwd(), join(domainsDir, entry.name)), + line: 1, + rule: "required-domain-layer", + message: `Domain '${entry.name}' is missing required '${layer}' layer directory.`, + fix: `Create src/domains/${entry.name}/${layer}/ so agents can rely on the standard Types -> Config -> Repo -> Service -> Runtime -> UI shape.`, + }); + } + } + } +} + // Run const srcDir = join(process.cwd(), "src"); try { + checkDomainShape(srcDir); const files = walkTs(srcDir); for (const file of files) { checkFile(file); diff --git a/migrations/0001_example_items.sql b/migrations/0001_example_items.sql new file mode 100644 index 0000000..cf89270 --- /dev/null +++ b/migrations/0001_example_items.sql @@ -0,0 +1,12 @@ +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE IF NOT EXISTS items ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name varchar(255) NOT NULL, + description text, + status varchar(32) NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS items_status_idx ON items (status); diff --git a/package.json b/package.json index c5d854b..9652741 100644 --- a/package.json +++ b/package.json @@ -9,16 +9,25 @@ "build": "tsc --noEmit && vite build --config src/app/vite.config.ts", "test": "vitest run", "test:watch": "vitest", + "e2e": "playwright test", "lint": "biome check . && tsx lints/check-deps.ts", "lint:fix": "biome check --fix .", "format": "biome format --write .", "db:generate": "drizzle-kit generate", - "db:migrate": "drizzle-kit migrate", + "db:migrate": "tsx scripts/db-migrate.ts", "check:docs": "tsx scripts/check-doc-freshness.ts", + "quality:audit": "tsx scripts/quality-audit.ts", + "harness:boot": "tsx scripts/harness/boot.ts", + "harness:down": "tsx scripts/harness/down.ts", + "harness:health": "tsx scripts/harness/health.ts", + "harness:logs": "tsx scripts/harness/logs.ts", + "harness:seed": "tsx scripts/harness/seed.ts", + "harness:test": "tsx scripts/harness/test.ts", "worktree:boot": "bash scripts/worktree-boot.sh" }, "devDependencies": { "@biomejs/biome": "^1.9.0", + "@playwright/test": "^1.59.1", "@types/node": "^25.3.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -38,6 +47,7 @@ "dependencies": { "fastify": "^5.7.4", "pino": "^10.3.1", + "postgres": "^3.4.9", "react": "^19.2.4", "react-dom": "^19.2.4", "zod": "^4.3.6" diff --git a/plans/debt.md b/plans/debt.md deleted file mode 100644 index ab070a5..0000000 --- a/plans/debt.md +++ /dev/null @@ -1,17 +0,0 @@ -# Technical Debt - -Track known debt items. Prioritize by impact on agent productivity. - -## Active Debt - -| ID | Area | Description | Impact | Added | -|----|------|-------------|--------|-------| -| D-001 | CI | No automated doc freshness check | Docs may drift | YYYY-MM-DD | -| D-002 | Testing | No integration test harness | Can't verify cross-domain flows | YYYY-MM-DD | -| D-003 | Deploy | No production deployment config | Manual deploys only | YYYY-MM-DD | - -## Resolved - -| ID | Area | Description | Resolved | -|----|------|-------------|----------| -| *(none yet)* | | | | diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..f2eb231 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/e2e", + outputDir: "test-results", + reporter: process.env.CI + ? [["line"], ["html", { open: "never" }]] + : [["list"], ["html", { open: "never" }]], + retries: process.env.CI ? 1 : 0, + use: { + baseURL: process.env.WEB_URL ?? "http://127.0.0.1:3000", + trace: "retain-on-failure", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf2ba2b..d3bbf5e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: pino: specifier: ^10.3.1 version: 10.3.1 + postgres: + specifier: ^3.4.9 + version: 3.4.9 react: specifier: ^19.2.4 version: 19.2.4 @@ -27,6 +30,9 @@ importers: '@biomejs/biome': specifier: ^1.9.0 version: 1.9.4 + '@playwright/test': + specifier: ^1.59.1 + version: 1.59.1 '@types/node': specifier: ^25.3.0 version: 25.3.0 @@ -47,7 +53,7 @@ importers: version: 0.31.9 drizzle-orm: specifier: ^0.45.1 - version: 0.45.1 + version: 0.45.1(postgres@3.4.9) tsx: specifier: ^4.19.0 version: 4.21.0 @@ -829,6 +835,11 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@playwright/test@1.59.1': + resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} + engines: {node: '>=18'} + hasBin: true + '@rolldown/pluginutils@1.0.0-rc.3': resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} @@ -1301,6 +1312,11 @@ packages: resolution: {integrity: sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==} engines: {node: '>=20'} + 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} @@ -1399,10 +1415,24 @@ packages: resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} hasBin: true + playwright-core@1.59.1: + resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.59.1: + resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} + engines: {node: '>=18'} + hasBin: true + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + postgres@3.4.9: + resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==} + engines: {node: '>=12'} + process-warning@4.0.1: resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} @@ -2211,6 +2241,10 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@playwright/test@1.59.1': + dependencies: + playwright: 1.59.1 + '@rolldown/pluginutils@1.0.0-rc.3': {} '@rollup/rollup-android-arm-eabi@4.58.0': @@ -2478,7 +2512,9 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.45.1: {} + drizzle-orm@0.45.1(postgres@3.4.9): + optionalDependencies: + postgres: 3.4.9 electron-to-chromium@1.5.302: {} @@ -2661,6 +2697,9 @@ snapshots: fast-querystring: 1.1.2 safe-regex2: 5.0.0 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -2742,12 +2781,22 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 4.0.0 + playwright-core@1.59.1: {} + + playwright@1.59.1: + dependencies: + playwright-core: 1.59.1 + optionalDependencies: + fsevents: 2.3.2 + postcss@8.5.6: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 source-map-js: 1.2.1 + postgres@3.4.9: {} + process-warning@4.0.1: {} process-warning@5.0.0: {} diff --git a/scripts/check-doc-freshness.ts b/scripts/check-doc-freshness.ts index 0832650..cb71693 100644 --- a/scripts/check-doc-freshness.ts +++ b/scripts/check-doc-freshness.ts @@ -21,6 +21,8 @@ if (!existsSync(CATALOG_PATH)) { const content = readFileSync(CATALOG_PATH, "utf-8"); const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g; const warnings: string[] = []; +const staleDaysMs = STALE_DAYS * 24 * 60 * 60 * 1000; +const now = new Date(); for (const match of content.matchAll(linkRegex)) { const [, label, href] = match; @@ -32,6 +34,28 @@ for (const match of content.matchAll(linkRegex)) { } } +if (content.includes("YYYY-MM-DD")) { + warnings.push("❌ Placeholder date found: replace YYYY-MM-DD with a real Last Verified date"); +} + +for (const line of content.split("\n")) { + const dateMatch = line.match(/\|\s*(\d{4}-\d{2}-\d{2})\s*\|$/); + if (!dateMatch) continue; + + const date = new Date(`${dateMatch[1]}T00:00:00Z`); + if (Number.isNaN(date.valueOf())) { + warnings.push(`❌ Invalid Last Verified date: ${dateMatch[1]}`); + continue; + } + if (date > now) { + warnings.push(`❌ Future Last Verified date: ${dateMatch[1]}`); + continue; + } + if (now.getTime() - date.getTime() > staleDaysMs) { + warnings.push(`❌ Stale Last Verified date: ${dateMatch[1]} is older than ${STALE_DAYS} days`); + } +} + if (warnings.length > 0) { console.error(`\n⚠️ ${warnings.length} doc issue(s) found:\n`); for (const w of warnings) { @@ -39,5 +63,5 @@ if (warnings.length > 0) { } process.exit(1); } else { - console.log("✅ All catalog links valid."); + console.log("✅ Catalog links and verification dates valid."); } diff --git a/scripts/db-migrate.ts b/scripts/db-migrate.ts new file mode 100644 index 0000000..95e4919 --- /dev/null +++ b/scripts/db-migrate.ts @@ -0,0 +1,39 @@ +import { readFile, readdir } from "node:fs/promises"; +import { join } from "node:path"; +import postgres from "postgres"; + +const migrationsDir = join(process.cwd(), "migrations"); +const databaseUrl = process.env.DATABASE_URL; + +if (!databaseUrl) { + throw new Error("DATABASE_URL is required to run migrations."); +} + +const sql = postgres(databaseUrl, { max: 1 }); + +try { + await sql` + CREATE TABLE IF NOT EXISTS schema_migrations ( + name text PRIMARY KEY, + applied_at timestamptz NOT NULL DEFAULT now() + ) + `; + + const files = (await readdir(migrationsDir)).filter((file) => file.endsWith(".sql")).sort(); + + for (const file of files) { + const applied = await sql<{ name: string }[]>` + SELECT name FROM schema_migrations WHERE name = ${file} + `; + if (applied.length > 0) continue; + + const statement = await readFile(join(migrationsDir, file), "utf-8"); + await sql.begin(async (tx) => { + await tx.unsafe(statement); + await tx`INSERT INTO schema_migrations (name) VALUES (${file})`; + }); + console.log(`applied migration ${file}`); + } +} finally { + await sql.end(); +} diff --git a/scripts/harness/boot.ts b/scripts/harness/boot.ts new file mode 100644 index 0000000..1f1f128 --- /dev/null +++ b/scripts/harness/boot.ts @@ -0,0 +1,121 @@ +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import postgres from "postgres"; +import { + type HarnessMetadata, + computePortSeeds, + findFreePort, + getHarnessPaths, + isProcessAlive, + readMetadata, + runCommand, + spawnLogged, + waitForHttp, + writeMetadata, +} from "./shared.js"; + +const existing = readMetadata(); +if (existing && isProcessAlive(existing.pids.api) && isProcessAlive(existing.pids.web)) { + await waitForHttp(`${existing.urls.api}/healthz`, 10_000); + await waitForHttp(existing.urls.web, 10_000); + console.log("harness already running"); + console.log(`api: ${existing.urls.api}`); + console.log(`web: ${existing.urls.web}`); + console.log(`metadata: ${join(existing.dir, "metadata.json")}`); + process.exit(0); +} + +const paths = getHarnessPaths(); +const seeds = computePortSeeds(paths.hash); +const ports = { + api: await findFreePort(seeds.api), + web: await findFreePort(seeds.web), + postgres: await findFreePort(seeds.postgres), +}; + +mkdirSync(paths.logDir, { recursive: true }); + +const databaseUrl = `postgres://app:localdev@127.0.0.1:${ports.postgres}/app`; + +console.log(`starting docker compose database for ${paths.projectName}`); +runCommand("docker", ["compose", "-p", paths.projectName, "up", "-d", "db"], { + POSTGRES_PORT: String(ports.postgres), +}); + +console.log("waiting for database"); +await waitForPostgres(databaseUrl); + +console.log("running migrations"); +runCommand("pnpm", ["db:migrate"], { DATABASE_URL: databaseUrl }); + +const metadata: HarnessMetadata = { + worktreeName: paths.worktreeName, + projectName: paths.projectName, + root: paths.root, + dir: paths.dir, + ports, + urls: { + api: `http://127.0.0.1:${ports.api}`, + web: `http://127.0.0.1:${ports.web}`, + }, + databaseUrl, + pids: {}, + logs: { + api: join(paths.logDir, "api.log"), + web: join(paths.logDir, "web.log"), + }, + startedAt: new Date().toISOString(), +}; + +metadata.pids.api = await spawnLogged("pnpm", ["exec", "tsx", "src/server.ts"], metadata.logs.api, { + DATABASE_URL: databaseUrl, + HOST: "127.0.0.1", + PORT: String(ports.api), +}); + +metadata.pids.web = await spawnLogged( + "pnpm", + [ + "exec", + "vite", + "--config", + "src/app/vite.config.ts", + "--host", + "127.0.0.1", + "--port", + String(ports.web), + "--strictPort", + ], + metadata.logs.web, + { + API_ORIGIN: metadata.urls.api, + }, +); + +writeMetadata(metadata); + +console.log("waiting for api and web"); +await waitForHttp(`${metadata.urls.api}/healthz`, 30_000); +await waitForHttp(metadata.urls.web, 30_000); + +console.log(`api: ${metadata.urls.api}`); +console.log(`web: ${metadata.urls.web}`); +console.log(`metadata: ${join(metadata.dir, "metadata.json")}`); + +async function waitForPostgres(databaseUrl: string) { + const started = Date.now(); + let lastError: unknown; + while (Date.now() - started < 30_000) { + const sql = postgres(databaseUrl, { max: 1, connect_timeout: 2 }); + try { + await sql`SELECT 1`; + await sql.end(); + return; + } catch (err) { + lastError = err; + await sql.end().catch(() => undefined); + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error(`Timed out waiting for Postgres: ${String(lastError)}`); +} diff --git a/scripts/harness/down.ts b/scripts/harness/down.ts new file mode 100644 index 0000000..574bd1b --- /dev/null +++ b/scripts/harness/down.ts @@ -0,0 +1,14 @@ +import { getHarnessPaths, killProcess, readMetadata, runCommand } from "./shared.js"; + +const metadata = readMetadata(); +const paths = getHarnessPaths(); + +if (metadata) { + killProcess(metadata.pids.web); + killProcess(metadata.pids.api); + runCommand("docker", ["compose", "-p", metadata.projectName, "down", "-v", "--remove-orphans"]); +} else { + runCommand("docker", ["compose", "-p", paths.projectName, "down", "-v", "--remove-orphans"]); +} + +console.log(`harness stopped; artifacts kept in ${paths.dir}`); diff --git a/scripts/harness/health.ts b/scripts/harness/health.ts new file mode 100644 index 0000000..a08294e --- /dev/null +++ b/scripts/harness/health.ts @@ -0,0 +1,26 @@ +import { isProcessAlive, readMetadata, waitForHttp } from "./shared.js"; + +const metadata = readMetadata(); +if (!metadata) { + console.error("harness metadata not found; run pnpm harness:boot"); + process.exit(1); +} + +const checks = [ + ["api process", isProcessAlive(metadata.pids.api)], + ["web process", isProcessAlive(metadata.pids.web)], +] as const; + +for (const [name, passed] of checks) { + if (!passed) { + console.error(`${name}: failed`); + process.exit(1); + } + console.log(`${name}: ok`); +} + +await waitForHttp(`${metadata.urls.api}/healthz`, 5_000); +console.log(`api health: ok (${metadata.urls.api}/healthz)`); + +await waitForHttp(metadata.urls.web, 5_000); +console.log(`web health: ok (${metadata.urls.web})`); diff --git a/scripts/harness/logs.ts b/scripts/harness/logs.ts new file mode 100644 index 0000000..fe373bf --- /dev/null +++ b/scripts/harness/logs.ts @@ -0,0 +1,39 @@ +import { existsSync, readFileSync } from "node:fs"; +import { readMetadata } from "./shared.js"; + +const metadata = readMetadata(); +if (!metadata) { + console.error("harness metadata not found; run pnpm harness:boot"); + process.exit(1); +} + +const args = process.argv.slice(2); +const service = readArg("--service") ?? "api"; +const query = readArg("--query"); +const lines = Number(readArg("--lines") ?? 120); + +if (service !== "api" && service !== "web") { + console.error("--service must be api or web"); + process.exit(1); +} + +const logPath = metadata.logs[service]; +if (!existsSync(logPath)) { + console.error(`log file not found: ${logPath}`); + process.exit(1); +} + +let output = readFileSync(logPath, "utf-8").split("\n").filter(Boolean); +if (query) { + output = output.filter((line) => line.includes(query)); +} + +for (const line of output.slice(-lines)) { + console.log(line); +} + +function readArg(name: string) { + const index = args.indexOf(name); + if (index === -1) return undefined; + return args[index + 1]; +} diff --git a/scripts/harness/seed.ts b/scripts/harness/seed.ts new file mode 100644 index 0000000..73aa865 --- /dev/null +++ b/scripts/harness/seed.ts @@ -0,0 +1,21 @@ +import { sql } from "drizzle-orm"; +import { itemRepo } from "../../src/domains/example/repo/item-repo.js"; +import { closeDb, getDb } from "../../src/providers/database/index.js"; +import { readMetadata } from "./shared.js"; + +const metadata = readMetadata(); +if (metadata && !process.env.DATABASE_URL) { + process.env.DATABASE_URL = metadata.databaseUrl; +} + +if (!process.env.DATABASE_URL) { + throw new Error("DATABASE_URL is required. Run pnpm harness:boot before seeding."); +} + +try { + await getDb().execute(sql`TRUNCATE TABLE items`); + const item = await itemRepo.create({ name: "Seed Harness Item", status: "draft" }); + console.log(`seeded item ${item.id}`); +} finally { + await closeDb(); +} diff --git a/scripts/harness/shared.ts b/scripts/harness/shared.ts new file mode 100644 index 0000000..8296277 --- /dev/null +++ b/scripts/harness/shared.ts @@ -0,0 +1,162 @@ +import { execFileSync, spawn, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { open } from "node:fs/promises"; +import { createServer } from "node:net"; +import { basename, join } from "node:path"; + +export interface HarnessMetadata { + worktreeName: string; + projectName: string; + root: string; + dir: string; + ports: { + api: number; + web: number; + postgres: number; + }; + urls: { + api: string; + web: string; + }; + databaseUrl: string; + pids: { + api?: number; + web?: number; + }; + logs: { + api: string; + web: string; + }; + startedAt: string; +} + +export function getRoot() { + return execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf-8" }).trim(); +} + +export function getHarnessPaths(root = getRoot()) { + const worktreeName = basename(root); + const hash = createHash("sha256").update(root).digest("hex").slice(0, 8); + const dir = join(root, ".harness", `${worktreeName}-${hash}`); + return { + root, + worktreeName, + hash, + projectName: `aft-${worktreeName}-${hash}`, + dir, + metadataPath: join(dir, "metadata.json"), + logDir: join(dir, "logs"), + }; +} + +export function readMetadata(): HarnessMetadata | null { + const { metadataPath } = getHarnessPaths(); + if (!existsSync(metadataPath)) return null; + return JSON.parse(readFileSync(metadataPath, "utf-8")) as HarnessMetadata; +} + +export function writeMetadata(metadata: HarnessMetadata) { + mkdirSync(metadata.dir, { recursive: true }); + mkdirSync(join(metadata.dir, "logs"), { recursive: true }); + writeFileSync(join(metadata.dir, "metadata.json"), `${JSON.stringify(metadata, null, 2)}\n`); +} + +export async function findFreePort(start: number) { + for (let port = start; port < start + 1000; port++) { + if (await isPortFree(port)) return port; + } + throw new Error(`No free port found starting at ${start}.`); +} + +async function isPortFree(port: number) { + return new Promise((resolve) => { + const server = createServer(); + server.once("error", () => resolve(false)); + server.once("listening", () => { + server.close(() => resolve(true)); + }); + server.listen(port, "127.0.0.1"); + }); +} + +export function computePortSeeds(hash: string) { + const offset = Number.parseInt(hash.slice(0, 4), 16) % 500; + return { + web: 3000 + offset, + api: 4000 + offset, + postgres: 5400 + offset, + }; +} + +export function runCommand(command: string, args: string[], env: NodeJS.ProcessEnv = {}) { + const result = spawnSync(command, args, { + cwd: getRoot(), + env: { ...process.env, ...env }, + stdio: "inherit", + }); + if (result.status !== 0) { + throw new Error( + `${command} ${args.join(" ")} failed with exit code ${result.status ?? "unknown"}.`, + ); + } +} + +export async function spawnLogged( + command: string, + args: string[], + logPath: string, + env: NodeJS.ProcessEnv = {}, +) { + const logFile = await open(logPath, "a"); + const child = spawn(command, args, { + cwd: getRoot(), + detached: true, + env: { ...process.env, ...env }, + stdio: ["ignore", logFile.fd, logFile.fd], + }); + child.unref(); + await logFile.close(); + return child.pid; +} + +export async function waitForHttp(url: string, timeoutMs: number) { + const started = Date.now(); + let lastError: unknown; + + while (Date.now() - started < timeoutMs) { + try { + const response = await fetch(url); + if (response.ok) return; + lastError = new Error(`${url} returned HTTP ${response.status}`); + } catch (err) { + lastError = err; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + throw new Error(`Timed out waiting for ${url}: ${String(lastError)}`); +} + +export function isProcessAlive(pid: number | undefined) { + if (!pid) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +export function killProcess(pid: number | undefined) { + if (!pid || !isProcessAlive(pid)) return; + try { + process.kill(-pid, "SIGTERM"); + } catch { + try { + process.kill(pid, "SIGTERM"); + } catch { + // Already gone. + } + } +} diff --git a/scripts/harness/test.ts b/scripts/harness/test.ts new file mode 100644 index 0000000..19e5d0c --- /dev/null +++ b/scripts/harness/test.ts @@ -0,0 +1,31 @@ +import { readMetadata, runCommand } from "./shared.js"; + +let exitCode = 0; + +try { + runCommand("pnpm", ["harness:boot"]); + const metadata = readMetadata(); + if (!metadata) { + throw new Error("Harness boot completed without metadata."); + } + + runCommand("pnpm", ["harness:seed"]); + runCommand("pnpm", ["test"], { DATABASE_URL: metadata.databaseUrl }); + runCommand("pnpm", ["e2e"], { + API_ORIGIN: metadata.urls.api, + WEB_URL: metadata.urls.web, + DATABASE_URL: metadata.databaseUrl, + }); +} catch (err) { + exitCode = 1; + console.error(err instanceof Error ? err.message : String(err)); +} finally { + try { + runCommand("pnpm", ["harness:down"]); + } catch (err) { + exitCode = 1; + console.error(err instanceof Error ? err.message : String(err)); + } +} + +process.exit(exitCode); diff --git a/scripts/quality-audit.ts b/scripts/quality-audit.ts new file mode 100644 index 0000000..6ba5471 --- /dev/null +++ b/scripts/quality-audit.ts @@ -0,0 +1,26 @@ +import { existsSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +const DOMAIN_LAYERS = ["types", "config", "repo", "service", "runtime", "ui"]; +const domainsDir = join(process.cwd(), "src/domains"); + +if (!existsSync(domainsDir)) { + console.log("No domains found."); + process.exit(0); +} + +for (const domain of readdirSync(domainsDir, { withFileTypes: true }).filter((entry) => + entry.isDirectory(), +)) { + console.log(`domain: ${domain.name}`); + for (const layer of DOMAIN_LAYERS) { + const layerDir = join(domainsDir, domain.name, layer); + const sourceFiles = existsSync(layerDir) + ? readdirSync(layerDir).filter((file) => /\.(ts|tsx)$/.test(file) && !file.includes(".test.")) + : []; + const testFiles = existsSync(layerDir) + ? readdirSync(layerDir).filter((file) => /\.(test|integration\.test)\.(ts|tsx)$/.test(file)) + : []; + console.log(` ${layer}: ${sourceFiles.length} source, ${testFiles.length} test`); + } +} diff --git a/scripts/worktree-boot.sh b/scripts/worktree-boot.sh index 5f56078..6fdaac9 100644 --- a/scripts/worktree-boot.sh +++ b/scripts/worktree-boot.sh @@ -1,32 +1,4 @@ #!/usr/bin/env bash -# Boot an isolated app instance for the current git worktree. -# -# Usage: pnpm worktree:boot -# -# Each worktree gets its own: -# - Database (via Docker Compose with unique project name) -# - App server on a unique port -# - Observability stack (Pino logs to stdout) -# -# This enables agents to work on multiple tasks in parallel -# without interference. - set -euo pipefail -WORKTREE_NAME=$(basename "$(git rev-parse --show-toplevel)") -PROJECT_NAME="aft-${WORKTREE_NAME}" - -echo "🚀 Booting isolated instance for worktree: ${WORKTREE_NAME}" -echo " Project name: ${PROJECT_NAME}" - -# Start database -if [ -f docker-compose.yml ]; then - docker compose -p "${PROJECT_NAME}" up -d db 2>/dev/null || echo "⚠️ Docker not available, skipping DB" -fi - -# Install deps if needed -if [ ! -d node_modules ]; then - pnpm install --frozen-lockfile -fi - -echo "✅ Worktree ready. Run 'pnpm dev' to start the app." +pnpm harness:boot diff --git a/src/app-server.test.ts b/src/app-server.test.ts new file mode 100644 index 0000000..b1639da --- /dev/null +++ b/src/app-server.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { buildServer } from "./app-server.js"; + +describe("buildServer", () => { + it("exposes a health endpoint without database access", async () => { + const app = await buildServer(); + try { + const response = await app.inject({ method: "GET", url: "/healthz" }); + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ ok: true }); + } finally { + await app.close(); + } + }); +}); diff --git a/src/app-server.ts b/src/app-server.ts new file mode 100644 index 0000000..8d8b28b --- /dev/null +++ b/src/app-server.ts @@ -0,0 +1,40 @@ +import { closeDb } from "@providers/database/index.js"; +import { createLogger } from "@providers/telemetry/index.js"; +import Fastify from "fastify"; +import { registerItemRoutes } from "./domains/example/runtime/routes.js"; + +const log = createLogger("app-server"); + +export async function buildServer() { + const app = Fastify({ + logger: false, + genReqId: () => crypto.randomUUID(), + }); + + app.addHook("onRequest", async (request) => { + request.headers["x-request-start"] = String(performance.now()); + }); + + app.addHook("onResponse", async (request, reply) => { + const started = Number(request.headers["x-request-start"] ?? performance.now()); + log.info( + { + requestId: request.id, + method: request.method, + url: request.url, + statusCode: reply.statusCode, + durationMs: Math.round(performance.now() - started), + }, + "HTTP request completed", + ); + }); + + app.get("/healthz", async () => ({ ok: true })); + await registerItemRoutes(app); + + app.addHook("onClose", async () => { + await closeDb(); + }); + + return app; +} diff --git a/src/app/app.test.tsx b/src/app/app.test.tsx new file mode 100644 index 0000000..45a3301 --- /dev/null +++ b/src/app/app.test.tsx @@ -0,0 +1,9 @@ +import { renderToString } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { App } from "./app.js"; + +describe("App", () => { + it("renders the application shell", () => { + expect(renderToString()).toContain("Agent-First Template"); + }); +}); diff --git a/src/app/vite.config.ts b/src/app/vite.config.ts index 7f34c7c..a809c68 100644 --- a/src/app/vite.config.ts +++ b/src/app/vite.config.ts @@ -15,7 +15,7 @@ export default defineConfig({ port: 3000, proxy: { "/api": { - target: "http://localhost:4000", + target: process.env.API_ORIGIN ?? "http://localhost:4000", changeOrigin: true, }, }, diff --git a/src/domains/example/config/index.test.ts b/src/domains/example/config/index.test.ts new file mode 100644 index 0000000..873bcd4 --- /dev/null +++ b/src/domains/example/config/index.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from "vitest"; +import { exampleConfig } from "./index.js"; + +describe("exampleConfig", () => { + it("uses draft as the default status", () => { + expect(exampleConfig.defaultStatus).toBe("draft"); + }); +}); diff --git a/src/domains/example/config/index.ts b/src/domains/example/config/index.ts new file mode 100644 index 0000000..69456db --- /dev/null +++ b/src/domains/example/config/index.ts @@ -0,0 +1,3 @@ +export const exampleConfig = { + defaultStatus: "draft", +} as const; diff --git a/src/domains/example/repo/item-repo.integration.test.ts b/src/domains/example/repo/item-repo.integration.test.ts new file mode 100644 index 0000000..2662a1c --- /dev/null +++ b/src/domains/example/repo/item-repo.integration.test.ts @@ -0,0 +1,32 @@ +import { closeDb, getDb } from "@providers/database/index.js"; +import { sql } from "drizzle-orm"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { itemRepo } from "./item-repo.js"; + +const runDbTests = Boolean(process.env.DATABASE_URL); + +describe.skipIf(!runDbTests)("itemRepo integration", () => { + beforeEach(async () => { + await getDb().execute(sql`TRUNCATE TABLE items`); + }); + + afterAll(async () => { + await closeDb(); + }); + + it("creates, lists, finds, and deletes items in Postgres", async () => { + const created = await itemRepo.create({ name: "Harness Item", status: "draft" }); + + expect(created.id).toMatch(/[0-9a-f-]{36}/); + expect(created.name).toBe("Harness Item"); + + await expect(itemRepo.findById(created.id)).resolves.toMatchObject({ + id: created.id, + name: "Harness Item", + }); + + await expect(itemRepo.findAll()).resolves.toHaveLength(1); + await expect(itemRepo.delete(created.id)).resolves.toBe(true); + await expect(itemRepo.findAll()).resolves.toHaveLength(0); + }); +}); diff --git a/src/domains/example/repo/item-repo.ts b/src/domains/example/repo/item-repo.ts index 911d243..0a9594a 100644 --- a/src/domains/example/repo/item-repo.ts +++ b/src/domains/example/repo/item-repo.ts @@ -4,36 +4,37 @@ * May import from: types, config * Must NOT import from: service, runtime, ui * - * In a real app, this would use Drizzle ORM to query PostgreSQL. - * This scaffold uses an in-memory store as a placeholder. + * Uses Drizzle ORM to query PostgreSQL. Database rows are parsed before + * returning domain values. */ +import { getDb } from "@providers/database/index.js"; +import { eq } from "drizzle-orm"; import type { CreateItem, Item } from "../types/index.js"; - -// Placeholder: replace with Drizzle queries against PostgreSQL -const store = new Map(); +import { parseItemRow } from "./item-row.js"; +import { itemsTable } from "./item-table.js"; export const itemRepo = { async findById(id: string): Promise { - return store.get(id) ?? null; + const rows = await getDb().select().from(itemsTable).where(eq(itemsTable.id, id)).limit(1); + return rows[0] ? parseItemRow(rows[0]) : null; }, async findAll(): Promise { - return Array.from(store.values()); + const rows = await getDb().select().from(itemsTable).orderBy(itemsTable.createdAt); + return rows.map(parseItemRow); }, async create(input: CreateItem): Promise { - const item: Item = { - ...input, - id: crypto.randomUUID(), - createdAt: new Date(), - updatedAt: new Date(), - }; - store.set(item.id, item); - return item; + const rows = await getDb().insert(itemsTable).values(input).returning(); + return parseItemRow(rows[0]); }, async delete(id: string): Promise { - return store.delete(id); + const rows = await getDb() + .delete(itemsTable) + .where(eq(itemsTable.id, id)) + .returning({ id: itemsTable.id }); + return rows.length > 0; }, }; diff --git a/src/domains/example/repo/item-row.test.ts b/src/domains/example/repo/item-row.test.ts new file mode 100644 index 0000000..9b73421 --- /dev/null +++ b/src/domains/example/repo/item-row.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { parseItemRow } from "./item-row.js"; + +describe("parseItemRow", () => { + it("parses a database row into an item", () => { + const item = parseItemRow({ + id: "550e8400-e29b-41d4-a716-446655440000", + name: "Test Item", + description: null, + status: "active", + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), + }); + + expect(item.name).toBe("Test Item"); + }); + + it("rejects invalid database rows", () => { + expect(() => parseItemRow({ id: "bad" })).toThrow(); + }); +}); diff --git a/src/domains/example/repo/item-row.ts b/src/domains/example/repo/item-row.ts new file mode 100644 index 0000000..18f9059 --- /dev/null +++ b/src/domains/example/repo/item-row.ts @@ -0,0 +1,5 @@ +import { type Item, ItemSchema } from "../types/index.js"; + +export function parseItemRow(row: unknown): Item { + return ItemSchema.parse(row); +} diff --git a/src/domains/example/repo/item-table.test.ts b/src/domains/example/repo/item-table.test.ts new file mode 100644 index 0000000..8ac3df7 --- /dev/null +++ b/src/domains/example/repo/item-table.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from "vitest"; +import { itemsTable } from "./item-table.js"; + +describe("itemsTable", () => { + it("defines the items table", () => { + expect(itemsTable).toBeDefined(); + }); +}); diff --git a/src/domains/example/repo/item-table.ts b/src/domains/example/repo/item-table.ts new file mode 100644 index 0000000..ccc248e --- /dev/null +++ b/src/domains/example/repo/item-table.ts @@ -0,0 +1,10 @@ +import { pgTable, text, timestamp, uuid, varchar } from "drizzle-orm/pg-core"; + +export const itemsTable = pgTable("items", { + id: uuid("id").primaryKey().defaultRandom(), + name: varchar("name", { length: 255 }).notNull(), + description: text("description"), + status: varchar("status", { length: 32 }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); diff --git a/src/domains/example/runtime/routes.test.ts b/src/domains/example/runtime/routes.test.ts new file mode 100644 index 0000000..68efc06 --- /dev/null +++ b/src/domains/example/runtime/routes.test.ts @@ -0,0 +1,17 @@ +import Fastify from "fastify"; +import { describe, expect, it } from "vitest"; +import { registerItemRoutes } from "./routes.js"; + +describe("registerItemRoutes", () => { + it("rejects invalid item ids before database access", async () => { + const app = Fastify(); + await registerItemRoutes(app); + try { + const response = await app.inject({ method: "GET", url: "/api/items/not-a-uuid" }); + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ error: "Invalid item id" }); + } finally { + await app.close(); + } + }); +}); diff --git a/src/domains/example/runtime/routes.ts b/src/domains/example/runtime/routes.ts index b22c5b8..115ddbc 100644 --- a/src/domains/example/runtime/routes.ts +++ b/src/domains/example/runtime/routes.ts @@ -9,6 +9,7 @@ import type { FastifyInstance } from "fastify"; import { itemService } from "../service/item-service.js"; +import { ItemIdSchema } from "../types/index.js"; export async function registerItemRoutes(app: FastifyInstance) { app.get("/api/items", async () => { @@ -16,7 +17,12 @@ export async function registerItemRoutes(app: FastifyInstance) { }); app.get<{ Params: { id: string } }>("/api/items/:id", async (request, reply) => { - const item = await itemService.getItem(request.params.id); + const id = ItemIdSchema.safeParse(request.params.id); + if (!id.success) { + return reply.status(400).send({ error: "Invalid item id" }); + } + + const item = await itemService.getItem(id.data); if (!item) { return reply.status(404).send({ error: "Item not found" }); } @@ -33,7 +39,12 @@ export async function registerItemRoutes(app: FastifyInstance) { }); app.delete<{ Params: { id: string } }>("/api/items/:id", async (request, reply) => { - const deleted = await itemService.deleteItem(request.params.id); + const id = ItemIdSchema.safeParse(request.params.id); + if (!id.success) { + return reply.status(400).send({ error: "Invalid item id" }); + } + + const deleted = await itemService.deleteItem(id.data); if (!deleted) { return reply.status(404).send({ error: "Item not found" }); } diff --git a/src/domains/example/service/item-service.test.ts b/src/domains/example/service/item-service.test.ts new file mode 100644 index 0000000..0e2406f --- /dev/null +++ b/src/domains/example/service/item-service.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { itemRepo } from "../repo/item-repo.js"; +import { itemService } from "./item-service.js"; + +vi.mock("../repo/item-repo.js", () => ({ + itemRepo: { + create: vi.fn(), + delete: vi.fn(), + findAll: vi.fn(), + findById: vi.fn(), + }, +})); + +describe("itemService", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("parses create input before writing to the repo", async () => { + vi.mocked(itemRepo.create).mockResolvedValue({ + id: "550e8400-e29b-41d4-a716-446655440000", + name: "Created", + status: "draft", + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), + }); + + await itemService.createItem({ name: "Created", status: "draft" }); + + expect(itemRepo.create).toHaveBeenCalledWith({ name: "Created", status: "draft" }); + }); + + it("rejects invalid create input", async () => { + await expect(itemService.createItem({ name: "", status: "draft" })).rejects.toThrow(); + expect(itemRepo.create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/domains/example/types/index.ts b/src/domains/example/types/index.ts index 205cbde..5efa2a9 100644 --- a/src/domains/example/types/index.ts +++ b/src/domains/example/types/index.ts @@ -1 +1 @@ -export { ItemSchema, CreateItemSchema, type Item, type CreateItem } from "./item.js"; +export { ItemSchema, CreateItemSchema, ItemIdSchema, type Item, type CreateItem } from "./item.js"; diff --git a/src/domains/example/types/item.ts b/src/domains/example/types/item.ts index a33e992..4bed48e 100644 --- a/src/domains/example/types/item.ts +++ b/src/domains/example/types/item.ts @@ -10,12 +10,17 @@ import { z } from "zod"; export const ItemSchema = z.object({ id: z.string().uuid(), name: z.string().min(1).max(255), - description: z.string().max(2000).optional(), + description: z.preprocess( + (value) => (value === null ? undefined : value), + z.string().max(2000).optional(), + ), status: z.enum(["draft", "active", "archived"]), createdAt: z.coerce.date(), updatedAt: z.coerce.date(), }); +export const ItemIdSchema = z.string().uuid(); + export type Item = z.infer; export const CreateItemSchema = ItemSchema.omit({ diff --git a/src/domains/example/ui/item-list.test.tsx b/src/domains/example/ui/item-list.test.tsx new file mode 100644 index 0000000..5969e69 --- /dev/null +++ b/src/domains/example/ui/item-list.test.tsx @@ -0,0 +1,9 @@ +import { renderToString } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { ItemList } from "./item-list.js"; + +describe("ItemList", () => { + it("renders a deterministic loading state before browser effects run", () => { + expect(renderToString()).toContain("Loading..."); + }); +}); diff --git a/src/providers/database/client.test.ts b/src/providers/database/client.test.ts new file mode 100644 index 0000000..509d976 --- /dev/null +++ b/src/providers/database/client.test.ts @@ -0,0 +1,25 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { closeDb, getDatabaseUrl } from "./client.js"; + +const originalDatabaseUrl = process.env.DATABASE_URL; + +describe("getDatabaseUrl", () => { + afterEach(async () => { + if (originalDatabaseUrl === undefined) { + process.env.DATABASE_URL = ""; + } else { + process.env.DATABASE_URL = originalDatabaseUrl; + } + await closeDb(); + }); + + it("requires DATABASE_URL", () => { + process.env.DATABASE_URL = ""; + expect(() => getDatabaseUrl()).toThrow("DATABASE_URL is required"); + }); + + it("returns DATABASE_URL when configured", () => { + process.env.DATABASE_URL = "postgres://app:localdev@127.0.0.1:5432/app"; + expect(getDatabaseUrl()).toBe("postgres://app:localdev@127.0.0.1:5432/app"); + }); +}); diff --git a/src/providers/database/client.ts b/src/providers/database/client.ts new file mode 100644 index 0000000..9c73c1c --- /dev/null +++ b/src/providers/database/client.ts @@ -0,0 +1,35 @@ +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; + +let client: postgres.Sql | null = null; +let db: ReturnType | null = null; + +export function getDatabaseUrl() { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) { + throw new Error("DATABASE_URL is required for database access."); + } + return databaseUrl; +} + +export function getDb() { + if (!client) { + client = postgres(getDatabaseUrl(), { + max: Number(process.env.DATABASE_POOL_SIZE ?? 5), + }); + db = drizzle(client); + } + + if (!db) { + throw new Error("Database client failed to initialize."); + } + return db; +} + +export async function closeDb() { + if (client) { + await client.end(); + client = null; + db = null; + } +} diff --git a/src/providers/database/index.ts b/src/providers/database/index.ts new file mode 100644 index 0000000..5e63692 --- /dev/null +++ b/src/providers/database/index.ts @@ -0,0 +1 @@ +export { closeDb, getDatabaseUrl, getDb } from "./client.js"; diff --git a/src/providers/telemetry/logger.test.ts b/src/providers/telemetry/logger.test.ts new file mode 100644 index 0000000..c1a4105 --- /dev/null +++ b/src/providers/telemetry/logger.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from "vitest"; +import { createLogger } from "./logger.js"; + +describe("createLogger", () => { + it("binds the domain to child loggers", () => { + expect(createLogger("tests").bindings()).toMatchObject({ domain: "tests" }); + }); +}); diff --git a/src/server.ts b/src/server.ts index 70f37b1..597299c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -5,16 +5,12 @@ * Each domain's runtime layer exports a route registration function. */ -import { createLogger } from "@providers/telemetry/index.js"; -import Fastify from "fastify"; -import { registerItemRoutes } from "./domains/example/runtime/routes.js"; +import { buildServer } from "./app-server.js"; +import { createLogger } from "./providers/telemetry/index.js"; const log = createLogger("server"); -const app = Fastify({ logger: false }); - -// Register domain routes -await registerItemRoutes(app); +const app = await buildServer(); // Start const port = Number(process.env.PORT ?? 4000); @@ -27,3 +23,11 @@ app.listen({ port, host }, (err, address) => { } log.info({ address }, "Server started"); }); + +for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.on(signal, async () => { + log.info({ signal }, "Shutting down server"); + await app.close(); + process.exit(0); + }); +} diff --git a/tests/e2e/item-flow.spec.ts b/tests/e2e/item-flow.spec.ts new file mode 100644 index 0000000..db1926a --- /dev/null +++ b/tests/e2e/item-flow.spec.ts @@ -0,0 +1,35 @@ +import { expect, test } from "@playwright/test"; + +const apiOrigin = process.env.API_ORIGIN ?? "http://127.0.0.1:4000"; + +test.beforeEach(async ({ request }) => { + const response = await request.get(`${apiOrigin}/api/items`); + expect(response.ok()).toBe(true); + for (const item of await response.json()) { + await request.delete(`${apiOrigin}/api/items/${item.id}`); + } +}); + +test("creates, reloads, and deletes an item through the UI", async ({ page }) => { + await page.goto("/"); + await expect(page.getByText("No items yet. Create one above.")).toBeVisible(); + + await page.getByPlaceholder("New item name...").fill("Browser Harness Item"); + await page.getByRole("button", { name: "Add" }).click(); + + await expect(page.getByText("Browser Harness Item")).toBeVisible(); + await page.reload(); + await expect(page.getByText("Browser Harness Item")).toBeVisible(); + + await page.getByRole("button", { name: "Delete" }).click(); + await expect(page.getByText("No items yet. Create one above.")).toBeVisible(); +}); + +test("shows an error when the item API fails", async ({ page }) => { + await page.route("**/api/items", async (route) => { + await route.fulfill({ status: 503, body: "service unavailable" }); + }); + + await page.goto("/"); + await expect(page.getByText("HTTP 503")).toBeVisible(); +}); diff --git a/tsconfig.json b/tsconfig.json index 0eb1d90..62bf034 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,6 @@ "@providers/*": ["src/providers/*"] } }, - "include": ["src/**/*.ts", "src/**/*.tsx"], + "include": ["src/**/*.ts", "src/**/*.tsx", "drizzle.config.ts"], "exclude": ["node_modules", "dist"] } diff --git a/vitest.config.ts b/vitest.config.ts index 6787de1..cf96be4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,14 +1,15 @@ +import { resolve } from "node:path"; import { defineConfig } from "vitest/config"; export default defineConfig({ test: { globals: true, - include: ["src/**/*.test.ts"], + include: ["src/**/*.test.ts", "src/**/*.test.tsx"], }, resolve: { alias: { - "@domains": "./src/domains", - "@providers": "./src/providers", + "@domains": resolve(__dirname, "src/domains"), + "@providers": resolve(__dirname, "src/providers"), }, }, });