Skip to content
Merged
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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 16 additions & 4 deletions packages/durably-react/src/client/create-job-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,16 @@ import type { JobDefinition } from '@coji/durably'
import type { InferInput, InferOutput } from '../types'
import { useJob, type UseJobClientResult } from './use-job'
import { useJobLogs, type UseJobLogsClientResult } from './use-job-logs'
import { useJobRun, type UseJobRunClientResult } from './use-job-run'
import {
useJobRun,
type UseJobRunClientOptions,
type UseJobRunClientResult,
} from './use-job-run'

type RunCallbackOptions = Pick<
UseJobRunClientOptions,
'onStart' | 'onComplete' | 'onFail'
>

/**
* Options for createJobHooks
Expand Down Expand Up @@ -30,7 +39,10 @@ export interface JobHooks<TInput, TOutput> {
/**
* Hook for subscribing to an existing run by ID
*/
useRun: (runId: string | null) => UseJobRunClientResult<TOutput>
useRun: (
runId: string | null,
options?: RunCallbackOptions,
) => UseJobRunClientResult<TOutput>

/**
* Hook for subscribing to logs from a run
Expand Down Expand Up @@ -80,8 +92,8 @@ export function createJobHooks<
return useJob<InferInput<TJob>, InferOutput<TJob>>({ api, jobName })
},

useRun: (runId: string | null) => {
return useJobRun<InferOutput<TJob>>({ api, runId })
useRun: (runId: string | null, runOptions?: RunCallbackOptions) => {
return useJobRun<InferOutput<TJob>>({ api, runId, ...runOptions })
},

useLogs: (runId: string | null, logsOptions?: { maxLogs?: number }) => {
Expand Down
165 changes: 164 additions & 1 deletion packages/durably-react/tests/client/create-job-hooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import { defineJob } from '@coji/durably'
import { renderHook } from '@testing-library/react'
import { act, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { z } from 'zod'
import { createJobHooks } from '../../src/client/create-job-hooks'
Expand Down Expand Up @@ -125,4 +125,167 @@ describe('createJobHooks', () => {
// Verify the hook returns expected shape
expect(result.current.logs).toEqual([])
})

describe('useRun callbacks', () => {
const hooks = createJobHooks<typeof importCsvJob>({
api: '/api/durably',
jobName: 'import-csv',
})

it('works without options', async () => {
const { result } = renderHook(() => hooks.useRun('no-opts-run'))

await waitFor(() => {
expect(mockEventSource.instances.length).toBeGreaterThan(0)
})

act(() => {
mockEventSource.emit({
type: 'run:complete',
runId: 'no-opts-run',
output: { rowCount: 1, errors: 0 },
})
})

await waitFor(() => {
expect(result.current.status).toBe('completed')
expect(result.current.output).toEqual({ rowCount: 1, errors: 0 })
})
})

it('fires onComplete once when run completes via SSE run:complete', async () => {
const onComplete = vi.fn()

const { result } = renderHook(() =>
hooks.useRun('complete-hooks-run', { onComplete }),
)

await waitFor(() => {
expect(mockEventSource.instances.length).toBeGreaterThan(0)
})

act(() => {
mockEventSource.emit({
type: 'run:complete',
runId: 'complete-hooks-run',
output: { rowCount: 1, errors: 0 },
})
})

await waitFor(() => {
expect(result.current.isCompleted).toBe(true)
})

expect(onComplete).toHaveBeenCalledTimes(1)
})

it('fires onFail once when run fails via SSE run:fail', async () => {
const onFail = vi.fn()

const { result } = renderHook(() =>
hooks.useRun('fail-hooks-run', { onFail }),
)

await waitFor(() => {
expect(mockEventSource.instances.length).toBeGreaterThan(0)
})

act(() => {
mockEventSource.emit({
type: 'run:fail',
runId: 'fail-hooks-run',
error: 'import failed',
})
})

await waitFor(() => {
expect(result.current.isFailed).toBe(true)
})

expect(onFail).toHaveBeenCalledTimes(1)
})

it('fires onStart once when transitioning from null to pending then leased', async () => {
const onStart = vi.fn()

const { result } = renderHook(() =>
hooks.useRun('start-hooks-run', { onStart }),
)

await waitFor(() => {
expect(result.current.status).toBe('pending')
})

expect(onStart).toHaveBeenCalledTimes(1)

await waitFor(() => {
expect(mockEventSource.instances.length).toBeGreaterThan(0)
})

act(() => {
mockEventSource.emit({
type: 'run:leased',
runId: 'start-hooks-run',
})
})

await waitFor(() => {
expect(result.current.status).toBe('leased')
})

expect(onStart).toHaveBeenCalledTimes(1)
})

it.each([
{
label: 'onComplete after completion',
runId: 'rerender-complete-run',
callbackKey: 'onComplete' as const,
sseEvent: {
type: 'run:complete' as const,
runId: 'rerender-complete-run',
output: { rowCount: 1, errors: 0 },
},
isFinal: (r: ReturnType<typeof hooks.useRun>) => r.isCompleted,
},
{
label: 'onFail after failure',
runId: 'rerender-fail-run',
callbackKey: 'onFail' as const,
sseEvent: {
type: 'run:fail' as const,
runId: 'rerender-fail-run',
error: 'failed',
},
isFinal: (r: ReturnType<typeof hooks.useRun>) => r.isFailed,
},
])(
'does not refire $label on rerender',
async ({ runId, callbackKey, sseEvent, isFinal }) => {
const cb = vi.fn()

const { rerender, result } = renderHook(() =>
hooks.useRun(runId, { [callbackKey]: cb }),
)

await waitFor(() => {
expect(mockEventSource.instances.length).toBeGreaterThan(0)
})

act(() => {
mockEventSource.emit(sseEvent)
})

await waitFor(() => {
expect(isFinal(result.current)).toBe(true)
})

expect(cb).toHaveBeenCalledTimes(1)

rerender()

expect(cb).toHaveBeenCalledTimes(1)
},
)
})
})
44 changes: 44 additions & 0 deletions packages/durably-react/tests/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
import { defineJob, type RunStatus } from '@coji/durably'
import { describe, expectTypeOf, it } from 'vitest'
import { z } from 'zod'
import type { JobHooks } from '../src/client/create-job-hooks'
import type { UseJobRunClientResult } from '../src/client/use-job-run'
import type {
TypedClientRun,
UseRunsClientOptions,
Expand Down Expand Up @@ -109,6 +111,48 @@ describe('Type inference', () => {
})
})

describe('createJobHooks useRun', () => {
type Hooks = JobHooks<{ taskId: string }, { success: boolean }>
type UseRun = Hooks['useRun']

it('accepts runId only or with optional callbacks', () => {
expectTypeOf<UseRun>().parameter(0).toEqualTypeOf<string | null>()
expectTypeOf<UseRun>().parameter(1).toEqualTypeOf<
| {
onStart?: () => void
onComplete?: () => void
onFail?: () => void
}
| undefined
>()
expectTypeOf<UseRun>().returns.toEqualTypeOf<
UseJobRunClientResult<{ success: boolean }>
>()
})

it('accepts runId without second argument', () => {
expectTypeOf<UseRun>().toBeCallableWith('run-id')
expectTypeOf<UseRun>().toBeCallableWith(null)
})

it('accepts empty options object', () => {
expectTypeOf<UseRun>().toBeCallableWith('run-id', {})
expectTypeOf<UseRun>().toBeCallableWith(null, {})
})

it('accepts onComplete and optional callbacks', () => {
expectTypeOf<UseRun>().toBeCallableWith('run-id', {
onComplete: () => {},
})
expectTypeOf<UseRun>().toBeCallableWith('run-id', {
onStart: () => {},
})
expectTypeOf<UseRun>().toBeCallableWith('run-id', {
onFail: () => {},
})
})
})

describe('useJobLogs', () => {
it('returns logs array and clearLogs function', () => {
type Result = UseJobLogsResult
Expand Down
Loading