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
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* @vitest-environment node
*/

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { OrchestrationError } from '@/lib/core/orchestration/types'

const mocks = vi.hoisted(() => ({
authorize: vi.fn(),
getSession: vi.fn(),
resumePage: vi.fn(() => null),
unavailablePage: vi.fn(() => null),
redirect: vi.fn((url: string) => {
throw new Error(`NEXT_REDIRECT:${url}`)
}),
}))

vi.mock('@/lib/auth', () => ({
auth: { api: { getSession: vi.fn() } },
getSession: mocks.getSession,
}))

vi.mock('next/navigation', () => ({
redirect: mocks.redirect,
}))

vi.mock('@/lib/workflows/application/read-paused-workflow-execution', () => ({
readPausedWorkflowExecution: { authorize: mocks.authorize },
}))

vi.mock('@/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client', () => ({
default: mocks.resumePage,
}))

vi.mock(
'@/app/(interfaces)/resume/[workflowId]/[executionId]/resume-execution-unavailable',
() => ({
ResumeExecutionUnavailable: mocks.unavailablePage,
})
)

import ResumeExecutionPageWrapper from '@/app/(interfaces)/resume/[workflowId]/[executionId]/page'

const PAGE_PARAMS = { workflowId: 'workflow-1', executionId: 'execution-1' }

function pageProps(contextId?: string) {
return {
params: Promise.resolve(PAGE_PARAMS),
searchParams: Promise.resolve(contextId ? { contextId } : {}),
}
}

describe('ResumeExecutionPageWrapper', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getSession.mockResolvedValue({
user: { id: 'user-1' },
session: { id: 'session-1' },
})
mocks.authorize.mockResolvedValue(undefined)
})

it('redirects an unauthenticated visitor before any protected lookup', async () => {
mocks.getSession.mockResolvedValueOnce(null)
const callbackPath = '/resume/workflow-1/execution-1?contextId=context-1'

await expect(ResumeExecutionPageWrapper(pageProps('context-1'))).rejects.toThrow(
`NEXT_REDIRECT:/login?callbackUrl=${encodeURIComponent(callbackPath)}`
)
expect(mocks.authorize).not.toHaveBeenCalled()
})

it('authorizes the session without serializing paused execution detail into the page', async () => {
const result = await ResumeExecutionPageWrapper(pageProps('context-1'))

expect(mocks.authorize).toHaveBeenCalledWith({
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
input: PAGE_PARAMS,
})
expect(result.props).toMatchObject({
params: PAGE_PARAMS,
initialContextId: 'context-1',
})
expect(result.type).toBe(mocks.resumePage)
expect(result.key).toBe('workflow-1:execution-1:context-1')
expect(result.props).not.toHaveProperty('initialExecutionDetail')
expect(result.props).not.toHaveProperty('canLoadExecution')
})

it.each([
new OrchestrationError('forbidden', 'Insufficient workspace permissions'),
new OrchestrationError('not_found', 'Workflow not found'),
])('renders a data-free concealed state after authorization refusal: %s', async (error) => {
mocks.authorize.mockRejectedValueOnce(error)

const result = await ResumeExecutionPageWrapper(pageProps())

expect(result.type).toBe(mocks.unavailablePage)
expect(result.type).not.toBe(mocks.resumePage)
expect(result.props).toEqual({})
})

it('propagates authorization infrastructure failures', async () => {
const infrastructureError = new Error('database unavailable')
mocks.authorize.mockRejectedValueOnce(infrastructureError)

await expect(ResumeExecutionPageWrapper(pageProps())).rejects.toBe(infrastructureError)
})
})
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { Metadata } from 'next'
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { asOrchestrationError } from '@/lib/core/orchestration/types'
import { readPausedWorkflowExecution } from '@/lib/workflows/application/read-paused-workflow-execution'
import { ResumeExecutionUnavailable } from '@/app/(interfaces)/resume/[workflowId]/[executionId]/resume-execution-unavailable'
import ResumeExecutionPage from '@/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client'

export const metadata: Metadata = {
Expand Down Expand Up @@ -30,16 +34,37 @@ export default async function ResumeExecutionPageWrapper({
const initialContextId = Array.isArray(initialContextIdParam)
? initialContextIdParam[0]
: initialContextIdParam
const resumePath = `/resume/${encodeURIComponent(workflowId)}/${encodeURIComponent(executionId)}${
initialContextId ? `?${new URLSearchParams({ contextId: initialContextId })}` : ''
}`
const session = await getSession()
if (!session?.user?.id) {
redirect(`/login?callbackUrl=${encodeURIComponent(resumePath)}`)
}
if (!session.session?.id) throw new Error('Authenticated session is missing its session ID')

const detail = await PauseResumeManager.getPausedExecutionDetail({
workflowId,
executionId,
})
try {
if (!readPausedWorkflowExecution.authorize) {
throw new Error('Paused execution read use case does not expose authorization')
}
await readPausedWorkflowExecution.authorize({
principal: {
kind: 'session',
userId: session.user.id,
sessionId: session.session.id,
},
input: { workflowId, executionId },
})
} catch (error) {
const classified = asOrchestrationError(error)
if (classified?.code !== 'forbidden' && classified?.code !== 'not_found') throw error
return <ResumeExecutionUnavailable />
}

return (
<ResumeExecutionPage
key={`${workflowId}:${executionId}:${initialContextId ?? ''}`}
params={resolvedParams}
initialExecutionDetail={detail ? structuredClone(detail) : null}
initialContextId={initialContextId}
/>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { ChipLink } from '@sim/emcn'

export function ResumeExecutionUnavailable() {
return (
<div className='flex flex-1 items-center justify-center p-6'>
<div className='max-w-[400px] text-center'>
<h1 className='mb-2 text-[var(--text-primary)] text-xl'>Execution Not Found</h1>
<p className='mb-6 text-[var(--text-secondary)] text-sm'>
This execution could not be located or has already completed.
</p>
<ChipLink variant='border' href='/'>
Return Home
</ChipLink>
</div>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/**
* @vitest-environment jsdom
*/
import { act } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiClientError } from '@/lib/api/client/errors'
import type { PausePointWithQueue } from '@/hooks/queries/resume-execution'

const mocks = vi.hoisted(() => ({
pauseContextDetail: vi.fn(),
refetch: vi.fn(),
replace: vi.fn(),
resumeContext: vi.fn(),
resumeExecutionDetail: vi.fn(),
}))

vi.mock('next/navigation', () => ({
useRouter: () => ({ replace: mocks.replace }),
}))

vi.mock('@/hooks/queries/resume-execution', () => ({
resumeKeys: {
execution: (workflowId: string, executionId: string) => [
'resume-execution',
'execution',
workflowId,
executionId,
],
context: (workflowId: string, executionId: string, contextId: string) => [
'resume-execution',
'context',
workflowId,
executionId,
contextId,
],
},
usePauseContextDetail: mocks.pauseContextDetail,
useResumeContext: mocks.resumeContext,
useResumeExecutionDetail: mocks.resumeExecutionDetail,
}))

import ResumeExecutionPage, {
selectInitialResumeContextId,
} from '@/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client'

const params = { workflowId: 'workflow-1', executionId: 'execution-1' }

let container: HTMLDivElement
let queryClient: QueryClient
let root: Root

function apiError(status: number): ApiClientError {
return new ApiClientError({
status,
message: status === 404 ? 'Workflow not found' : 'Request failed',
body: { error: 'Request failed' },
})
}

function renderPage(initialContextId?: string) {
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<ResumeExecutionPage params={params} initialContextId={initialContextId} />
</QueryClientProvider>
)
})
}

describe('ResumeExecutionPage', () => {
beforeEach(() => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
mocks.pauseContextDetail.mockReturnValue({ data: undefined, isLoading: false })
mocks.resumeContext.mockReturnValue({ mutateAsync: vi.fn() })
mocks.resumeExecutionDetail.mockReturnValue({
data: undefined,
error: null,
isError: false,
isFetching: true,
isLoading: true,
refetch: mocks.refetch,
})
})

afterEach(() => {
act(() => root.unmount())
queryClient.clear()
container.remove()
vi.clearAllMocks()
})

it('renders a concealed state for an absent or newly inaccessible execution', () => {
mocks.resumeExecutionDetail.mockReturnValue({
data: undefined,
error: apiError(404),
isError: true,
isFetching: false,
isLoading: false,
refetch: mocks.refetch,
})

renderPage('context-1')

expect(container.textContent).toContain('Execution Not Found')
expect(container.textContent).not.toContain('Could Not Load Execution')
expect(mocks.pauseContextDetail).toHaveBeenLastCalledWith(
params.workflowId,
params.executionId,
undefined
)
})

it('redirects an expired session back through login', () => {
mocks.resumeExecutionDetail.mockReturnValue({
data: undefined,
error: apiError(401),
isError: true,
isFetching: false,
isLoading: false,
refetch: mocks.refetch,
})

renderPage('context-1')

const callbackPath = '/resume/workflow-1/execution-1?contextId=context-1'
expect(mocks.replace).toHaveBeenCalledWith(
`/login?callbackUrl=${encodeURIComponent(callbackPath)}`
)
expect(container.textContent).toContain('Redirecting to sign in')
})

it('shows a retryable error instead of mislabeling infrastructure failure', () => {
mocks.resumeExecutionDetail.mockReturnValue({
data: undefined,
error: apiError(500),
isError: true,
isFetching: false,
isLoading: false,
refetch: mocks.refetch,
})

renderPage()

expect(container.textContent).toContain('Could Not Load Execution')
expect(container.textContent).not.toContain('Execution Not Found')
const retryButton = Array.from(container.querySelectorAll('button')).find(
(button) => button.textContent === 'Try again'
)
expect(retryButton).toBeDefined()
act(() => retryButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })))
expect(mocks.refetch).toHaveBeenCalledOnce()
})
})

describe('selectInitialResumeContextId', () => {
const pausePoints = [
{ contextId: 'resumed-context', resumeStatus: 'resumed' },
{ contextId: 'paused-context', resumeStatus: 'paused' },
] as PausePointWithQueue[]

it('uses a requested context only when the authorized execution contains it', () => {
expect(selectInitialResumeContextId(pausePoints, 'paused-context')).toBe('paused-context')
expect(selectInitialResumeContextId(pausePoints, 'unknown-context')).toBe('paused-context')
})

it('falls back to the first context when none is paused', () => {
expect(
selectInitialResumeContextId(
[{ contextId: 'first-context', resumeStatus: 'resumed' }] as PausePointWithQueue[],
null
)
).toBe('first-context')
})
})
Loading
Loading