Files
familysync/.planning/phases/07-mobile-test-harness/07-PATTERNS.md
T
Lucas Berger 982438dc10 style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
2026-06-11 20:35:18 -04:00

19 KiB
Raw Blame History

Phase 7: Mobile Test Harness — Pattern Map

Mapped: 2026-06-10 Files analyzed: 7 (5 new, 2 modified) Analogs found: 7 / 7


File Classification

New/Modified File Role Data Flow Closest Analog Match Quality
apps/pwa/playwright.config.ts config request-response apps/pwa/vitest.config.ts + apps/api/vitest.config.ts role-match (same config-file shape, different runner)
apps/pwa/e2e/global-setup.ts utility CRUD (DB seed + HTTP poll) apps/api/src/db/client.ts (mysql2 connection) + apps/api/tests/routes/lists.test.ts (seed helpers) partial-match (same DB driver + env-var pattern)
apps/pwa/e2e/layout.spec.ts test request-response apps/pwa/src/components/CalendarShell.test.tsx (role/name locators, screen queries) role-match
apps/pwa/e2e/calendar.spec.ts test request-response apps/pwa/src/components/CalendarShell.test.tsx role-match
apps/pwa/e2e/lists.spec.ts test request-response apps/pwa/src/routes/ListDetail.test.tsx role-match
apps/pwa/vitest.config.ts (modify) config apps/pwa/vitest.config.ts (self — add exclude) exact
apps/pwa/package.json (modify) config apps/pwa/package.json (self) + root package.json (script conventions) exact

Pattern Assignments

apps/pwa/playwright.config.ts (config, new)

Analog: apps/pwa/vitest.config.ts (lines 114) — defineConfig wrapper convention; and apps/api/vitest.config.ts (lines 114) — fileParallelism: false and setupFiles equivalents.

Config structure pattern (apps/pwa/vitest.config.ts, lines 114):

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./src/test-setup.ts'],
    env: { TZ: 'UTC' },
  },
});

Key observation: no explicit include — Vitest defaults catch *.spec.ts too, which is why exclude must be added.

Serial execution pattern (apps/api/vitest.config.ts, lines 114):

export default defineConfig({
  test: {
    environment: 'node',
    globals: true,
    setupFiles: ['./test/setup.ts'],
    fileParallelism: false, // ← serial DB tests; analogous to workers:1 in CI
  },
});

Playwright config shape to produce (from RESEARCH.md Architecture Patterns §Pattern 1):

// apps/pwa/playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  testMatch: '**/*.spec.ts',
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: process.env.CI ? 'github' : 'list',
  globalSetup: './e2e/global-setup.ts',

  use: {
    baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173',
    trace: 'on-first-retry',
    video: 'on-first-retry',
    screenshot: 'only-on-failure',
  },

  projects: [
    {
      name: 'iphone',
      use: {
        ...devices['iPhone 14'],
        serviceWorkers: 'block', // D-02 / Pitfall 15
      },
    },
    {
      name: 'pixel',
      use: {
        ...devices['Pixel 7'],
        serviceWorkers: 'block',
      },
    },
  ],

  webServer: {
    command: 'pnpm --filter @familysync/pwa dev',
    url: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173',
    reuseExistingServer: !process.env.CI, // D-10
    timeout: 120_000,
  },
});

apps/pwa/e2e/global-setup.ts (utility, new)

Analog 1 — mysql2 connection env-var pattern: apps/api/src/db/client.ts (lines 116)

// apps/api/src/db/client.ts lines 6-14 — exact env-var names to copy
const pool = mysql.createPool({
  host: process.env.DB_HOST ?? 'localhost',
  port: Number(process.env.DB_PORT ?? 3306),
  user: process.env.DB_USER ?? 'familysync',
  password: process.env.DB_PASSWORD ?? '',
  database: process.env.DB_NAME ?? 'familysync',
  waitForConnections: true,
  connectionLimit: 10,
});

The global-setup uses mysql.createConnection (single connection, not pool) with identical env-var names. DB_HOST defaults to 127.0.0.1 (not localhost) per project memory api-integration-test-db.

Analog 2 — seed helper pattern: apps/api/tests/routes/lists.test.ts (lines 5085) — shows Drizzle-based seed helpers. The global-setup uses raw mysql2 instead (no Drizzle outside API), but the INSERT shape and table names are confirmed here:

  • lists: (owner_id, name, is_shared)ownerId=1, isShared=true
  • list_shares: (list_id, user_id) — join table, seed one row for user 1
  • list_items: (list_id, text, checked, rank)rank is fractional-indexing string (e.g. 'a0', 'a1')

Schema column names (confirmed from apps/api/src/db/schema.ts):

Table Relevant columns
calendars id, user_id, url, display_name, color, is_shared
calendar_events calendar_id, uid, etag, raw_vevent, title, dtstart_utc (TIMESTAMP), dtstart_date (DATE), all_day, has_rrule
lists id, owner_id, name, is_shared
list_shares list_id, user_id
list_items list_id, text, checked, rank (utf8mb4_bin varchar)

DEV_USER confirmed (apps/api/src/auth/devBypass.ts, lines 3036):

export const DEV_USER = {
  id: 1,
  oidcIss: 'dev',
  oidcSub: 'dev-user',
  displayName: 'Dev User',
  color: '#4A90D9',
} as const;

Seeds must target user_id = 1 and owner_id = 1.

Guard for production (apps/api/src/auth/devBypass.ts, lines 6166):

if (process.env.NODE_ENV === 'production') {
  return async (_c, next) => next();
}
if (process.env.DEV_AUTH_BYPASS !== 'true') {
  return async (_c, next) => next();
}

The bypass requires both NODE_ENV !== 'production' AND DEV_AUTH_BYPASS=true. The harness does not control these; they must be set before the API process starts.

Full global-setup shape (from RESEARCH.md §Pattern 2):

// apps/pwa/e2e/global-setup.ts
import mysql from 'mysql2/promise';

export default async function globalSetup() {
  const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173';
  const deadline = Date.now() + 60_000;
  while (Date.now() < deadline) {
    try {
      const res = await fetch(`${baseURL}/health`);
      if (res.ok) break;
    } catch {
      /* ECONNREFUSED — not ready */
    }
    await new Promise((r) => setTimeout(r, 1_000));
  }

  const conn = await mysql.createConnection({
    host: process.env.DB_HOST ?? '127.0.0.1',
    port: Number(process.env.DB_PORT ?? 3306),
    user: process.env.DB_USER ?? 'familysync',
    password: process.env.DB_PASSWORD ?? '',
    database: process.env.DB_NAME ?? 'familysync',
  });
  try {
    await conn.execute('SET FOREIGN_KEY_CHECKS=0');
    await conn.execute('TRUNCATE TABLE list_items');
    await conn.execute('TRUNCATE TABLE list_shares');
    await conn.execute('TRUNCATE TABLE lists');
    await conn.execute('TRUNCATE TABLE calendar_events');
    await conn.execute('SET FOREIGN_KEY_CHECKS=1');

    // CI guard: ensure calendars row id=10 exists (Pitfall 4)
    await conn.execute(
      `INSERT IGNORE INTO calendars (id, user_id, url, display_name, color, is_shared)
       VALUES (10, 1, 'https://caldav.fastmail.com/dav/calendars/e2e/', 'FamilySync', '#4A90D9', true)`,
    );

    // Seed one timed calendar event on shared calendar id=10
    const uid = 'e2e-seed-event-001';
    const futureStart = new Date(Date.now() + 24 * 60 * 60 * 1000);
    const futureStartUtc = futureStart.toISOString().replace(/\.\d+Z$/, 'Z');
    const rawVevent = [
      'BEGIN:VCALENDAR',
      'VERSION:2.0',
      'BEGIN:VEVENT',
      `UID:${uid}`,
      `DTSTART:${futureStart
        .toISOString()
        .replace(/[-:]/g, '')
        .replace(/\.\d+Z$/, 'Z')}`,
      'SUMMARY:Seeded Test Event',
      'END:VEVENT',
      'END:VCALENDAR',
    ].join('\r\n');
    await conn.execute(
      `INSERT INTO calendar_events
         (calendar_id, uid, etag, raw_vevent, title, dtstart_utc, all_day, has_rrule)
       VALUES (10, ?, 'e2e-etag-001', ?, 'Seeded Test Event', ?, false, false)`,
      [uid, rawVevent, futureStartUtc],
    );

    // Seed one list with two items for user 1
    const [listResult] = (await conn.execute(
      `INSERT INTO lists (owner_id, name, is_shared) VALUES (1, 'E2E Grocery List', true)`,
    )) as any[];
    const listId = (listResult as any).insertId;
    await conn.execute(`INSERT INTO list_shares (list_id, user_id) VALUES (?, 1)`, [listId]);
    await conn.execute(
      `INSERT INTO list_items (list_id, text, checked, rank) VALUES (?, 'Milk', false, 'a0'), (?, 'Eggs', false, 'a1')`,
      [listId, listId],
    );
  } finally {
    await conn.end();
  }
}

apps/pwa/e2e/layout.spec.ts (test, new)

Analog: apps/pwa/src/components/CalendarShell.test.tsx — the closest existing file using screen.findByRole, getByRole, and waitFor patterns with role/name locator assertions.

Test file structure (CalendarShell.test.tsx, lines 1418, 140158):

import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'
// ...
describe('CalendarShell — CAL-03 render smoke', () => {
  beforeEach(() => {
    vi.clearAllMocks()
    sessionStorage.clear()
  })
  it('renders without throwing...', () => { ... })
  it('mounts the ScheduleXCalendar...', async () => { ... })
})

Role-based locator pattern (CalendarShell.test.tsx, lines 220230):

const tapToRetry = await screen.findByText(/Tap here to try again/i);
expect(tapToRetry).toBeDefined();

Playwright equivalents (from RESEARCH.md §Patterns 35) — @playwright/test uses page.getByRole(), not screen:

import { test, expect } from '@playwright/test';

test.describe('BottomTabBar presence and tap targets', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/calendar');
  });

  test('Calendar tab meets 44px touch target', async ({ page }) => {
    const tab = page.getByRole('link', { name: 'Calendar' });
    const box = await tab.boundingBox();
    expect(box).not.toBeNull();
    expect(box!.width).toBeGreaterThanOrEqual(44);
    expect(box!.height).toBeGreaterThanOrEqual(44);
  });

  test('no horizontal overflow on /calendar', async ({ page }) => {
    const overflow = await page.evaluate(() => ({
      scrollWidth: document.documentElement.scrollWidth,
      clientWidth: document.documentElement.clientWidth,
    }));
    expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth);
  });
});

Error state via page.route() (from RESEARCH.md §Pattern 5):

// Register BEFORE page.goto() — route intercepts the matching request
await page.route('/api/events*', (route) =>
  route.fulfill({ status: 500, body: JSON.stringify({ error: 'simulated' }) }),
);
await page.goto('/calendar');
await expect(page.getByRole('heading', { name: "Couldn't load events" })).toBeVisible();

apps/pwa/e2e/calendar.spec.ts (test, new)

Analog: apps/pwa/src/components/CalendarShell.test.tsx — same component under test; provides fixture data shapes and the expected ARIA landmark (data-testid="schedule-x-calendar", navigation role).

Fixture data shape confirmed (CalendarShell.test.tsx, lines 83113):

// Timed event shape returned by /api/events
const TIMED_OCCURRENCE = {
  id: 'timed-uid::2026-06-15T10:00:00',
  title: 'Team Standup',
  start: '2026-06-15T10:00:00-04:00[America/New_York]',
  end: '2026-06-15T10:30:00-04:00[America/New_York]',
  allDay: false,
};

Key insight: The Playwright spec navigates to /calendar and asserts structural elements (Schedule-X wrapper present and visible, event chip text visible for seeded event) via role/text locators — not by data-testid (prefer stable ARIA roles). The seeded event title is 'Seeded Test Event'.

Query client wrapper convention (CalendarShell.test.tsx, lines 117136) — not directly applicable in Playwright (no React wrapper needed), but confirms the route path is /calendar.


apps/pwa/e2e/lists.spec.ts (test, new)

Analog: apps/pwa/src/routes/ListDetail.test.tsx — the closest file testing the lists data shape; confirms list item text ('bread', 'Milk', 'Eggs'), the two-section layout (active / completed), and the rank fractional-indexing strings.

List item shape (ListDetail.test.tsx, lines 2131):

function makeItem(overrides: Partial<ListItem> = {}): ListItem {
  return {
    id: 1,
    listId: 10,
    text: 'bread',
    checked: false,
    rank: 'a0',
  };
}

Section assertion pattern (ListDetail.test.tsx, lines 178193):

const activeItems = items.filter((i) => !i.checked);
const completedItems = items.filter((i) => i.checked);
expect(activeItems).toHaveLength(1);
expect(completedItems).toHaveLength(1);

In Playwright: assert page.getByRole('listitem', { name: 'Milk' }) is visible (seeded active item) and that the "No items yet" empty text is NOT visible when seeded.


apps/pwa/vitest.config.ts (modify)

Analog: Self — read at lines 114 above. Change is additive: add exclude array to prevent Vitest from picking up e2e/**/*.spec.ts.

Current file (apps/pwa/vitest.config.ts, lines 114):

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./src/test-setup.ts'],
    env: { TZ: 'UTC' },
    // ADD: exclude to prevent Vitest glob collision with Playwright specs
    // exclude: ['e2e/**', 'node_modules/**'],
  },
});

Diff to apply: add one line inside the test: block:

exclude: ['e2e/**', 'node_modules/**'],

apps/pwa/package.json (modify)

Analog: apps/pwa/package.json (self, lines 611) + root package.json (lines 412) for naming conventions.

Current scripts block (apps/pwa/package.json, lines 611):

"scripts": {
  "dev": "vite",
  "build": "tsc && vite build",
  "preview": "vite preview",
  "typecheck": "tsc --noEmit",
  "test": "vitest run"
}

Root workspace convention (package.json, lines 412): scripts use pnpm --filter @familysync/<app> <script> and follow verb or verb:modifier naming (dev:api, dev:pwa, typecheck).

Additions to apps/pwa/package.json:

"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed"

Addition to devDependencies:

"@playwright/test": "1.60.0"

Root package.json addition (propagate to workspace-level scripts):

"test:e2e": "pnpm --filter @familysync/pwa test:e2e"

Shared Patterns

Dev bypass — resolves to user id 1

Source: apps/api/src/auth/devBypass.ts lines 3036, 6176 Apply to: global-setup.ts (seed targets user_id=1, owner_id=1); all spec files (asserted data belongs to user 1)

// DEV_USER.id === 1 — seed and assert against this identity
export const DEV_USER = { id: 1, displayName: 'Dev User', color: '#4A90D9' } as const;
// Guard: requires NODE_ENV !== 'production' AND DEV_AUTH_BYPASS=true

mysql2 env-var connection pattern

Source: apps/api/src/db/client.ts lines 614 Apply to: global-setup.ts

// Exact env-var names used across the project — use same names in global-setup
host: process.env.DB_HOST ?? '127.0.0.1',   // NOT 'localhost' (per memory api-integration-test-db)
port: Number(process.env.DB_PORT ?? 3306),
user: process.env.DB_USER ?? 'familysync',
password: process.env.DB_PASSWORD ?? '',
database: process.env.DB_NAME ?? 'familysync',

Test file header comment convention

Source: apps/pwa/src/components/CalendarShell.test.tsx lines 114; apps/api/tests/routes/lists.test.ts lines 117 Apply to: all e2e/*.spec.ts files and global-setup.ts

/**
 * <Component/route> — <requirement ID>
 *
 * <what it tests>
 *
 * Run:
 *   pnpm --filter @familysync/pwa test:e2e
 */

Role/name locator convention (Vitest + Testing Library → Playwright equivalent)

Source: apps/pwa/src/components/CalendarShell.test.tsx lines 217231

// Testing Library (Vitest) — existing pattern
const tapToRetry = await screen.findByText(/Tap here to try again/i);
// ↓ Playwright equivalent in e2e specs:
await expect(page.getByRole('button', { name: /Retry/i })).toBeVisible();
await expect(page.getByText(/Tap here to try again/i)).toBeVisible();

No hardcoded absolute URLs in specs

Source: RESEARCH.md §Anti-Patterns; consistent with apps/pwa/vite.config.ts proxy pattern Apply to: all e2e/*.spec.ts files

// Wrong — breaks CI
await page.goto('http://localhost:5173/calendar');
// Correct — resolves against playwright.config.ts baseURL
await page.goto('/calendar');

No Analog Found

None. All files have at least a role-match analog in the codebase.


Metadata

Analog search scope: apps/pwa/src/, apps/api/src/, apps/api/tests/ Files read: 12 Pattern extraction date: 2026-06-10