<!--
Playwright Tests Without Pipeline Fire
Part of the Bles Software agent skills repository:
https://www.bles-software.com/claude-skills

Install for Claude Code:
  mkdir -p ~/.claude/skills/playwright-tests-without-pipeline-fire
  mv playwright-tests-without-pipeline-fire.md ~/.claude/skills/playwright-tests-without-pipeline-fire/SKILL.md

Install for Codex:
  mkdir -p ~/.codex/skills/playwright-tests-without-pipeline-fire
  mv playwright-tests-without-pipeline-fire.md ~/.codex/skills/playwright-tests-without-pipeline-fire/SKILL.md

For Gemini CLI and other agents, drop the body of this file into the
project instruction file the agent already reads.
-->

---
name: playwright-tests-without-pipeline-fire
description: Write Playwright integration tests for API endpoints whose happy path triggers an expensive (multi-minute, multi-dollar) pipeline — without actually firing the pipeline. Tests cover validation guards, DB schema (history/state rows), wiring (orchestrator branches, settings UI options), and asset serving. Includes the sqlite3-CLI-vs-better-sqlite3 ABI workaround for test-runner/PM2 Node version mismatches.
---

# Playwright integration tests without firing the pipeline

## When to use this skill

You added or rewired a feature whose endpoint hands off to a long pipeline:
- Reels generation (~17 min, ~$2-3 per run)
- Image generation queues
- Video render farms
- LLM batch jobs
- LATE-API publishes that go to real social accounts

The user (or a stop-hook, or your own discipline) wants tests, but the cost of a real pipeline run is prohibitive — and worse, repeated test runs would hit rate limits or post junk to live accounts.

The answer is **integration tests that exercise the code paths around the pipeline, not the pipeline itself.** You can verify almost everything that matters without pulling the trigger:
- Auth guards
- Input validation (missing field, wrong type, unknown id)
- DB schema for state rows the endpoint creates (preview drafts, history entries)
- Asset serving for outputs of past runs
- Wiring (orchestrator branches present, settings UI exposes the new type, service module exports the right functions)
- Existing artifacts from a prior real run, used as a fixture

## What to actually test (and what NOT to)

| Test category | Verifies | Pipeline fires? |
|---|---|---|
| Auth guards | Endpoint requires login | No |
| Schema validation | 400 on missing/bad inputs | No |
| Type guard | 400 when wrong-type variant supplied | No (returns before pipeline starts) |
| Unknown id | 404 when target doesn't exist | No |
| Backfilled artifact in history | DB row has expected schema (`variant_type`, `video_url`, `user_id`) | No (uses a real prior run as fixture) |
| Asset serving | `HEAD /public/media/<id>/file.mp4` returns 200 | No |
| API surfaces past artifact | `GET /api/history` returns the fixture row, scoped to right user | No |
| Source-code wiring | Orchestrator file contains `isReels` branch, filter includes new type | No (just reads source as text) |
| Service module exports | `require('../src/foo').barFn` is a function | No |
| Settings UI option | `<option value="newType">` rendered in the settings page | No |

What you skip: the actual end-to-end pipeline. Run it once manually (the user's "real reel"), let it land in DB + disk as a fixture, then point your tests at that fixture forever.

## The sqlite3-CLI workaround for ABI mismatches

The project's `better-sqlite3` is compiled against the Node version PM2 uses (often v20). Playwright runs the test file with whatever `node` is on the test runner's PATH (often v22). Loading `better-sqlite3` from inside a test then crashes:

```
Error: The module '...better_sqlite3.node' was compiled against a different Node.js version
using NODE_MODULE_VERSION 115. This version of Node.js requires NODE_MODULE_VERSION 127.
```

**Don't** rebuild for every Node version, **don't** force the test runner to use the project's Node — you'll hit it again the next time anyone upgrades. Just shell out to the `sqlite3` CLI binary, which has no Node coupling at all:

```javascript
const { execFileSync } = require('child_process');
const path = require('path');
const DB_PATH = path.join(__dirname, '..', 'autoposter.sqlite');

function sqliteJSON(sql) {
  const out = execFileSync('sqlite3', ['-readonly', '-json', DB_PATH, sql], {
    encoding: 'utf-8',
  });
  return out.trim() ? JSON.parse(out) : [];
}

// Usage
const rows = sqliteJSON(`SELECT id, user_id, variant_type FROM generation_history WHERE user_id = '33' LIMIT 10`);
expect(rows[0].variant_type).toBe('reels');
```

Three tiny things to know:
- `-readonly` is a safety belt; tests should never write to the production DB
- `-json` returns a clean array of objects (no manual cat-pipe-jq parsing)
- The CLI takes the SQL as the last positional arg — be careful with quoting if interpolating variables (use simple types only, never interpolate untrusted strings)

## Settings stored as a JSON blob

Many apps stuff per-user settings into a single TEXT column. The schema looks like:

```sql
CREATE TABLE user_settings (
  user_id TEXT PRIMARY KEY,
  settings TEXT NOT NULL DEFAULT '{}'
);
```

So you can't `SELECT image_instruction_variants FROM user_settings`. You select the blob and parse:

```javascript
const rows = sqliteJSON(`SELECT settings FROM user_settings WHERE user_id = '${USER_ID}'`);
const settings = JSON.parse(rows[0].settings || '{}');
const variants = settings.imageInstructionVariants || [];
```

Always check the actual schema with `.schema <table>` before writing the query, especially if a project has migrated from JSON-file storage to SQLite — the column names rarely match the JSON keys.

## Auth: mint a JWT instead of fighting login forms

Most apps gate the API behind a session cookie. For tests, don't drive the login form (slow, brittle, breaks if password rotates). Mint the JWT directly from the same secret the server uses:

```javascript
const fs = require('fs');
const path = require('path');
const jwt = require('jsonwebtoken');

function readEnvSecret(name, fallback) {
  try {
    const env = fs.readFileSync(path.join(__dirname, '..', '.env'), 'utf-8');
    const m = env.match(new RegExp(`^${name}=(.+)$`, 'm'));
    return m ? m[1].trim().replace(/^['"]|['"]$/g, '') : fallback;
  } catch { return fallback; }
}

const SECRET = process.env.JWT_SECRET || readEnvSecret('JWT_SECRET', 'dev-secret-change-me');
const COOKIE_NAME = process.env.AUTH_COOKIE || 'autoposter_token';
const TOKEN = jwt.sign({ sub: String(USER_ID), email: 'test@example.com' }, SECRET, { expiresIn: '1h' });
const AUTH_COOKIE = `${COOKIE_NAME}=${TOKEN}`;

// Then in tests:
const r = await request.post(`${BASE_URL}/api/whatever`, {
  headers: { Cookie: AUTH_COOKIE, 'Content-Type': 'application/json' },
  data: { ... },
});
```

For Playwright `page` (not `request`) tests, set the cookie on the context:

```javascript
await context.addCookies([{
  name: COOKIE_NAME,
  value: TOKEN,
  domain: new URL(BASE_URL).hostname,
  path: '/',
  httpOnly: true,
  secure: false,
  sameSite: 'Lax',
}]);
await page.goto(`${BASE_URL}/settings`);
```

This works for any project where you can read the JWT secret from env. It's how the production server validates real users — you're using the same primitive, just bypassing the form.

## Source-code wiring tests (very cheap, very valuable)

The biggest silent failure mode for a new variant type is "the orchestrator filter doesn't include it, so the variant is silently skipped at scheduling time." You don't need to fire the pipeline to verify that — just `grep` the source:

```javascript
const fs = require('fs');
const path = require('path');

test('scheduler.js active-creatives filter includes the new type', () => {
  const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'scheduler.js'), 'utf-8');
  expect(src).toMatch(/type\s*===?\s*['"]reels['"]/);
});

test('scheduler.js has the orchestrator branch for the new type', () => {
  const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'scheduler.js'), 'utf-8');
  expect(src).toMatch(/isReels/);
});

test('service module exposes the lifecycle functions the orchestrator needs', () => {
  const svc = require(path.join('..', 'src', 'reelsService.js'));
  expect(typeof svc.startReelsJob).toBe('function');
  expect(typeof svc.getReelsJobStatus).toBe('function');
});
```

These run in milliseconds, never fire the pipeline, and catch the most embarrassing class of regression: somebody refactors the filter and your variant goes dark.

## Use a past real run as a fixture

The user's prior real reel/image/whatever is the gift that keeps giving. Rather than mocking, point tests at the artifact:

```javascript
const BACKFILLED_REEL_ID = 'reel-674d8aabe268';  // a known-good prior run
const BACKFILLED_USER_ID = 33;

test('Backfilled reel exists in generation_history with expected schema', () => {
  const rows = sqliteJSON(
    `SELECT id, user_id, variant_type, video_url FROM generation_history
       WHERE preview_id = '${BACKFILLED_REEL_ID}' OR video_url LIKE '%${BACKFILLED_REEL_ID}%'
       LIMIT 1`
  );
  const row = rows[0];
  expect(row, 'backfilled reel must exist').toBeTruthy();
  expect(row.variant_type).toBe('reels');
  expect(row.video_url).toMatch(/\.mp4$/);
});

test('Backfilled MP4 is publicly served', async ({ request }) => {
  const r = await request.head(`${BASE_URL}/public/media/${BACKFILLED_REEL_ID}/reel.mp4`);
  expect(r.status()).toBe(200);
});

test('Authed history endpoint returns the backfilled reel scoped to the right user', async ({ request }) => {
  const r = await request.get(`${BASE_URL}/api/generation-history?limit=100`, {
    headers: { Cookie: AUTH_COOKIE },
  });
  expect(r.status()).toBe(200);
  const arr = (await r.json()).items || [];
  const reel = arr.find((it) => (it.video_url || '').includes(BACKFILLED_REEL_ID));
  expect(reel).toBeTruthy();
  expect(reel.variant_type || reel.variantType).toBe('reels');
});
```

This tests the same surface a real run would write to — without the cost. The only assumption is "the user has done at least one real run, ever." That's almost always true for any feature in production.

## Skipping when the fixture doesn't exist

If you want the suite to pass on a fresh dev machine that hasn't done a real run, gate the fixture-dependent tests:

```javascript
test('POST /api/reels/preview with non-reels variant returns 400', async ({ request }) => {
  const rows = sqliteJSON(`SELECT settings FROM user_settings WHERE user_id = '${USER_ID}'`);
  if (!rows[0]) test.skip(true, 'user has no settings row — skip on fresh DB');
  const settings = JSON.parse(rows[0].settings || '{}');
  const variants = settings.imageInstructionVariants || [];
  const nonReels = variants.find((v) => v.type && v.type !== 'reels');
  if (!nonReels) test.skip(true, 'no non-reels variants to test against');
  // ... actual test
});
```

`test.skip(condition, 'reason')` shows up clearly in the report — much better than a silent pass or a confusing failure on a fresh checkout.

## When you DO need to test the pipeline

Run it manually, once, with the user. Save the resulting artifacts as fixtures (reel ID, image ID, history row id). Then never run it from a test again. The fixture pays back every time you run the suite.

If you need automated pipeline tests for CI, build them as a *separate* suite (`npm run test:pipeline`) gated behind an env flag (`PIPELINE_TESTS=1`). They run on a schedule (nightly, weekly), not on every commit.

## Quick checklist

- [ ] Use `sqlite3` CLI via `execFileSync`, not `better-sqlite3`, to avoid Node ABI mismatches
- [ ] Mint JWTs from the env secret instead of driving the login form
- [ ] Read `.env` for `JWT_SECRET` + `AUTH_COOKIE` defensively (fall back to dev defaults)
- [ ] Pick a real prior run as the fixture, hard-code its ID, test the schema around it
- [ ] Add source-code wiring tests for orchestrator filters / branches / module exports
- [ ] Use `test.skip(condition, 'reason')` when the fixture is missing on a fresh DB
- [ ] Cover: auth guards, input validation, type guards, schema, asset serving, wiring
- [ ] Don't cover: the actual pipeline. That's the user's manual smoke test.

## Reference implementation

`/opt/autoposter/tests/reels-integration.test.js` — 14 tests covering the rewired `/api/reels/preview` endpoint:
- 7 endpoint validation guards (no pipeline fire)
- 3 backfilled-reel Gen History integration tests (uses real prior reel as fixture)
- 3 scheduler/service wiring tests (greps source code)
- 1 settings UI test (verifies `<option value="reels">` rendered)
All run in ~3 seconds, no media generation, no API charges.
