Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion test/integration/setup.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
1 change: 1 addition & 0 deletions test/trigger/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './inlineSdk'
30 changes: 30 additions & 0 deletions test/trigger/inlineSdk.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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 })
})
})
149 changes: 149 additions & 0 deletions test/trigger/inlineSdk.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
83 changes: 83 additions & 0 deletions test/trigger/inlineSdk.ts
Original file line number Diff line number Diff line change
@@ -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<P> {
id: string
run: (payload: P) => unknown | Promise<unknown>
// 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<P> {
trigger(payload: P, options?: unknown): Promise<{ id: string }>
triggerAndWait(payload: P, options?: unknown): Promise<RunResult>
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<P = any>(config: TaskConfig<P>): InlineTaskHandle<P> {
const { id, run } = config

// Run inline; capture success/throw as a result rather than rejecting.
const runToResult = async (payload: P): Promise<RunResult> => {
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'
}
}
Loading