d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
212 lines
6.9 KiB
TypeScript
212 lines
6.9 KiB
TypeScript
/**
|
|
* @vitest-environment node
|
|
*
|
|
* Focused tests for the registry store's `loadWorkflowState` after the
|
|
* workflow-state cache collapse: it hydrates the shared
|
|
* `workflowKeys.state(id)` entry via `fetchQuery` (always-fresh,
|
|
* `staleTime: 0`) and projects the envelope into the workflow / sub-block /
|
|
* variables / deployment stores, guarding against superseded responses.
|
|
*/
|
|
import { QueryClient } from '@tanstack/react-query'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const { mockRequestJson, sharedQueryClient } = vi.hoisted(() => ({
|
|
mockRequestJson: vi.fn(),
|
|
sharedQueryClient: { current: null as unknown },
|
|
}))
|
|
|
|
vi.mock('@/lib/api/client/request', () => ({
|
|
requestJson: mockRequestJson,
|
|
}))
|
|
|
|
vi.mock('@/app/_shell/providers/get-query-client', () => ({
|
|
getQueryClient: () => sharedQueryClient.current as QueryClient,
|
|
}))
|
|
|
|
const { replaceWorkflowState, initializeFromWorkflow, setVariablesState, clearError } = vi.hoisted(
|
|
() => ({
|
|
replaceWorkflowState: vi.fn(),
|
|
initializeFromWorkflow: vi.fn(),
|
|
setVariablesState: vi.fn(),
|
|
clearError: vi.fn(),
|
|
})
|
|
)
|
|
|
|
vi.mock('@/stores/workflows/workflow/store', () => ({
|
|
useWorkflowStore: {
|
|
getState: () => ({ replaceWorkflowState, blocks: {} }),
|
|
setState: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
vi.mock('@/stores/workflows/subblock/store', () => ({
|
|
useSubBlockStore: {
|
|
getState: () => ({ initializeFromWorkflow }),
|
|
setState: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
vi.mock('@/stores/variables/store', () => ({
|
|
useVariablesStore: {
|
|
getState: () => ({ variables: {} }),
|
|
setState: (updater: unknown) => setVariablesState(updater),
|
|
},
|
|
}))
|
|
|
|
vi.mock('@/stores/operation-queue/store', () => ({
|
|
useOperationQueueStore: {
|
|
getState: () => ({ clearError }),
|
|
},
|
|
}))
|
|
|
|
vi.mock('@/hooks/queries/utils/invalidate-workflow-lists', () => ({
|
|
invalidateWorkflowLists: vi.fn().mockResolvedValue(undefined),
|
|
}))
|
|
|
|
vi.mock('@/stores/workflows/utils', () => ({
|
|
getUniqueBlockName: vi.fn(),
|
|
regenerateBlockIds: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@/lib/workflows/autolayout/constants', () => ({
|
|
DEFAULT_DUPLICATE_OFFSET: { x: 0, y: 0 },
|
|
}))
|
|
|
|
vi.mock('@/hooks/queries/deployments', () => ({
|
|
deploymentKeys: {
|
|
infos: () => ['deployments', 'info'],
|
|
info: (workflowId: string | null) => ['deployments', 'info', workflowId ?? ''],
|
|
},
|
|
}))
|
|
|
|
import { workflowKeys } from '@/hooks/queries/utils/workflow-keys'
|
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
|
|
|
function makeEnvelope(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: 'wf-1',
|
|
isDeployed: true,
|
|
deployedAt: new Date('2026-01-01T00:00:00.000Z'),
|
|
isPublicApi: false,
|
|
state: {
|
|
blocks: { b1: { id: 'b1' } },
|
|
edges: [],
|
|
loops: {},
|
|
parallels: {},
|
|
},
|
|
variables: { v1: { id: 'v1', workflowId: 'wf-1', name: 'x' } },
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
describe('registry store loadWorkflowState (collapsed cache)', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
// The store dispatches an `active-workflow-changed` CustomEvent on the
|
|
// window; provide a minimal stub under the node environment.
|
|
vi.stubGlobal('window', { dispatchEvent: vi.fn() })
|
|
sharedQueryClient.current = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
})
|
|
// Reset store to a clean state with a workspace scope so loadWorkflowState
|
|
// does not bail on the missing-workspace guard.
|
|
useWorkflowRegistry.setState({
|
|
activeWorkflowId: null,
|
|
error: null,
|
|
hydration: {
|
|
phase: 'idle',
|
|
workspaceId: 'ws-1',
|
|
workflowId: null,
|
|
requestId: null,
|
|
error: null,
|
|
},
|
|
})
|
|
})
|
|
|
|
it('projects envelope state, variables, and deployment info into the stores', async () => {
|
|
mockRequestJson.mockResolvedValue({ data: makeEnvelope() })
|
|
|
|
await useWorkflowRegistry.getState().loadWorkflowState('wf-1')
|
|
|
|
expect(replaceWorkflowState).toHaveBeenCalledTimes(1)
|
|
expect(replaceWorkflowState.mock.calls[0][0]).toMatchObject({
|
|
currentWorkflowId: 'wf-1',
|
|
blocks: { b1: { id: 'b1' } },
|
|
edges: [],
|
|
})
|
|
expect(initializeFromWorkflow).toHaveBeenCalledWith('wf-1', { b1: { id: 'b1' } })
|
|
expect(setVariablesState).toHaveBeenCalledTimes(1)
|
|
|
|
const deploymentInfo = (sharedQueryClient.current as QueryClient).getQueryData([
|
|
'deployments',
|
|
'info',
|
|
'wf-1',
|
|
])
|
|
expect(deploymentInfo).toMatchObject({
|
|
isDeployed: true,
|
|
isPublicApi: false,
|
|
deployedAt: '2026-01-01T00:00:00.000Z',
|
|
})
|
|
|
|
expect(useWorkflowRegistry.getState().activeWorkflowId).toBe('wf-1')
|
|
expect(useWorkflowRegistry.getState().hydration.phase).toBe('ready')
|
|
})
|
|
|
|
it('hydrates the SAME workflowKeys.state(id) cache entry the hooks read', async () => {
|
|
const envelope = makeEnvelope()
|
|
mockRequestJson.mockResolvedValue({ data: envelope })
|
|
|
|
await useWorkflowRegistry.getState().loadWorkflowState('wf-1')
|
|
|
|
const client = sharedQueryClient.current as QueryClient
|
|
const cached = client.getQueryData(workflowKeys.state('wf-1'))
|
|
expect(cached).toBeDefined()
|
|
expect((cached as { id: string }).id).toBe('wf-1')
|
|
|
|
// Exactly one cache entry exists for this endpoint — the shared one.
|
|
const stateEntries = client
|
|
.getQueryCache()
|
|
.findAll({ queryKey: workflowKeys.states() })
|
|
.filter((q) => q.queryKey[2] === 'wf-1')
|
|
expect(stateEntries).toHaveLength(1)
|
|
})
|
|
|
|
it('re-fetches on every call (staleTime: 0, never served stale)', async () => {
|
|
mockRequestJson.mockResolvedValue({ data: makeEnvelope() })
|
|
|
|
await useWorkflowRegistry.getState().loadWorkflowState('wf-1')
|
|
await useWorkflowRegistry.getState().loadWorkflowState('wf-1')
|
|
|
|
expect(mockRequestJson).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('discards a superseded response via the staleness guard', async () => {
|
|
// First load (wf-1) is in-flight; a second load (wf-2) supersedes the
|
|
// hydration workflowId, then wf-1 finally resolves. The guard compares the
|
|
// current hydration workflowId/requestId against the resolving request and
|
|
// must discard the now-stale wf-1 projection.
|
|
let resolveFirst: (value: unknown) => void = () => {}
|
|
const firstPending = new Promise((resolve) => {
|
|
resolveFirst = resolve
|
|
})
|
|
|
|
mockRequestJson
|
|
.mockImplementationOnce(() => firstPending)
|
|
.mockImplementationOnce(() => Promise.resolve({ data: makeEnvelope({ id: 'wf-2' }) }))
|
|
|
|
const firstLoad = useWorkflowRegistry.getState().loadWorkflowState('wf-1')
|
|
const secondLoad = useWorkflowRegistry.getState().loadWorkflowState('wf-2')
|
|
await secondLoad
|
|
|
|
expect(useWorkflowRegistry.getState().activeWorkflowId).toBe('wf-2')
|
|
const projectionsAfterSecond = replaceWorkflowState.mock.calls.length
|
|
|
|
resolveFirst({ data: makeEnvelope({ id: 'wf-1' }) })
|
|
await firstLoad
|
|
|
|
// The stale wf-1 result must not project again — hydration is now wf-2.
|
|
expect(replaceWorkflowState.mock.calls.length).toBe(projectionsAfterSecond)
|
|
expect(useWorkflowRegistry.getState().activeWorkflowId).toBe('wf-2')
|
|
})
|
|
})
|