From 217ecfc8ced167b526e35914edbc6f6b296a04eb Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 7 Aug 2026 16:50:00 +0545 Subject: [PATCH 1/2] test(OUT-4025): add inline Trigger SDK double + mechanics tests Test-only double of @trigger.dev/sdk: trigger/triggerAndWait/batchTrigger/ batchTriggerAndWait invoke each task's own run() inline, in-process. Matches the real error contract - *AndWait resolve a TaskRunResult (never reject), trigger/batchTrigger are fire-and-forget, batches run every item. No-op logger and stub ApiError. 13 unit cases cover the mechanics. Co-Authored-By: Claude Opus 4.8 --- test/trigger/index.ts | 1 + test/trigger/inlineSdk.test.ts | 149 +++++++++++++++++++++++++++++++++ test/trigger/inlineSdk.ts | 83 ++++++++++++++++++ 3 files changed, 233 insertions(+) create mode 100644 test/trigger/index.ts create mode 100644 test/trigger/inlineSdk.test.ts create mode 100644 test/trigger/inlineSdk.ts diff --git a/test/trigger/index.ts b/test/trigger/index.ts new file mode 100644 index 0000000..7f7a592 --- /dev/null +++ b/test/trigger/index.ts @@ -0,0 +1 @@ +export * from './inlineSdk' diff --git a/test/trigger/inlineSdk.test.ts b/test/trigger/inlineSdk.test.ts new file mode 100644 index 0000000..179abd5 --- /dev/null +++ b/test/trigger/inlineSdk.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from 'vitest' +import { ApiError, logger, schedules, task } from './inlineSdk' + +describe('inline task double', () => { + it('trigger runs the task run() inline and resolves after it completes', async () => { + const calls: number[] = [] + // genuinely async run: proves the double awaits it before resolving. + const run = vi.fn(async (n: number) => { + await Promise.resolve() + calls.push(n) + }) + const t = task({ id: 't', run }) + await t.trigger(5) + expect(run).toHaveBeenCalledWith(5) + expect(calls).toEqual([5]) + }) + + it('trigger ignores the options argument (e.g. concurrencyKey)', async () => { + const run = vi.fn(() => Promise.resolve()) + const t = task({ id: 't', run }) + await t.trigger('acct', { concurrencyKey: 'acct' }) + expect(run).toHaveBeenCalledTimes(1) + expect(run).toHaveBeenCalledWith('acct') // second arg not forwarded + }) + + it('triggerAndWait resolves a { ok: true, output } TaskRunResult on success', async () => { + const t = task({ id: 't', run: (p: { x: number }) => p.x * 2 }) + const res = await t.triggerAndWait({ x: 3 }) + expect(res).toMatchObject({ ok: true, output: 6, taskIdentifier: 't' }) + }) + + it('triggerAndWait RESOLVES { ok: false, error } when run throws — it does not reject', async () => { + const boom = new Error('boom') + const t = task({ + id: 't', + run: () => { + throw boom + }, + }) + // must resolve, not reject: real *AndWait absorbs run failures into the result. + const res = await t.triggerAndWait(undefined) + expect(res).toMatchObject({ ok: false, error: boom, taskIdentifier: 't' }) + }) + + it('batchTriggerAndWait unwraps { payload } items and runs each in order', async () => { + const seen: string[] = [] + const t = task({ + id: 't', + run: (p: string) => { + seen.push(p) + }, + }) + const res = await t.batchTriggerAndWait([{ payload: 'a' }, { payload: 'b' }]) + expect(seen).toEqual(['a', 'b']) + expect(res.runs.map((r) => r.ok)).toEqual([true, true]) + }) + + it('batchTriggerAndWait runs EVERY item even when one throws, reporting per-item results', async () => { + const seen: string[] = [] + const t = task({ + id: 't', + run: (p: string) => { + seen.push(p) + if (p === 'b') throw new Error('bad b') + }, + }) + const res = await t.batchTriggerAndWait([{ payload: 'a' }, { payload: 'b' }, { payload: 'c' }]) + expect(seen).toEqual(['a', 'b', 'c']) // b throwing did not skip c + expect(res.runs.map((r) => r.ok)).toEqual([true, false, true]) + }) + + it('batchTrigger unwraps { payload } items and runs each', async () => { + const seen: string[] = [] + const t = task({ + id: 't', + run: (p: string) => { + seen.push(p) + }, + }) + await t.batchTrigger([{ payload: 'x' }, { payload: 'y' }]) + expect(seen).toEqual(['x', 'y']) + }) + + it('batchTrigger runs every item and does not reject when one throws (fire-and-forget)', async () => { + const seen: string[] = [] + const t = task({ + id: 't', + run: (p: string) => { + seen.push(p) + if (p === 'x') throw new Error('bad x') + }, + }) + await expect(t.batchTrigger([{ payload: 'x' }, { payload: 'y' }])).resolves.toEqual({ id: 't' }) + expect(seen).toEqual(['x', 'y']) + }) + + it('trigger swallows a run failure and does not reject (fire-and-forget)', async () => { + const t = task({ + id: 't', + run: () => { + throw new Error('nope') + }, + }) + await expect(t.trigger(undefined)).resolves.toEqual({ id: 't' }) + }) + + it('preserves delete-before-create ordering across sequential awaited calls', async () => { + const order: string[] = [] + const del = task({ + id: 'del', + run: () => { + order.push('delete') + }, + }) + const create = task({ + id: 'create', + run: () => { + order.push('create') + }, + }) + // mirrors handleChannelFileChanges: delete batch awaited before create batch + await del.batchTriggerAndWait([{ payload: 1 }]) + await create.batchTriggerAndWait([{ payload: 2 }]) + expect(order).toEqual(['delete', 'create']) + }) + + it('schedules.task builds an inline handle too', async () => { + const run = vi.fn(() => Promise.resolve()) + const t = schedules.task({ id: 's', cron: '0 8 * * *', run }) + await t.trigger(undefined) + expect(run).toHaveBeenCalledTimes(1) + }) + + it('logger methods are no-ops', () => { + expect(() => { + logger.info('x') + logger.error('y', {}) + logger.warn('z') + }).not.toThrow() + }) + + it('ApiError is an Error subclass carrying status', () => { + const e = new ApiError('boom') + e.status = 429 + expect(e).toBeInstanceOf(Error) + expect(e.status).toBe(429) + expect(e.message).toBe('boom') + }) +}) diff --git a/test/trigger/inlineSdk.ts b/test/trigger/inlineSdk.ts new file mode 100644 index 0000000..fdfb569 --- /dev/null +++ b/test/trigger/inlineSdk.ts @@ -0,0 +1,83 @@ +// Test-only double of @trigger.dev/sdk (and /v3): runs each task's own run() inline so +// integration tests drive the real task graph with no Trigger server. Matches the real +// error contract — *AndWait resolve a TaskRunResult (never reject), trigger/batchTrigger +// are fire-and-forget, batches always run every item. Concurrency is not modelled (L2). + +interface TaskConfig

{ + id: string + run: (payload: P) => unknown | Promise + // config keys (machine/queue/retry/cron/…) are accepted and ignored. + [key: string]: unknown +} + +// Mirrors @trigger.dev/core's TaskRunResult. +type RunResult = + | { ok: true; id: string; taskIdentifier: string; output: unknown } + | { ok: false; id: string; taskIdentifier: string; error: unknown } + +export interface InlineTaskHandle

{ + trigger(payload: P, options?: unknown): Promise<{ id: string }> + triggerAndWait(payload: P, options?: unknown): Promise + batchTrigger(items: { payload: P }[], options?: unknown): Promise<{ id: string }> + batchTriggerAndWait( + items: { payload: P }[], + options?: unknown, + ): Promise<{ id: string; runs: RunResult[] }> +} + +// biome-ignore lint/suspicious/noExplicitAny: the double mirrors the SDK's loose payload generics. +export function task

(config: TaskConfig

): InlineTaskHandle

{ + const { id, run } = config + + // Run inline; capture success/throw as a result rather than rejecting. + const runToResult = async (payload: P): Promise => { + try { + const output = await run(payload) + return { ok: true, id, taskIdentifier: id, output } + } catch (error) { + return { ok: false, id, taskIdentifier: id, error } + } + } + + return { + async trigger(payload) { + await runToResult(payload) // fire-and-forget: swallow failures + return { id } + }, + triggerAndWait(payload) { + return runToResult(payload) + }, + async batchTrigger(items) { + for (const item of items) await runToResult(item.payload) + return { id } + }, + async batchTriggerAndWait(items) { + const runs: RunResult[] = [] + for (const item of items) runs.push(await runToResult(item.payload)) + return { id, runs } + }, + } +} + +export const schedules = { task } + +const noop = (..._args: unknown[]): void => { + /* no-op */ +} +export const logger = { + info: noop, + error: noop, + warn: noop, + debug: noop, + log: noop, + trace: noop, +} + +// Stub so withErrorLogger's `instanceof ApiError` resolves without the real SDK. +export class ApiError extends Error { + status?: number + constructor(message?: string) { + super(message) + this.name = 'ApiError' + } +} From c03923ad9ff03771028e20e05ff0bb795f5c22e6 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 7 Aug 2026 16:50:13 +0545 Subject: [PATCH 2/2] test(OUT-4025): wire inline Trigger SDK double into integration setup vi.mock both SDK specifiers (@trigger.dev/sdk/v3 for tasks, base @trigger.dev/sdk for withErrorLogger) so integration tests run the real task graph in-process. Smoke test drives a real exported task (processDropboxChanges) inline against Postgres. Co-Authored-By: Claude Opus 4.8 --- test/integration/setup.ts | 7 ++++- test/trigger/inlineSdk.integration.test.ts | 30 ++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 test/trigger/inlineSdk.integration.test.ts diff --git a/test/integration/setup.ts b/test/integration/setup.ts index 63acf58..e926068 100644 --- a/test/integration/setup.ts +++ b/test/integration/setup.ts @@ -1,9 +1,14 @@ import postgres from 'postgres' -import { afterAll, afterEach, beforeAll, beforeEach, inject } from 'vitest' +import { afterAll, afterEach, beforeAll, beforeEach, inject, vi } from 'vitest' import { resetFactories } from '../factories' import { server } from '../msw/server' import { applyPlaceholderServerEnv } from '../support/placeholder-env' +// Run tasks inline via the double so tests drive the real graph (no Trigger server). +// Both specifiers: tasks use `/v3`, withErrorLogger uses the base import. +vi.mock('@trigger.dev/sdk/v3', () => import('../trigger/inlineSdk')) +vi.mock('@trigger.dev/sdk', () => import('../trigger/inlineSdk')) + // Runs in every worker BEFORE any test file imports `@/db`. Point the app's DB // singleton (`src/db/index.ts` reads `env.DATABASE_URL` at import) at the // container, and satisfy the rest of the server-env Zod schema with placeholders. diff --git a/test/trigger/inlineSdk.integration.test.ts b/test/trigger/inlineSdk.integration.test.ts new file mode 100644 index 0000000..f1c9445 --- /dev/null +++ b/test/trigger/inlineSdk.integration.test.ts @@ -0,0 +1,30 @@ +import { task } from '@trigger.dev/sdk/v3' +import { describe, expect, it } from 'vitest' +import { processDropboxChanges } from '@/trigger/processFileSync' + +// Load-bearing on the setup-level vi.mock: unmocked, the live SDK's trigger methods +// throw "can only be used from inside a task.run()". + +describe('inline SDK double in the integration project', () => { + it('the doubled task() runs run() inline and resolves a TaskRunResult', async () => { + const ran: string[] = [] + // returns a Promise to satisfy the real `run` type (tsc sees the real SDK). + const probe = task({ + id: 'inline-double-probe', + run: (payload: string) => { + ran.push(payload) + return Promise.resolve() + }, + }) + const result = await probe.triggerAndWait('hit') + expect(ran).toEqual(['hit']) + expect(result).toMatchObject({ ok: true, output: undefined }) + }) + + it('drives a REAL exported task through the double, in-process, against Postgres', async () => { + // No active connection in a truncated DB → fetchDropBoxChanges logs + returns early + // (no MSW calls, no throw). Proves a real task ran inline via the double. + const result = await processDropboxChanges.triggerAndWait('no-such-account') + expect(result).toMatchObject({ ok: true }) + }) +})