Skip to content

Commit 83a1daf

Browse files
fix(tests): reserve run slots, await pending assertions, keep dynamic tool args
- Each test workflow run reserves and releases an execution slot. - A case waits for assertions it did not await and fails if one fails. - Mocked MCP and custom tools keep the arguments their schema declares. - A closed session refuses starts still awaiting their lookups. - Stable refresh callback; scroll fade on the results pane. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EHGBgHrtePpi7KMrEfy41R
1 parent e3197cd commit 83a1daf

6 files changed

Lines changed: 78 additions & 16 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/tests/components/test-dashboard.tsx‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
'use client'
22

3-
import { lazy, type ReactNode, Suspense, useEffect, useState } from 'react'
3+
import { lazy, type ReactNode, Suspense, useEffect, useRef, useState } from 'react'
44
import {
55
Badge,
66
cn,
77
DashboardMetric,
8+
scrollFadeAttributes,
9+
scrollFadeClass,
810
Table,
911
TableBody,
1012
TableCell,
1113
TableHead,
1214
TableHeader,
1315
TableRow,
16+
useScrollEdges,
1417
} from '@sim/emcn'
1518
import { Loader } from '@sim/emcn/icons'
1619
import { formatDuration, formatRelativeTime } from '@sim/utils/formatting'
@@ -59,6 +62,8 @@ interface TestDashboardProps {
5962
* freshness, every case, and what it ran against. A latest run still going shows live.
6063
*/
6164
export function TestDashboard({ workspaceId, name, detail }: TestDashboardProps) {
65+
const scrollRef = useRef<HTMLDivElement>(null)
66+
const edges = useScrollEdges(scrollRef)
6267
const selectedRunId =
6368
useTestRunSelectionStore(
6469
(state) => state.selectedRunIds[testRunSelectionKey(workspaceId, name)]
@@ -89,7 +94,11 @@ export function TestDashboard({ workspaceId, name, detail }: TestDashboardProps)
8994
const durationMs = run?.report?.tests.reduce((total, result) => total + result.durationMs, 0)
9095

9196
return (
92-
<div className='@container/dashboard min-h-0 flex-1 overflow-y-auto'>
97+
<div
98+
ref={scrollRef}
99+
className={cn('@container/dashboard min-h-0 flex-1 overflow-y-auto', scrollFadeClass)}
100+
{...scrollFadeAttributes(edges)}
101+
>
93102
<div className='flex flex-col gap-8 @min-[640px]/dashboard:px-8 px-4 py-6'>
94103
<div className='flex flex-col gap-4'>
95104
<div className='-ml-2'>

‎apps/sim/hooks/queries/workflow-tests.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useCallback } from 'react'
12
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
23
import { requestJson } from '@/lib/api/client/request'
34
import {
@@ -83,10 +84,10 @@ export function useWorkflowTestRun(workspaceId: string, name: string, runId: str
8384
/** Refetches one test after its file changes, so its cases follow the edit. */
8485
export function useRefreshWorkflowTest(workspaceId: string, name: string) {
8586
const queryClient = useQueryClient()
86-
return () => {
87+
return useCallback(() => {
8788
queryClient.invalidateQueries({ queryKey: workflowTestKeys.lists() })
8889
queryClient.invalidateQueries({ queryKey: workflowTestKeys.test(workspaceId, name) })
89-
}
90+
}, [queryClient, workspaceId, name])
9091
}
9192

9293
export function useRunWorkflowTests(workspaceId: string) {

‎apps/sim/lib/workflow-tests/session.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,8 @@ export class WorkflowTestSession {
468468
private readonly runs = new Map<string, TestWorkflowRun>()
469469
/** Every workflow this file's runs executed, children included. */
470470
readonly enteredWorkflowIds = new Set<string>()
471+
/** Set when the sandbox run ends; a start still awaiting its lookups then starts nothing. */
472+
private closed = false
471473

472474
constructor(readonly config: WorkflowTestSessionConfig) {}
473475

@@ -481,6 +483,7 @@ export class WorkflowTestSession {
481483
const workflowId = await resolveWorkflowId(workspaceId, args.workflow)
482484
const blocks = await loadWorkflowBlocks(workflowId, workspaceId, version)
483485
const triggerBlockId = chooseTrigger(args.workflow, blocks, args.trigger)
486+
if (this.closed) throw new Error('This workflow test run has ended')
484487
const matcher = new RunMatcher(args.targets, loadChildBlockNames, (id) =>
485488
this.enteredWorkflowIds.add(id)
486489
)
@@ -527,6 +530,7 @@ export class WorkflowTestSession {
527530
}
528531

529532
close(): void {
533+
this.closed = true
530534
this.cancel([...this.runs.keys()])
531535
}
532536

‎apps/sim/lib/workflows/application/run-workflow-for-test.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,13 @@ import {
55
} from '@sim/auth/principal'
66
import { generateId } from '@sim/utils/id'
77
import { getActivelyBannedUserIds } from '@/lib/auth/ban'
8+
import {
9+
releaseExecutionSlot,
10+
reserveExecutionSlot,
11+
} from '@/lib/billing/calculations/usage-reservation'
812
import { resolveBillingAttribution } from '@/lib/billing/core/billing-attribution'
913
import { checkExecutionUsageLimits } from '@/lib/billing/core/usage-gate-cache'
14+
import { getExecutionTimeout, RESERVATION_TTL_BUFFER_MS } from '@/lib/core/execution-limits/types'
1015
import { OrchestrationError } from '@/lib/core/orchestration/types'
1116
import { generateRequestId } from '@/lib/core/utils/request'
1217
import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context'
@@ -80,6 +85,36 @@ export async function runWorkflowForTest(
8085
if (usage.isExceeded) {
8186
throw new OrchestrationError('forbidden', usage.message ?? 'Usage limit exceeded')
8287
}
88+
if (usage.payerUsage) {
89+
const plan = billingAttribution.payerSubscription?.plan
90+
const reservation = await reserveExecutionSlot({
91+
billingEntity: billingAttribution.billingEntity,
92+
reservationId: executionId,
93+
plan,
94+
enterpriseConcurrencyLimit: billingAttribution.payerSubscription?.enterpriseConcurrencyLimit,
95+
currentUsage: usage.payerUsage.currentUsage,
96+
limit: usage.payerUsage.limit,
97+
expiresAt: Date.now() + getExecutionTimeout(plan, 'sync') + RESERVATION_TTL_BUFFER_MS,
98+
...(billingAttribution.organizationId &&
99+
usage.memberUsage?.limit !== null &&
100+
usage.memberUsage?.limit !== undefined
101+
? {
102+
member: {
103+
organizationId: billingAttribution.organizationId,
104+
actorUserId: billingAttribution.actorUserId,
105+
currentUsage: usage.memberUsage.currentUsage,
106+
limit: usage.memberUsage.limit,
107+
},
108+
}
109+
: {}),
110+
})
111+
if (!reservation.reserved) {
112+
throw new OrchestrationError(
113+
'forbidden',
114+
`The workflow cannot start now (${reservation.reason}); wait for running executions to finish`
115+
)
116+
}
117+
}
83118
try {
84119
const result = await executeWorkflow(
85120
{
@@ -108,6 +143,8 @@ export async function runWorkflowForTest(
108143
} catch (error) {
109144
attachAttemptedExecutionId(error, executionId)
110145
throw error
146+
} finally {
147+
await releaseExecutionSlot(executionId)
111148
}
112149
}
113150

‎apps/sim/providers/runtime-context.ts‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { AsyncLocalStorage } from 'node:async_hooks'
22
import { createLogger } from '@sim/logger'
33
import { getErrorMessage } from '@sim/utils/errors'
4-
import { isRecordLike, omit, toRecord } from '@sim/utils/object'
4+
import { isRecordLike, omit, toRecord, toRecordOrNull } from '@sim/utils/object'
55
import {
66
durableSecretProvenanceFromRegistry,
77
importDurableSecretProvenance,
@@ -133,13 +133,18 @@ function modelSuppliedToolInput(
133133
toolId: string,
134134
params: Record<string, unknown>
135135
): Record<string, unknown> {
136-
const declared = getToolParams(toolId) ?? {}
136+
const declared = getToolParams(toolId)
137+
// MCP and custom tools declare their arguments only in the schema the model was given.
138+
const dynamicArgs = declared
139+
? undefined
140+
: toRecord(toRecordOrNull(params._toolSchema)?.properties ?? {})
137141
const input: Record<string, unknown> = {}
138142
for (const [key, value] of Object.entries(params)) {
139-
const visibility = declared[key]?.visibility
140-
if (Object.hasOwn(declared, key) && visibility !== 'user-only' && visibility !== 'hidden') {
141-
input[key] = value
142-
}
143+
const visibility = declared?.[key]?.visibility
144+
const isArgument = declared
145+
? Object.hasOwn(declared, key)
146+
: Object.hasOwn(dynamicArgs ?? {}, key)
147+
if (isArgument && visibility !== 'user-only' && visibility !== 'hidden') input[key] = value
143148
}
144149
return input
145150
}

‎apps/sim/sandbox-tasks/workflow-test.ts‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ const HARNESS = `
115115
check.status = status;
116116
if (error) Object.assign(check, describeError(error));
117117
}
118-
function track(assertion, check) {
118+
function track(assertion, check, test) {
119119
return new Proxy(assertion, {
120120
get(target, key) {
121121
let value;
@@ -135,16 +135,18 @@ const HARNESS = `
135135
throw error;
136136
}
137137
if (result && typeof result.then === 'function') {
138-
return result.then(
138+
const settled = result.then(
139139
(resolved) => { settle(check, 'pass'); return resolved; },
140140
(error) => { settle(check, 'fail', error); throw error; }
141141
);
142+
test.pendingAssertions.push(settled.catch(() => {}));
143+
return settled;
142144
}
143145
settle(check, 'pass');
144-
return result && typeof result === 'object' && result.__flags ? track(result, check) : result;
146+
return result && typeof result === 'object' && result.__flags ? track(result, check, test) : result;
145147
};
146148
}
147-
if (value && typeof value === 'object' && value.__flags) return track(value, check);
149+
if (value && typeof value === 'object' && value.__flags) return track(value, check, test);
148150
return value;
149151
},
150152
});
@@ -155,7 +157,7 @@ const HARNESS = `
155157
const check = { status: 'pending', ...(callSite(new Error().stack) || {}) };
156158
currentTest.checks.push(check);
157159
currentTest.activeCheck = check;
158-
return track(assertion, check);
160+
return track(assertion, check, currentTest);
159161
};
160162
Object.setPrototypeOf(expect, baseExpect);
161163
expect.assertions = (count) => {
@@ -385,7 +387,7 @@ const HARNESS = `
385387
386388
async function runOne(suite, test, onlyNames) {
387389
const path = [...suitePath(suite), test.name];
388-
const result = { path, status: 'pass', durationMs: 0, checks: [], executions: [], logs: [], runIds: [], matchedKeys: new Set(), activeCheck: null, expectedAssertions: null, expectsAssertions: false };
390+
const result = { path, status: 'pass', durationMs: 0, checks: [], executions: [], logs: [], runIds: [], matchedKeys: new Set(), activeCheck: null, expectedAssertions: null, expectsAssertions: false, pendingAssertions: [] };
389391
if (!willRun(suite, test, onlyNames)) {
390392
result.status = 'skip';
391393
return result;
@@ -407,6 +409,10 @@ const HARNESS = `
407409
} finally {
408410
for (const s of suiteChain(suite).reverse()) await runHooks(s.hooks.afterEach);
409411
}
412+
await Promise.all(result.pendingAssertions);
413+
if (result.checks.some((check) => check.status === 'fail')) {
414+
throw new Error('An assertion failed after its test returned; await expect(...).resolves, .rejects, and toMatchRubric');
415+
}
410416
checkAssertionCount(result);
411417
const unmatched = [...registrations.values()].filter((r) => r.test === result && result.runIds.length > 0 && !result.matchedKeys.has(r.key));
412418
if (unmatched.length > 0) {

0 commit comments

Comments
 (0)