Skip to content

Run the worker in e2e and verify a submitted job is processed#471

Merged
jrhizor merged 6 commits into
mainfrom
jrhizor/e2e-worker-job-processing
Jul 19, 2026
Merged

Run the worker in e2e and verify a submitted job is processed#471
jrhizor merged 6 commits into
mainfrom
jrhizor/e2e-worker-job-processing

Conversation

@jrhizor

@jrhizor jrhizor commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

What

The e2e suite previously started only web and deliberately skipped the worker. Now that we have a no-network stub provider, this runs the worker in e2e and adds a spec that proves a submitted job is actually processed end-to-end.

How it works

The run is split into two phases so the worker's self-healing scheduler can't mutate the seeded fixtures the other tests assert on. The phase boundary lives in the Playwright config as two projects (fixtures / worker), so every entrypoint inherits it — not just the CI YAML.

Phase 1 — worker down. Seed, then run every fixture-dependent suite: the fixtures Playwright project and the Bruno API suite. With no worker running, nothing can re-enqueue or reprocess the seeded prompts. pnpm test:e2e runs only this project, so a bare local run never depends on worker topology (and can't feed a paid job to a real worker).

Phase 2 — worker up. Start the worker and run --project=worker. Its one spec POSTs to /api/v1/prompts (which enqueues an immediate process-prompt job) and expect.polls Postgres until a prompt_runs row with the stub's version appears for the new prompt. There is no separate readiness wait: the job is durable in pg-boss, so the 120s poll budget absorbs worker startup and covers one 60s retry cycle of the queue. Asserting the DB side-effect — not just job state — means it only passes if the handler actually did its work.

The worker project writes to its own outputDir and drops the html reporter (keeping github for PR annotations), so the second Playwright invocation can't wipe phase 1's traces, report, or the Bruno JUnit/JSON reports before the artifact uploads.

The worker runs exactly as it does in production; only its SCRAPE_TARGETS is overridden to stub:stub via e2e/worker-override.yaml. The override is worker-only because the web app derives its model filter/settings UI from SCRAPE_TARGETS, so web keeps the real value from .env. The compose file set is pinned once via a job-level COMPOSE_FILE so every step runs against the same effective config.

Shared fixture constants (DB URL, brand/prompt IDs, the test API key) live in a side-effect-free e2e/fixtures.ts; specs and auth-setup import from it, and seed.ts stays a pure script.

Why phased, not a flag

An earlier revision added a WORKER_DISABLE_SCHEDULES env branch to the production worker so its schedule-maintenance cron wouldn't reprocess seeded prompts (that maintenance pass counts any prompt with no run for the configured model as immediately overdue, so it would re-enqueue the run-less fixtures the moment it fired). Gating production code purely for tests is an anti-pattern — this instead orders the run so the fixture suites finish before the worker exists. No production code changes.

Verification

  • tsc --noEmit green on e2e/; workflow YAML parses.
  • playwright test --list confirms the split: 21 tests in 5 files under fixtures, exactly the 1 worker spec under worker.
  • Confirmed the fixture suites (loose Playwright content checks; Bruno's total gte 5, totalPages gt 1, prompts[0].brandId) are unaffected, and they run before the worker starts regardless.
  • Full suite runs in CI (or locally via pnpm test:e2e-local / act) — not run locally since it needs the Docker + elmo init --dev flow.

No changeset: internal test/CI plumbing, not user-facing.

Start the worker alongside web in the e2e Docker Compose run, pointed at the no-network stub provider so it makes no paid API calls. A new Playwright spec submits a prompt via POST /api/v1/prompts and polls Postgres until the worker records the prompt_run.

Gate the worker's recurring cron schedules behind WORKER_DISABLE_SCHEDULES (default off, so production is unchanged). schedule-maintenance treats any prompt with no run for the configured model as immediately overdue, so without this the worker would re-enqueue the seeded fixtures and mutate data the other specs assert on.
@vercel

vercel Bot commented Jul 18, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
elmo Ready Ready Preview, Comment Jul 18, 2026 8:19pm

Request Review

@blacksmith-sh

This comment has been minimized.

Replaces WORKER_DISABLE_SCHEDULES — a test-only flag in production worker code — with test orchestration. The fixture-dependent Playwright and Bruno suites run first with the worker down, then the worker starts and a tagged @worker spec runs on its own. Starting the worker only after those suites finish keeps its self-healing scheduler from re-enqueuing the seeded prompts and mutating data they assert on, with no production code path.

The worker now runs exactly as in prod; only its SCRAPE_TARGETS is overridden to the no-network stub provider to avoid paid API calls.
seed.ts ran seed() — a destructive DELETE + re-insert — as a top-level side effect, so importing it for its exported fixture constants re-ran the whole seed. Playwright's globalSetup (auth-setup) and the worker spec both import it, so loading them wiped rows out from under running tests: the worker spec's submitted prompt and its recorded run were deleted mid-poll, and a concurrent wipe made POST /api/v1/prompts return 400 'Brand default not found' on retry.

seed() now runs only when the file is the process entry (tsx seed.ts). The worker spec no longer imports seed — it hardcodes the brand id like the other specs.
jrhizor added 3 commits July 18, 2026 13:16
seed.ts goes back to being a pure script with an unconditional top-level
seed() call: with the constants in a side-effect-free module there is
nothing left to import from the seeder, so the realpath entry-point guard
(which silently skipped seeding on a loader/argv quirk) is unnecessary.
The phase boundary moves out of CI-only grep flags into the Playwright
config, so `pnpm test:e2e` runs only the fixture specs again — a bare
local run no longer times out against a stopped worker or feeds a paid
job to a real one. CI selects --project=worker explicitly for phase 2.

The worker project writes to its own outputDir (a second playwright run
wipes the output dir it uses, which was deleting phase 1 traces and the
Bruno reports from test-results/ before upload) and phase 2 keeps the
github reporter for PR annotations while still skipping html so it
doesn't overwrite phase 1's report.

The readiness log-grep loop is gone: the submitted job is durable in
pg-boss, so the spec's poll — now expect.poll with a budget covering
worker startup plus one 60s retry cycle — gives the same guarantee, and
worker logs dump in a failure() step instead of an inline || wrapper.
Every docker compose step was repeating the elmo.yaml + worker-override
pair; a future step that forgot the second -f would silently bring the
worker up with real SCRAPE_TARGETS.
@jrhizor
jrhizor merged commit f63e343 into main Jul 19, 2026
8 of 10 checks passed
@jrhizor
jrhizor deleted the jrhizor/e2e-worker-job-processing branch July 19, 2026 20:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant