From 49c243b25789f4152586f759ef73d89402da6468 Mon Sep 17 00:00:00 2001 From: Thomas Hart Date: Fri, 17 Jul 2026 23:23:40 +0000 Subject: [PATCH 1/3] feat(worker): add BullMQ job queue for sequence steps and workflow delays --- .gitignore | 3 + README.md | 43 +++++ docs/design-prompts/00-default.md | 2 + package.json | 1 + packages/worker/eslint.config.mjs | 36 ++++ packages/worker/package.json | 31 ++++ packages/worker/src/dispatcher.test.ts | 60 +++++++ packages/worker/src/dispatcher.ts | 35 ++++ packages/worker/src/index.ts | 23 +++ packages/worker/src/payloads.test.ts | 64 ++++++++ packages/worker/src/payloads.ts | 37 +++++ packages/worker/src/queue.test.ts | 115 +++++++++++++ packages/worker/src/queue.ts | 77 +++++++++ packages/worker/tsconfig.json | 9 + packages/worker/vitest.config.ts | 8 + pnpm-lock.yaml | 218 +++++++++++++++++++++++++ 16 files changed, 762 insertions(+) create mode 100644 packages/worker/eslint.config.mjs create mode 100644 packages/worker/package.json create mode 100644 packages/worker/src/dispatcher.test.ts create mode 100644 packages/worker/src/dispatcher.ts create mode 100644 packages/worker/src/index.ts create mode 100644 packages/worker/src/payloads.test.ts create mode 100644 packages/worker/src/payloads.ts create mode 100644 packages/worker/src/queue.test.ts create mode 100644 packages/worker/src/queue.ts create mode 100644 packages/worker/tsconfig.json create mode 100644 packages/worker/vitest.config.ts diff --git a/.gitignore b/.gitignore index c69add6..1927e75 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ CLAUDE.md .claude/ .vercel + +# node compile cache +node-compile-cache diff --git a/README.md b/README.md index 20a28c8..3634794 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,17 @@ Modern CRM with sequence automation, visual workflow builder, and a design system grounded in cognitive science research. +## What this demonstrates + +The interesting part of a CRM is the automation engine, and getting it right means treating time and delivery as first-class problems. Relay runs sequences and workflows on a real BullMQ job queue backed by Redis, so a delay actually delays and an email actually fires when it should instead of blocking a request. The worker package leans on a few ideas worth calling out: at-least-once delivery with idempotent consumers (deterministic job IDs so a retried trigger can't double-schedule), delayed jobs for step timing, bounded retries with exponential backoff for transient failures like a flaky SMTP host, and backpressure through a fixed worker concurrency. Everything crossing a boundary is validated with Zod, and job routing goes through a small handler registry rather than a switch chain. + +## What's implemented + +- **packages/db** — Prisma schema for contacts, companies, deals, sequences, and workflows, with a seeded local database. +- **packages/shared** — Zod schemas, workflow graph validation (cycle detection, topological sort, reachability), and sequence analytics. +- **packages/web** — Next.js app: CRM views, sequence editor, and a React Flow workflow builder. +- **packages/worker** — BullMQ queue on the declared Redis with two job types, `sequence-step` and `workflow-delay`, deterministic job IDs for idempotent scheduling, clamped delays, and a Zod-validated dispatcher. + ## Tech Stack - **Framework:** Next.js 15+ (App Router, React 19) @@ -20,6 +31,7 @@ relay/ ├── packages/ │ ├── web/ # Next.js frontend │ ├── shared/ # Zod schemas, types, constants +│ ├── worker/ # BullMQ queue + background job processing │ └── db/ # Prisma schema, client, migrations ├── docker-compose.yml ├── turbo.json @@ -69,6 +81,37 @@ pnpm test:integration pnpm test:e2e ``` +## Background Jobs + +The worker package schedules and runs sequence steps and workflow delays on Redis. Register a handler per job type, then start a worker. + +```ts +import { + createConnection, + createQueue, + createWorker, + enqueueSequenceStep, + JobDispatcher, + JOB_NAMES, +} from "@relay/worker"; + +const connection = createConnection(); // reads REDIS_URL +const queue = createQueue(connection); + +// Schedule the next step for an enrollment, 30 minutes out. +await enqueueSequenceStep(queue, { enrollmentId, sequenceId, stepId }, 30 * 60 * 1000); + +const dispatcher = new JobDispatcher() + .on(JOB_NAMES.sequenceStep, async (payload) => { + /* send the email, advance the enrollment */ + }) + .on(JOB_NAMES.workflowDelay, async (payload) => { + /* resume the workflow execution from the delay node */ + }); + +createWorker(dispatcher, { connection }); +``` + ## Environment Variables Copy `.env.example` to `.env` and fill in the values. See the file for required variables. diff --git a/docs/design-prompts/00-default.md b/docs/design-prompts/00-default.md index a3725c6..33b4a05 100644 --- a/docs/design-prompts/00-default.md +++ b/docs/design-prompts/00-default.md @@ -9,6 +9,7 @@ Paste the relevant section below into [claude.ai/design](https://claude.ai/desig > I'm working on `[PROJECT NAME]` — `[ONE-LINE DESCRIPTION OF WHAT IT IS, e.g. "an Electron desktop app for managing multi-project development workflows" or "a marketplace for vintage vinyl"]`. Stack: `[NEXT 15 / NUXT 4 / VITE + REACT 19 / SHOPIFY HYDROGEN / etc.]` with `[CSS MODULES / TAILWIND / CSS-IN-JS / VANILLA CSS]` for styling. > > The brand color palette uses these CSS variables (from `app/globals.css` or equivalent — replace with actual values): +> > - `--color-accent: [HEX]` (primary brand) > - `--color-background: [HEX]` (page bg) > - `--color-foreground: [HEX]` (default text) @@ -42,6 +43,7 @@ Paste the relevant section below into [claude.ai/design](https://claude.ai/desig ## Section 5 — Handoff prep (always include at end) > When the design is ready, package it as a Claude Code handoff bundle. I'll save the bundle to `design-bundles/.json` in the project repo and pass it to Claude Code with: "implement design-bundles/.json". Include in the bundle: +> > - The component tree as nested elements with semantic HTML > - All CSS variables used (matching my project's existing names) > - Any new icons (preferring Lucide React icon names where they exist) diff --git a/package.json b/package.json index b4d72f0..acfee08 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ }, "devDependencies": { "prettier": "^3.4.2", + "prettier-plugin-tailwindcss": "^0.6.9", "turbo": "^2.3.3", "typescript": "^5.7.2" }, diff --git a/packages/worker/eslint.config.mjs b/packages/worker/eslint.config.mjs new file mode 100644 index 0000000..827882b --- /dev/null +++ b/packages/worker/eslint.config.mjs @@ -0,0 +1,36 @@ +import js from "@eslint/js"; +import tsPlugin from "@typescript-eslint/eslint-plugin"; +import tsParser from "@typescript-eslint/parser"; + +export default [ + js.configs.recommended, + { + files: ["src/**/*.ts"], + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaVersion: 2022, + sourceType: "module", + }, + globals: { + process: "readonly", + globalThis: "readonly", + console: "readonly", + }, + }, + plugins: { + "@typescript-eslint": tsPlugin, + }, + rules: { + ...tsPlugin.configs.recommended.rules, + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, + ], + "no-unused-vars": "off", + }, + }, + { + ignores: ["dist/**", "node_modules/**"], + }, +]; diff --git a/packages/worker/package.json b/packages/worker/package.json new file mode 100644 index 0000000..6b4fe43 --- /dev/null +++ b/packages/worker/package.json @@ -0,0 +1,31 @@ +{ + "name": "@relay/worker", + "version": "0.0.1", + "private": true, + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "lint": "eslint src/", + "lint:fix": "eslint src/ --fix", + "test": "vitest run", + "test:watch": "vitest", + "clean": "rm -rf dist" + }, + "dependencies": { + "@relay/shared": "workspace:*", + "bullmq": "^5.34.10", + "ioredis": "^5.4.2", + "zod": "^3.24.1" + }, + "devDependencies": { + "@eslint/js": "^9.39.2", + "@types/node": "^22.10.5", + "@typescript-eslint/eslint-plugin": "^8.56.0", + "@typescript-eslint/parser": "^8.56.0", + "eslint": "^9.18.0", + "typescript": "^5.7.2", + "vitest": "^2.1.8" + } +} diff --git a/packages/worker/src/dispatcher.test.ts b/packages/worker/src/dispatcher.test.ts new file mode 100644 index 0000000..c043c75 --- /dev/null +++ b/packages/worker/src/dispatcher.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "vitest"; +import { JobDispatcher, UnknownJobError } from "./dispatcher"; +import { JOB_NAMES } from "./payloads"; + +const seqData = { + enrollmentId: "clh1a2b3c4d5e6f7g8h9i0j1", + sequenceId: "clh2a2b3c4d5e6f7g8h9i0j2", + stepId: "clh3a2b3c4d5e6f7g8h9i0j3", +}; + +describe("JobDispatcher", () => { + it("validates then routes to the registered handler", async () => { + const handler = vi.fn(async () => {}); + const d = new JobDispatcher().on(JOB_NAMES.sequenceStep, handler); + await d.dispatch(JOB_NAMES.sequenceStep, seqData); + expect(handler).toHaveBeenCalledWith(seqData); + }); + + it("throws UnknownJobError for an unregistered known name", async () => { + const d = new JobDispatcher(); + await expect(d.dispatch(JOB_NAMES.workflowDelay, {})).rejects.toBeInstanceOf(UnknownJobError); + }); + + it("throws UnknownJobError for a name it never knew", async () => { + const d = new JobDispatcher().on(JOB_NAMES.sequenceStep, async () => {}); + await expect(d.dispatch("garbage", {})).rejects.toBeInstanceOf(UnknownJobError); + }); + + it("rejects an invalid payload without calling the handler", async () => { + const handler = vi.fn(async () => {}); + const d = new JobDispatcher().on(JOB_NAMES.sequenceStep, handler); + await expect(d.dispatch(JOB_NAMES.sequenceStep, { enrollmentId: "x" })).rejects.toThrow(); + expect(handler).not.toHaveBeenCalled(); + }); + + it("propagates a handler failure so the queue can retry", async () => { + const d = new JobDispatcher().on(JOB_NAMES.sequenceStep, async () => { + throw new Error("smtp down"); + }); + await expect(d.dispatch(JOB_NAMES.sequenceStep, seqData)).rejects.toThrow("smtp down"); + }); + + it("reports which names it can handle", () => { + const d = new JobDispatcher().on(JOB_NAMES.workflowDelay, async () => {}); + expect(d.handles(JOB_NAMES.workflowDelay)).toBe(true); + expect(d.handles(JOB_NAMES.sequenceStep)).toBe(false); + expect(d.handles("nope")).toBe(false); + }); + + it("keeps the last handler registered for a name", async () => { + const first = vi.fn(async () => {}); + const second = vi.fn(async () => {}); + const d = new JobDispatcher() + .on(JOB_NAMES.sequenceStep, first) + .on(JOB_NAMES.sequenceStep, second); + await d.dispatch(JOB_NAMES.sequenceStep, seqData); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/worker/src/dispatcher.ts b/packages/worker/src/dispatcher.ts new file mode 100644 index 0000000..d01bf2a --- /dev/null +++ b/packages/worker/src/dispatcher.ts @@ -0,0 +1,35 @@ +import { JOB_PAYLOAD_SCHEMAS, isJobName, type JobName, type JobPayloads } from "./payloads"; + +export class UnknownJobError extends Error { + constructor(public readonly jobName: string) { + super(`No handler registered for job "${jobName}"`); + this.name = "UnknownJobError"; + } +} + +export type JobHandler = (payload: JobPayloads[N]) => Promise; + +type AnyHandler = (payload: unknown) => Promise; + +export class JobDispatcher { + private readonly handlers = new Map(); + + on(name: N, handler: JobHandler): this { + this.handlers.set(name, handler as AnyHandler); + return this; + } + + handles(name: string): name is JobName { + return isJobName(name) && this.handlers.has(name); + } + + // Throws on unknown name or invalid payload so BullMQ marks the job failed and + // retries it under the queue's backoff policy rather than silently dropping it. + async dispatch(name: string, data: unknown): Promise { + if (!isJobName(name)) throw new UnknownJobError(name); + const handler = this.handlers.get(name); + if (!handler) throw new UnknownJobError(name); + const payload = JOB_PAYLOAD_SCHEMAS[name].parse(data); + await handler(payload); + } +} diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts new file mode 100644 index 0000000..8a370f2 --- /dev/null +++ b/packages/worker/src/index.ts @@ -0,0 +1,23 @@ +import { Worker, type Job } from "bullmq"; +import type { Redis } from "ioredis"; +import { JobDispatcher } from "./dispatcher"; +import { QUEUE_NAME } from "./queue"; + +export * from "./payloads"; +export * from "./queue"; +export * from "./dispatcher"; + +export interface WorkerOptions { + connection: Redis; + concurrency?: number; +} + +export function createWorker(dispatcher: JobDispatcher, opts: WorkerOptions): Worker { + return new Worker( + QUEUE_NAME, + async (job: Job) => { + await dispatcher.dispatch(job.name, job.data); + }, + { connection: opts.connection, concurrency: opts.concurrency ?? 5 }, + ); +} diff --git a/packages/worker/src/payloads.test.ts b/packages/worker/src/payloads.test.ts new file mode 100644 index 0000000..4d50f0b --- /dev/null +++ b/packages/worker/src/payloads.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { + isJobName, + JOB_NAMES, + JOB_PAYLOAD_SCHEMAS, + sequenceStepPayloadSchema, + workflowDelayPayloadSchema, +} from "./payloads"; + +const CUID_A = "clh1a2b3c4d5e6f7g8h9i0j1"; +const CUID_B = "clh2a2b3c4d5e6f7g8h9i0j2"; +const CUID_C = "clh3a2b3c4d5e6f7g8h9i0j3"; + +describe("payload schemas", () => { + it("accepts a well-formed sequence-step payload", () => { + const parsed = sequenceStepPayloadSchema.parse({ + enrollmentId: CUID_A, + sequenceId: CUID_B, + stepId: CUID_C, + }); + expect(parsed.stepId).toBe(CUID_C); + }); + + it("rejects non-cuid ids", () => { + expect(() => + sequenceStepPayloadSchema.parse({ enrollmentId: "1", sequenceId: CUID_B, stepId: CUID_C }), + ).toThrow(); + }); + + it("strips unknown keys on workflow-delay", () => { + const parsed = workflowDelayPayloadSchema.parse({ + executionId: CUID_A, + workflowId: CUID_B, + nodeId: CUID_C, + injected: "nope", + }); + expect(parsed).not.toHaveProperty("injected"); + }); + + it("rejects a payload missing a required field", () => { + expect(() => + workflowDelayPayloadSchema.parse({ executionId: CUID_A, workflowId: CUID_B }), + ).toThrow(); + }); +}); + +describe("isJobName", () => { + it("recognizes the two known job names", () => { + expect(isJobName(JOB_NAMES.sequenceStep)).toBe(true); + expect(isJobName(JOB_NAMES.workflowDelay)).toBe(true); + }); + + it("rejects anything else", () => { + expect(isJobName("send-slack")).toBe(false); + expect(isJobName("")).toBe(false); + }); +}); + +describe("JOB_PAYLOAD_SCHEMAS", () => { + it("maps each name to its schema", () => { + expect(JOB_PAYLOAD_SCHEMAS[JOB_NAMES.sequenceStep]).toBe(sequenceStepPayloadSchema); + expect(JOB_PAYLOAD_SCHEMAS[JOB_NAMES.workflowDelay]).toBe(workflowDelayPayloadSchema); + }); +}); diff --git a/packages/worker/src/payloads.ts b/packages/worker/src/payloads.ts new file mode 100644 index 0000000..43f0933 --- /dev/null +++ b/packages/worker/src/payloads.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; + +export const JOB_NAMES = { + sequenceStep: "sequence-step", + workflowDelay: "workflow-delay", +} as const; + +export type JobName = (typeof JOB_NAMES)[keyof typeof JOB_NAMES]; + +export const sequenceStepPayloadSchema = z.object({ + enrollmentId: z.string().cuid(), + sequenceId: z.string().cuid(), + stepId: z.string().cuid(), +}); + +export const workflowDelayPayloadSchema = z.object({ + executionId: z.string().cuid(), + workflowId: z.string().cuid(), + nodeId: z.string().cuid(), +}); + +export type SequenceStepPayload = z.infer; +export type WorkflowDelayPayload = z.infer; + +export interface JobPayloads { + [JOB_NAMES.sequenceStep]: SequenceStepPayload; + [JOB_NAMES.workflowDelay]: WorkflowDelayPayload; +} + +export const JOB_PAYLOAD_SCHEMAS = { + [JOB_NAMES.sequenceStep]: sequenceStepPayloadSchema, + [JOB_NAMES.workflowDelay]: workflowDelayPayloadSchema, +} satisfies { [N in JobName]: z.ZodType }; + +export function isJobName(name: string): name is JobName { + return name === JOB_NAMES.sequenceStep || name === JOB_NAMES.workflowDelay; +} diff --git a/packages/worker/src/queue.test.ts b/packages/worker/src/queue.test.ts new file mode 100644 index 0000000..f31f241 --- /dev/null +++ b/packages/worker/src/queue.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildJobOptions, + enqueueSequenceStep, + enqueueWorkflowDelay, + resolveDelayMs, + sequenceStepJobId, + workflowDelayJobId, + type Enqueuer, +} from "./queue"; +import { JOB_NAMES, type SequenceStepPayload, type WorkflowDelayPayload } from "./payloads"; + +const seqPayload: SequenceStepPayload = { + enrollmentId: "clh1a2b3c4d5e6f7g8h9i0j1", + sequenceId: "clh2a2b3c4d5e6f7g8h9i0j2", + stepId: "clh3a2b3c4d5e6f7g8h9i0j3", +}; + +const wfPayload: WorkflowDelayPayload = { + executionId: "clh4a2b3c4d5e6f7g8h9i0j4", + workflowId: "clh5a2b3c4d5e6f7g8h9i0j5", + nodeId: "clh6a2b3c4d5e6f7g8h9i0j6", +}; + +function fakeQueue(): Enqueuer & { calls: Array<{ name: string; data: unknown; opts?: unknown }> } { + const calls: Array<{ name: string; data: unknown; opts?: unknown }> = []; + return { + calls, + add: vi.fn(async (name, data, opts) => { + calls.push({ name, data, opts }); + return { id: (opts as { jobId?: string })?.jobId }; + }), + }; +} + +describe("resolveDelayMs", () => { + it("clamps null, undefined, and negatives to zero", () => { + expect(resolveDelayMs(null)).toBe(0); + expect(resolveDelayMs(undefined)).toBe(0); + expect(resolveDelayMs(-1)).toBe(0); + }); + + it("clamps NaN and Infinity to zero", () => { + expect(resolveDelayMs(NaN)).toBe(0); + expect(resolveDelayMs(Infinity)).toBe(0); + }); + + it("floors fractional milliseconds", () => { + expect(resolveDelayMs(1500.9)).toBe(1500); + }); + + it("caps absurdly large delays at one year", () => { + const oneYear = 365 * 24 * 60 * 60 * 1_000; + expect(resolveDelayMs(oneYear * 10)).toBe(oneYear); + }); +}); + +describe("job ids", () => { + it("are deterministic for the same payload", () => { + expect(sequenceStepJobId(seqPayload)).toBe(sequenceStepJobId(seqPayload)); + expect(workflowDelayJobId(wfPayload)).toBe(workflowDelayJobId(wfPayload)); + }); + + it("scope by the identity that must not double-run", () => { + expect(sequenceStepJobId(seqPayload)).toBe( + `seq:${seqPayload.enrollmentId}:${seqPayload.stepId}`, + ); + expect(workflowDelayJobId(wfPayload)).toBe(`wf:${wfPayload.executionId}:${wfPayload.nodeId}`); + }); + + it("differ when the step changes but the enrollment stays", () => { + const other = sequenceStepJobId({ ...seqPayload, stepId: "clh9a2b3c4d5e6f7g8h9i0j9" }); + expect(other).not.toBe(sequenceStepJobId(seqPayload)); + }); +}); + +describe("buildJobOptions", () => { + it("carries the retry policy and clamps the delay", () => { + const opts = buildJobOptions("job-1", -50); + expect(opts.jobId).toBe("job-1"); + expect(opts.delay).toBe(0); + expect(opts.attempts).toBe(3); + expect(opts.backoff).toEqual({ type: "exponential", delay: 5_000 }); + }); +}); + +describe("enqueueSequenceStep", () => { + it("adds a named job with a deterministic id and default zero delay", async () => { + const q = fakeQueue(); + await enqueueSequenceStep(q, seqPayload); + expect(q.calls).toHaveLength(1); + expect(q.calls[0]!.name).toBe(JOB_NAMES.sequenceStep); + const opts = q.calls[0]!.opts as { jobId: string; delay: number }; + expect(opts.jobId).toBe(sequenceStepJobId(seqPayload)); + expect(opts.delay).toBe(0); + }); + + it("reuses the same id across retried enqueues (idempotent scheduling)", async () => { + const q = fakeQueue(); + await enqueueSequenceStep(q, seqPayload, 60_000); + await enqueueSequenceStep(q, seqPayload, 60_000); + const ids = q.calls.map((c) => (c.opts as { jobId: string }).jobId); + expect(ids[0]).toBe(ids[1]); + }); +}); + +describe("enqueueWorkflowDelay", () => { + it("passes the resume delay through to the job options", async () => { + const q = fakeQueue(); + await enqueueWorkflowDelay(q, wfPayload, 3_600_000); + const opts = q.calls[0]!.opts as { jobId: string; delay: number }; + expect(opts.jobId).toBe(workflowDelayJobId(wfPayload)); + expect(opts.delay).toBe(3_600_000); + }); +}); diff --git a/packages/worker/src/queue.ts b/packages/worker/src/queue.ts new file mode 100644 index 0000000..6c3ced9 --- /dev/null +++ b/packages/worker/src/queue.ts @@ -0,0 +1,77 @@ +import { Queue, type JobsOptions } from "bullmq"; +import IORedis, { type Redis } from "ioredis"; +import { + JOB_NAMES, + type JobName, + type SequenceStepPayload, + type WorkflowDelayPayload, +} from "./payloads"; + +export const QUEUE_NAME = "relay-jobs"; + +export const DEFAULT_JOB_OPTIONS: JobsOptions = { + attempts: 3, + backoff: { type: "exponential", delay: 5_000 }, + removeOnComplete: 1_000, + removeOnFail: 5_000, +}; + +// Redis rejects a huge range; clamp anything unbounded to a sane one-year ceiling. +const MAX_DELAY_MS = 365 * 24 * 60 * 60 * 1_000; + +export function resolveDelayMs(ms: number | undefined | null): number { + if (ms == null || !Number.isFinite(ms) || ms <= 0) return 0; + return Math.min(Math.floor(ms), MAX_DELAY_MS); +} + +// Deterministic ids make enqueue idempotent: BullMQ drops a second add for a +// job that's already waiting, so a retried trigger can't double-schedule. +export function sequenceStepJobId(payload: SequenceStepPayload): string { + return `seq:${payload.enrollmentId}:${payload.stepId}`; +} + +export function workflowDelayJobId(payload: WorkflowDelayPayload): string { + return `wf:${payload.executionId}:${payload.nodeId}`; +} + +export function buildJobOptions(jobId: string, delayMs: number): JobsOptions { + return { ...DEFAULT_JOB_OPTIONS, jobId, delay: resolveDelayMs(delayMs) }; +} + +export interface Enqueuer { + add(name: JobName, data: unknown, opts?: JobsOptions): Promise; +} + +export function enqueueSequenceStep( + queue: Enqueuer, + payload: SequenceStepPayload, + delayMs = 0, +): Promise { + return queue.add( + JOB_NAMES.sequenceStep, + payload, + buildJobOptions(sequenceStepJobId(payload), delayMs), + ); +} + +export function enqueueWorkflowDelay( + queue: Enqueuer, + payload: WorkflowDelayPayload, + delayMs: number, +): Promise { + return queue.add( + JOB_NAMES.workflowDelay, + payload, + buildJobOptions(workflowDelayJobId(payload), delayMs), + ); +} + +// bullmq workers require maxRetriesPerRequest: null on the shared connection. +export function createConnection(url = process.env.REDIS_URL): Redis { + if (!url) throw new Error("REDIS_URL is not set"); + return new IORedis(url, { maxRetriesPerRequest: null }); +} + +export function createQueue(connection: Redis): Queue { + return new Queue(QUEUE_NAME, { connection, defaultJobOptions: DEFAULT_JOB_OPTIONS }); +} diff --git a/packages/worker/tsconfig.json b/packages/worker/tsconfig.json new file mode 100644 index 0000000..8b411f1 --- /dev/null +++ b/packages/worker/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/worker/vitest.config.ts b/packages/worker/vitest.config.ts new file mode 100644 index 0000000..3f824fb --- /dev/null +++ b/packages/worker/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91da1fb..e1c6e60 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,6 +15,9 @@ importers: prettier: specifier: ^3.4.2 version: 3.8.1 + prettier-plugin-tailwindcss: + specifier: ^0.6.9 + version: 0.6.14(prettier@3.8.1) turbo: specifier: ^2.3.3 version: 2.8.9 @@ -214,6 +217,43 @@ importers: specifier: ^2.1.8 version: 2.1.9(@types/node@22.19.11)(jsdom@25.0.1) + packages/worker: + dependencies: + '@relay/shared': + specifier: workspace:* + version: link:../shared + bullmq: + specifier: ^5.34.10 + version: 5.80.7 + ioredis: + specifier: ^5.4.2 + version: 5.11.1 + zod: + specifier: ^3.24.1 + version: 3.25.76 + devDependencies: + '@eslint/js': + specifier: ^9.39.2 + version: 9.39.2 + '@types/node': + specifier: ^22.10.5 + version: 22.19.11 + '@typescript-eslint/eslint-plugin': + specifier: ^8.56.0 + version: 8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^8.56.0 + version: 8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint: + specifier: ^9.18.0 + version: 9.39.2(jiti@2.6.1) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.19.11)(jsdom@25.0.1) + packages: '@adobe/css-tools@4.4.4': @@ -854,6 +894,9 @@ packages: cpu: [x64] os: [win32] + '@ioredis/commands@1.10.0': + resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -870,6 +913,36 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} + cpu: [x64] + os: [win32] + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -1952,6 +2025,15 @@ packages: buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + bullmq@5.80.7: + resolution: {integrity: sha512-hMBxHhwmpP8Yy94AhI9WI2H2mLaa7I9AMTmwD2HZW2Iij3lHOkMC9/MjUnwj5v6QbknYTV2sF7Z41Km2trbLMw==} + engines: {node: '>=12.22.0'} + peerDependencies: + redis: '>=5.0.0' + peerDependenciesMeta: + redis: + optional: true + c12@3.1.0: resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==} peerDependencies: @@ -2026,6 +2108,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cluster-key-slot@1.1.1: + resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} + engines: {node: '>=0.10.0'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -2054,6 +2140,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cron-parser@4.9.0: + resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} + engines: {node: '>=12.0.0'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2176,6 +2266,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -2632,6 +2726,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + ioredis@5.11.1: + resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} + engines: {node: '>=12.22.0'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -2880,6 +2978,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -2930,6 +3032,13 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.4: + resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} + hasBin: true + + msgpackr@2.0.4: + resolution: {integrity: sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA==} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -2967,6 +3076,9 @@ packages: sass: optional: true + node-abort-controller@3.1.1: + resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + node-exports-info@1.6.0: resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} engines: {node: '>= 0.4'} @@ -2974,6 +3086,10 @@ packages: node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} @@ -3326,6 +3442,14 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -3403,6 +3527,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -3456,6 +3585,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -4381,6 +4513,8 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true + '@ioredis/commands@1.10.0': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -4400,6 +4534,24 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + optional: true + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.8.1 @@ -5507,6 +5659,17 @@ snapshots: buffer-equal-constant-time@1.0.1: {} + bullmq@5.80.7: + dependencies: + cron-parser: 4.9.0 + ioredis: 5.11.1 + msgpackr: 2.0.4 + node-abort-controller: 3.1.1 + semver: 7.8.5 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + c12@3.1.0: dependencies: chokidar: 4.0.3 @@ -5594,6 +5757,8 @@ snapshots: clsx@2.1.1: {} + cluster-key-slot@1.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -5614,6 +5779,10 @@ snapshots: convert-source-map@2.0.0: {} + cron-parser@4.9.0: + dependencies: + luxon: 3.7.2 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -5724,6 +5893,8 @@ snapshots: delayed-stream@1.0.0: {} + denque@2.1.0: {} + dequal@2.0.3: {} destr@2.0.5: {} @@ -6398,6 +6569,18 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + ioredis@5.11.1: + dependencies: + '@ioredis/commands': 1.10.0 + cluster-key-slot: 1.1.1 + debug: 4.4.3 + denque: 2.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 @@ -6667,6 +6850,8 @@ snapshots: dependencies: react: 19.2.4 + luxon@3.7.2: {} + lz-string@1.5.0: {} magic-string@0.30.21: @@ -6708,6 +6893,22 @@ snapshots: ms@2.1.3: {} + msgpackr-extract@3.0.4: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 + optional: true + + msgpackr@2.0.4: + optionalDependencies: + msgpackr-extract: 3.0.4 + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -6744,6 +6945,8 @@ snapshots: - '@babel/core' - babel-plugin-macros + node-abort-controller@3.1.1: {} + node-exports-info@1.6.0: dependencies: array.prototype.flatmap: 1.3.3 @@ -6753,6 +6956,11 @@ snapshots: node-fetch-native@1.6.7: {} + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + node-releases@2.0.27: {} normalize-path@3.0.0: {} @@ -7021,6 +7229,12 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -7134,6 +7348,8 @@ snapshots: semver@7.7.4: {} + semver@7.8.5: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -7230,6 +7446,8 @@ snapshots: stackback@0.0.2: {} + standard-as-callback@2.1.0: {} + std-env@3.10.0: {} stop-iteration-iterator@1.1.0: From c644f1e11d3b43b03763468137a53d8ba047e439 Mon Sep 17 00:00:00 2001 From: Thomas Hart Date: Fri, 17 Jul 2026 23:25:36 +0000 Subject: [PATCH 2/3] fix(ci): let e2e workflow read pnpm version from packageManager --- .github/workflows/e2e.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 0022e1b..012d37f 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -11,8 +11,6 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 10 - uses: actions/setup-node@v4 with: From a577445e37ea416c449563398f8a283a7240af3d Mon Sep 17 00:00:00 2001 From: Thomas Hart Date: Fri, 17 Jul 2026 23:29:54 +0000 Subject: [PATCH 3/3] chore(ci): drop redundant standalone e2e workflow the ci.yml e2e job already runs playwright against the web package with the right setup. this root-level workflow duplicated it, ran npx playwright test with no database, and never passed. --- .github/workflows/e2e.yml | 39 --------------------------------------- 1 file changed, 39 deletions(-) delete mode 100644 .github/workflows/e2e.yml diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml deleted file mode 100644 index 012d37f..0000000 --- a/.github/workflows/e2e.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: E2E Tests - -on: - pull_request: - branches: [main, master] - -jobs: - e2e: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v4 - - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: "pnpm" - - - run: pnpm install - - - name: Install Playwright browsers - run: npx playwright install --with-deps chromium - - - name: Start dev server - run: pnpm dev & - - - name: Wait for server - run: npx wait-on http://localhost:3000 --timeout 30000 || npx wait-on http://localhost:5173 --timeout 30000 || true - - - name: Run E2E tests - run: npx playwright test - - - uses: actions/upload-artifact@v4 - if: failure() - with: - name: playwright-report - path: playwright-report/ - retention-days: 7