Skip to content
Merged
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
50 changes: 49 additions & 1 deletion .agents/conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,11 +319,59 @@ Rules:

## Testing

### Vitest (unit / integration / static analysis)

- Unit tests go next to the file they test: `foo.test.ts` alongside `foo.ts`
- Use Vitest: `import { describe, it, expect } from 'vitest'`
- API route tests: test the route handler directly
- Skip tests for components that are just layout/styling with no logic
- Run before pushing: `pnpm lint && pnpm typecheck && pnpm test`

### Playwright (E2E)

E2E tests live in `e2e/` at the project root. Config: `playwright.config.ts`.

```typescript
// Unauthenticated test — uses base Playwright test
import { test, expect } from "@playwright/test";

test("sign-in page renders", async ({ page }) => {
await page.goto("/sign-in");
await expect(page.locator('input[type="email"]')).toBeVisible();
});
```

```typescript
// Authenticated test — uses the auth fixture
import { test, expect } from "./fixtures/auth";

test("editor loads on page", async ({ authenticatedPage: page }) => {
// authenticatedPage is already logged in
const editor = page.locator('[contenteditable="true"]');
await expect(editor).toBeVisible({ timeout: 10_000 });
});
```

#### Selector conventions

- Prefer `getByRole`, `getByLabel`, `getByText` over CSS selectors
- For editor elements: `[contenteditable="true"]`, `[data-lexical-editor]`
- For drag handles: `.memo-draggable-block-menu`
- For floating UI: `[role="toolbar"]`, `[role="option"]`
- Use `.filter({ hasText: ... })` to narrow generic selectors

#### Test structure

- One `describe` block per feature area
- Use `test.beforeEach` for common navigation (e.g., opening a page)
- Use `test.skip(true, "reason")` when preconditions aren't met (no pages, no button)
- Timeouts: 10s for page loads, 3s for UI elements, 2s for state changes

#### Running

- All tests: `pnpm test:e2e`
- Single file: `pnpm test:e2e -- e2e/editor-drag.spec.ts`
- Authenticated tests require `TEST_USER_EMAIL` and `TEST_USER_PASSWORD` env vars
- Run before pushing: `pnpm lint && pnpm typecheck && pnpm test && pnpm test:e2e`

## Imports

Expand Down
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,20 @@ jobs:
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm test -- --run
e2e:
runs-on: ubuntu-latest
needs: check
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: npx playwright install --with-deps chromium
- name: Run unauthenticated E2E tests
run: pnpm test:e2e -- e2e/auth.spec.ts
env:
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY }}
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@

# testing
/coverage
/test-results/
/playwright-report/
/blob-report/

# next.js
/.next/
Expand Down
8 changes: 7 additions & 1 deletion .ona/automations/bug-fixer.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,15 @@ action:
9. Fix the root cause. Do NOT fix symptoms.
- Follow every convention in AGENTS.md without exception.
- Add a regression test that would have caught this bug.
For interaction bugs (drag-and-drop, floating UI, hover states, multi-step
flows), add a Playwright E2E test in `e2e/`. Use the auth fixture from
`e2e/fixtures/auth.ts` for authenticated tests. See `.agents/conventions.md`
→ Testing → Playwright for patterns.
For logic/data bugs, add a Vitest unit or integration test.
- For database changes: `npx supabase migration new <name>`
10. Run the full CI check locally: `pnpm lint && pnpm typecheck && pnpm test`
11. Fix any errors before proceeding.
11. Run E2E tests: `pnpm test:e2e`
12. Fix any errors before proceeding.

## Update Knowledge Base

Expand Down
9 changes: 7 additions & 2 deletions .ona/automations/feature-builder.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,13 @@ action:
2. For each criterion, confirm your implementation satisfies it.
If a criterion cannot be verified locally (e.g., "works on mobile"), note it in the PR.
3. Run the full CI check: `pnpm lint && pnpm typecheck && pnpm test`
4. Fix any errors before proceeding.
5. If any acceptance criterion is NOT met, either fix it or explain in the PR body
4. If the feature involves interactive UI (drag-and-drop, floating toolbars/menus,
multi-step flows), write E2E tests in `e2e/` using Playwright. Use the auth
fixture from `e2e/fixtures/auth.ts` for authenticated tests. See
`.agents/conventions.md` → Testing → Playwright for patterns.
5. Run E2E tests: `pnpm test:e2e`
6. Fix any errors before proceeding.
7. If any acceptance criterion is NOT met, either fix it or explain in the PR body
why it was descoped (and create a follow-up issue if needed).

## Update Knowledge Base
Expand Down
11 changes: 10 additions & 1 deletion .ona/automations/post-merge-verifier.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,16 @@ action:

Wait 90 seconds for Vercel to deploy the merge to production.

## Step 3 — Run smoke tests
## Step 3 — Run E2E test suite

First, run the project's E2E test suite against the live site:
BASE_URL=https://memo.software-factory.dev pnpm test:e2e

If E2E tests fail, include the failures in the report (Step 4).
If E2E tests pass, still run the ad-hoc smoke tests below for routes
not yet covered by the E2E suite.

## Step 3b — Run ad-hoc smoke tests

Write a Playwright script to /tmp/smoke-test.mjs.

Expand Down
33 changes: 29 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,36 @@ metrics/ → Daily/weekly metrics snapshots

## Testing

- Unit tests: utility functions, non-trivial logic
- Integration tests: API routes
- E2E (Playwright): critical user flows, new pages
- Unit tests (Vitest): utility functions, non-trivial logic, API route handlers
- Integration tests (Vitest): API routes with mocked Supabase
- E2E tests (Playwright): interactive features, critical user flows, new pages
- Static analysis tests (Vitest): design spec compliance checks on source code
- Skip tests for trivial layout-only components
- Run before pushing: `pnpm lint && pnpm typecheck && pnpm test`

### When to write E2E tests

- Drag-and-drop (editor blocks, sidebar pages)
- Floating UI (toolbars, menus, popovers that appear/disappear based on user action)
- Multi-step flows (auth, page creation, workspace switching)
- Any feature where the bug would only manifest in a real browser (not jsdom)

### When unit tests are sufficient

- Pure functions and utilities
- API route handlers (mock Supabase)
- Data transformations (markdown conversion, tree building)
- Component rendering without complex interaction

### E2E test location

- Config: `playwright.config.ts`
- Tests: `e2e/` directory
- Auth fixture: `e2e/fixtures/auth.ts` — provides `authenticatedPage` for tests needing login
- Authenticated tests require `TEST_USER_EMAIL` and `TEST_USER_PASSWORD` env vars

### Running tests

- Run before pushing: `pnpm lint && pnpm typecheck && pnpm test && pnpm test:e2e`

## Backlog

Expand Down
84 changes: 84 additions & 0 deletions e2e/auth.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { test, expect } from "@playwright/test";

test.describe("Authentication", () => {
test("sign-in page renders with email and password inputs", async ({
page,
}) => {
await page.goto("/sign-in");
await expect(page.locator('input[type="email"]')).toBeVisible();
await expect(page.locator('input[type="password"]')).toBeVisible();
await expect(page.getByRole("button", { name: /sign in/i })).toBeVisible();
});

test("sign-up page renders with display name, email, and password inputs", async ({
page,
}) => {
await page.goto("/sign-up");
await expect(page.locator('input[type="email"]')).toBeVisible();
await expect(page.locator('input[type="password"]')).toBeVisible();
await expect(page.getByRole("button", { name: /sign up/i })).toBeVisible();
});

test("unauthenticated user is redirected to sign-in", async ({ page }) => {
// Try to access an authenticated route
await page.goto("/test-workspace");
await page.waitForURL(/sign-in/, { timeout: 10_000 });
expect(page.url()).toContain("/sign-in");
});

test("user can sign in and lands in a workspace", async ({ page }) => {
const email = process.env.TEST_USER_EMAIL;
const password = process.env.TEST_USER_PASSWORD;
if (!email || !password) {
test.skip(true, "TEST_USER_EMAIL / TEST_USER_PASSWORD not set");
return;
}

await page.goto("/sign-in");
await page.fill('input[type="email"]', email);
await page.fill('input[type="password"]', password);
await page.click('button[type="submit"]');

// Should redirect to a workspace (not stay on sign-in)
await page.waitForURL((url) => !url.pathname.includes("/sign-in"), {
timeout: 15_000,
});
expect(page.url()).not.toContain("/sign-in");
});

test("user can sign out", async ({ page }) => {
const email = process.env.TEST_USER_EMAIL;
const password = process.env.TEST_USER_PASSWORD;
if (!email || !password) {
test.skip(true, "TEST_USER_EMAIL / TEST_USER_PASSWORD not set");
return;
}

// Sign in first
await page.goto("/sign-in");
await page.fill('input[type="email"]', email);
await page.fill('input[type="password"]', password);
await page.click('button[type="submit"]');
await page.waitForURL((url) => !url.pathname.includes("/sign-in"), {
timeout: 15_000,
});

// Find and click sign out (in user menu)
// Try the user menu dropdown first
const userMenuTrigger = page.locator("button").filter({ hasText: /@|sign out/i });
if ((await userMenuTrigger.count()) > 0) {
await userMenuTrigger.first().click();
await page.waitForTimeout(300);
}

const signOutBtn = page.getByRole("menuitem", { name: /sign out/i }).or(
page.locator("button").filter({ hasText: /sign out/i })
);

if ((await signOutBtn.count()) > 0) {
await signOutBtn.first().click();
await page.waitForURL(/sign-in/, { timeout: 10_000 });
expect(page.url()).toContain("/sign-in");
}
});
});
Loading
Loading