Files
simstudioai--sim/apps/sim/lib/workspace-events/no-activity.test.ts
T
wehub-resource-sync d25d482dc2
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

287 lines
10 KiB
TypeScript

/**
* @vitest-environment node
*/
import { dbChainMock, dbChainMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockDispatchSimEvent, mockReadLastFiredAt, mockClaimCooldown } = vi.hoisted(() => ({
mockDispatchSimEvent: vi.fn(),
mockReadLastFiredAt: vi.fn(),
mockClaimCooldown: vi.fn(),
}))
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@/lib/workspace-events/emitter', () => ({
dispatchSimEvent: mockDispatchSimEvent,
}))
vi.mock('@/lib/workspace-events/state', () => ({
readLastFiredAt: mockReadLastFiredAt,
claimCooldown: mockClaimCooldown,
isWithinCooldown: vi.fn(
(lastFiredAt: Date | null, cooldownMs: number) =>
lastFiredAt !== null && Date.now() - lastFiredAt.getTime() < cooldownMs
),
}))
vi.mock('@/lib/workspace-events/subscriptions', () => ({
parseSubscriptionConfig: vi.fn((providerConfig: unknown) => providerConfig),
}))
vi.mock('@/lib/workspace-events/rules', () => ({
excludeSimExecutionsCondition: vi.fn(() => ({ type: 'ne', right: 'sim' })),
}))
import {
NO_ACTIVITY_SUBSCRIPTION_PAGE_SIZE,
NO_ACTIVITY_WORKFLOW_PAGE_SIZE,
pollNoActivityEvents,
} from '@/lib/workspace-events/no-activity'
import type { SimSubscriptionConfig } from '@/lib/workspace-events/types'
function makeConfig(overrides: Partial<SimSubscriptionConfig> = {}): SimSubscriptionConfig {
return {
eventType: 'no_activity',
workflowIds: [],
consecutiveFailures: 3,
failureRatePercent: 50,
windowHours: 24,
durationThresholdMs: 30000,
latencySpikePercent: 100,
costThresholdCredits: 200,
errorCountThreshold: 10,
inactivityHours: 24,
...overrides,
}
}
/** Flattens nested and/or condition trees from the drizzle operator mocks. */
function flattenCondition(condition: unknown): unknown[] {
if (!condition || typeof condition !== 'object') return []
const node = condition as { type?: string; conditions?: unknown[] }
if (node.type === 'and' || node.type === 'or') {
return [node, ...(node.conditions ?? []).flatMap(flattenCondition)]
}
return [node]
}
function allWhereConditions(): unknown[] {
return dbChainMockFns.where.mock.calls.flatMap(([condition]) => flattenCondition(condition))
}
function makeSubscriptionRow(config: SimSubscriptionConfig, webhookId = 'wh-1') {
return {
webhook: {
id: webhookId,
workflowId: 'wf-subscriber',
blockId: 'block-1',
path: 'block-1',
provider: 'sim',
providerConfig: config,
isActive: true,
},
workflow: {
id: 'wf-subscriber',
name: 'Subscriber',
workspaceId: 'ws-1',
},
}
}
describe('pollNoActivityEvents', () => {
beforeEach(() => {
vi.clearAllMocks()
mockReadLastFiredAt.mockResolvedValue(null)
mockClaimCooldown.mockResolvedValue(true)
mockDispatchSimEvent.mockResolvedValue(undefined)
})
it('does nothing when there are no no_activity subscriptions', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([])
const result = await pollNoActivityEvents()
expect(result).toEqual({ subscriptions: 0, checked: 0, fired: 0, skipped: 0 })
expect(mockDispatchSimEvent).not.toHaveBeenCalled()
})
it('fires for a watched workflow with no executions in the window', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([makeSubscriptionRow(makeConfig())])
.mockResolvedValueOnce([{ id: 'wf-quiet', name: 'Quiet Workflow' }])
.mockResolvedValueOnce([])
const result = await pollNoActivityEvents()
expect(result.fired).toBe(1)
expect(mockClaimCooldown).toHaveBeenCalledWith(
'wf-subscriber',
'block-1',
'wf-quiet',
expect.any(Number)
)
expect(mockDispatchSimEvent).toHaveBeenCalledTimes(1)
expect(mockDispatchSimEvent.mock.calls[0][1]).toMatchObject({
event: 'no_activity',
workflowId: 'wf-quiet',
workflowName: 'Quiet Workflow',
runId: null,
})
})
it('does not fire for a workflow with recent activity', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([makeSubscriptionRow(makeConfig())])
.mockResolvedValueOnce([{ id: 'wf-busy', name: 'Busy Workflow' }])
.mockResolvedValueOnce([{ id: 'log-1' }])
const result = await pollNoActivityEvents()
expect(result.fired).toBe(0)
expect(result.skipped).toBe(1)
expect(mockDispatchSimEvent).not.toHaveBeenCalled()
})
it('only fires for the inactive workflow when watching several', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([makeSubscriptionRow(makeConfig())])
.mockResolvedValueOnce([
{ id: 'wf-busy', name: 'Busy Workflow' },
{ id: 'wf-quiet', name: 'Quiet Workflow' },
])
.mockResolvedValueOnce([{ id: 'log-1' }])
.mockResolvedValueOnce([])
const result = await pollNoActivityEvents()
expect(result.fired).toBe(1)
expect(mockDispatchSimEvent).toHaveBeenCalledTimes(1)
expect(mockDispatchSimEvent.mock.calls[0][1]).toMatchObject({ workflowId: 'wf-quiet' })
})
it('cooldown is scoped per watched workflow: a cooled-down workflow does not suppress others', async () => {
mockReadLastFiredAt.mockImplementation((_wf: string, _block: string, scopeKey: string) =>
Promise.resolve(scopeKey === 'wf-cooled' ? new Date() : null)
)
dbChainMockFns.limit
.mockResolvedValueOnce([makeSubscriptionRow(makeConfig())])
.mockResolvedValueOnce([
{ id: 'wf-cooled', name: 'Cooled Workflow' },
{ id: 'wf-quiet', name: 'Quiet Workflow' },
])
.mockResolvedValueOnce([])
const result = await pollNoActivityEvents()
expect(result.fired).toBe(1)
expect(result.skipped).toBe(1)
expect(mockDispatchSimEvent.mock.calls[0][1]).toMatchObject({ workflowId: 'wf-quiet' })
})
it('a never-executed workflow fires once, then the lost claim suppresses repeats', async () => {
mockClaimCooldown.mockResolvedValueOnce(true).mockResolvedValueOnce(false)
for (let poll = 0; poll < 2; poll++) {
dbChainMockFns.limit
.mockResolvedValueOnce([makeSubscriptionRow(makeConfig())])
.mockResolvedValueOnce([{ id: 'wf-never-ran', name: 'Never Ran' }])
.mockResolvedValueOnce([])
}
const first = await pollNoActivityEvents()
const second = await pollNoActivityEvents()
expect(first.fired).toBe(1)
expect(second.fired).toBe(0)
expect(mockDispatchSimEvent).toHaveBeenCalledTimes(1)
})
it('scopes the watched-workflow query to the explicit selection in SQL (before the LIMIT)', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([makeSubscriptionRow(makeConfig({ workflowIds: ['wf-watched'] }))])
.mockResolvedValueOnce([{ id: 'wf-watched', name: 'Watched' }])
.mockResolvedValueOnce([])
const result = await pollNoActivityEvents()
expect(result.checked).toBe(1)
expect(mockDispatchSimEvent).toHaveBeenCalledTimes(1)
expect(mockDispatchSimEvent.mock.calls[0][1]).toMatchObject({ workflowId: 'wf-watched' })
expect(allWhereConditions()).toContainEqual(
expect.objectContaining({ type: 'inArray', values: ['wf-watched'] })
)
})
it('pages through subscriptions past the page size with a keyset cursor (no starvation)', async () => {
// Full first page of non-matching subscriptions (skipped without further
// queries), then a second page holding the one real no_activity
// subscription that must still be reached.
const firstPage = Array.from({ length: NO_ACTIVITY_SUBSCRIPTION_PAGE_SIZE }, (_, i) =>
makeSubscriptionRow(makeConfig({ eventType: 'execution_error' }), `wh-page1-${i}`)
)
dbChainMockFns.limit
.mockResolvedValueOnce(firstPage)
.mockResolvedValueOnce([makeSubscriptionRow(makeConfig(), 'wh-page2-0')])
.mockResolvedValueOnce([{ id: 'wf-quiet', name: 'Quiet Workflow' }])
.mockResolvedValueOnce([])
const result = await pollNoActivityEvents()
expect(result.subscriptions).toBe(NO_ACTIVITY_SUBSCRIPTION_PAGE_SIZE + 1)
expect(result.fired).toBe(1)
expect(mockDispatchSimEvent.mock.calls[0][1]).toMatchObject({ workflowId: 'wf-quiet' })
expect(allWhereConditions()).toContainEqual(
expect.objectContaining({
type: 'gt',
right: `wh-page1-${NO_ACTIVITY_SUBSCRIPTION_PAGE_SIZE - 1}`,
})
)
})
it('pages through watched workflows past the page size with a keyset cursor (no lost coverage)', async () => {
// Full first page of watched workflows all inside their cooldown (skipped
// without activity queries), then a second page holding the quiet
// workflow that must still be reached.
mockReadLastFiredAt.mockImplementation((_wf: string, _block: string, scopeKey: string) =>
Promise.resolve(scopeKey.startsWith('wf-p1-') ? new Date() : null)
)
const firstPage = Array.from({ length: NO_ACTIVITY_WORKFLOW_PAGE_SIZE }, (_, i) => ({
id: `wf-p1-${i}`,
name: `Workflow ${i}`,
}))
dbChainMockFns.limit
.mockResolvedValueOnce([makeSubscriptionRow(makeConfig())])
.mockResolvedValueOnce(firstPage)
.mockResolvedValueOnce([{ id: 'wf-quiet', name: 'Quiet Workflow' }])
.mockResolvedValueOnce([])
const result = await pollNoActivityEvents()
expect(result.checked).toBe(NO_ACTIVITY_WORKFLOW_PAGE_SIZE + 1)
expect(result.skipped).toBe(NO_ACTIVITY_WORKFLOW_PAGE_SIZE)
expect(result.fired).toBe(1)
expect(mockDispatchSimEvent.mock.calls[0][1]).toMatchObject({ workflowId: 'wf-quiet' })
expect(allWhereConditions()).toContainEqual(
expect.objectContaining({
type: 'gt',
right: `wf-p1-${NO_ACTIVITY_WORKFLOW_PAGE_SIZE - 1}`,
})
)
})
it('excludes the subscriber workflow in SQL (before the LIMIT)', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([makeSubscriptionRow(makeConfig())])
.mockResolvedValueOnce([])
const result = await pollNoActivityEvents()
expect(result.checked).toBe(0)
expect(mockDispatchSimEvent).not.toHaveBeenCalled()
expect(allWhereConditions()).toContainEqual(
expect.objectContaining({ type: 'ne', right: 'wf-subscriber' })
)
})
})