Skip to content

Commit d8ecc5b

Browse files
authored
perf(tests): cut per-file import graphs in apps/sim and shard the suite across CI runners (#7367)
* perf(tests): cut per-file import graphs in apps/sim and shard the suite across CI runners * fix(tests): recheck abort after lazy handler load; list script tests by file
1 parent df833ef commit d8ecc5b

36 files changed

Lines changed: 622 additions & 329 deletions

File tree

.github/workflows/test-build.yml

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,13 +180,19 @@ jobs:
180180

181181
# Runs the setup CLI's Bun tests plus each workspace's Vitest suite,
182182
# without `--coverage`. See the Codecov note below.
183+
#
184+
# apps/sim runs only its first shard here; `test-shard` below runs the
185+
# other. That suite is bound by the single Vite server thread that feeds
186+
# every worker — wall time is flat from 4 to 13 workers — so a bigger
187+
# runner buys nothing and a second runner halves it.
183188
- name: Run tests
184189
env:
185190
NODE_OPTIONS: '--no-warnings --max-old-space-size=8192'
186191
NEXT_PUBLIC_APP_URL: 'https://www.sim.ai'
187192
DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/simstudio'
188193
ENCRYPTION_KEY: '7cf672e460e430c1fba707575c2b0e2ad5a99dddf9b7b7e3b5646e630861db1c' # dummy key for CI only
189194
TURBO_CACHE_DIR: .turbo
195+
SIM_TEST_SHARD: 1/2
190196
run: bun run test
191197

192198
- name: Check schema and migrations are in sync
@@ -202,6 +208,67 @@ jobs:
202208
fi
203209
echo "✅ Schema and migrations are in sync"
204210
211+
# The second half of apps/sim's Vitest suite. Everything else — lint, the
212+
# audits, type-check, the other workspaces' suites — lives in `test-build`
213+
# with shard 1; this job exists only because that suite cannot go faster on
214+
# one machine (see the "Run tests" note there). The Turbo cache disk gets
215+
# its own key so the two shards' entries do not evict each other.
216+
test-shard:
217+
name: Test (shard 2)
218+
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-latest' }}
219+
timeout-minutes: 15
220+
221+
steps:
222+
- name: Checkout code
223+
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
224+
225+
- name: Setup Bun
226+
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
227+
with:
228+
bun-version: 1.3.14
229+
230+
- name: Setup Node
231+
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
232+
with:
233+
node-version: 24
234+
235+
- name: Mount Bun cache
236+
uses: ./.github/actions/cache-mount
237+
with:
238+
provider: ${{ vars.CI_PROVIDER }}
239+
key: ${{ github.repository }}-bun-cache-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }}
240+
path: ~/.bun/install/cache
241+
242+
- name: Mount node_modules
243+
uses: ./.github/actions/cache-mount
244+
with:
245+
provider: ${{ vars.CI_PROVIDER }}
246+
key: ${{ github.repository }}-node-modules-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }}-${{ hashFiles('bun.lock') }}
247+
path: ./node_modules
248+
249+
- name: Mount Turbo cache
250+
uses: ./.github/actions/cache-mount
251+
with:
252+
provider: ${{ vars.CI_PROVIDER }}
253+
key: ${{ github.repository }}-turbo-cache-shard-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }}
254+
path: ./.turbo
255+
256+
- name: Install dependencies
257+
run: bun install --frozen-lockfile --ignore-scripts
258+
259+
- name: Install ripgrep
260+
run: command -v rg || (sudo apt-get update && sudo apt-get install -y ripgrep)
261+
262+
- name: Run tests (apps/sim shard 2/2)
263+
env:
264+
NODE_OPTIONS: '--no-warnings --max-old-space-size=8192'
265+
NEXT_PUBLIC_APP_URL: 'https://www.sim.ai'
266+
DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/simstudio'
267+
ENCRYPTION_KEY: '0000000000000000000000000000000000000000000000000000000000000000' # dummy key for CI only
268+
TURBO_CACHE_DIR: .turbo
269+
SIM_TEST_SHARD: 2/2
270+
run: bunx turbo run test --filter=@sim/app
271+
205272
# Next.js production build, in parallel with lint + tests. Sticky disks are
206273
# cloned from the last committed snapshot per job and committed last-writer-
207274
# wins, so concurrent mounts are safe. The bun/node_modules disks are shared

apps/sim/app/api/copilot/tools/execute/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ export const POST = withRouteHandler((request: NextRequest) =>
162162
// glob/read/grep, function execute, ...) plus the server tool router
163163
// fallback — the plain server-tool adapter alone rejects VFS tools
164164
// with "Unknown server tool".
165-
ensureHandlersRegistered()
165+
await ensureHandlersRegistered()
166166
const result = await executeTool(toolName, params, {
167167
userId,
168168
workflowId: workflowId ?? '',

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-icon.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
/** @vitest-environment node */
22
import { Workflow } from '@sim/emcn/icons'
3-
import { describe, expect, it } from 'vitest'
3+
import { describe, expect, it, vi } from 'vitest'
44
import { AgentSkillsIcon } from '@/components/icons'
55
import { getDocumentIcon } from '@/components/icons/document-icons'
66
import { mentionIcon } from './mention-icon'
77
import type { MentionKind } from './types'
88

9+
/** Compares real icon components by identity; the global `@/components/icons` stub in vitest.setup.ts would make that vacuous. */
10+
vi.unmock('@/components/icons')
11+
912
describe('mentionIcon', () => {
1013
it('uses the product-wide glyph for a known kind', () => {
1114
expect(mentionIcon('workflow', 'x')).toBe(Workflow)

apps/sim/blocks/blocks/harmonic.test.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,19 @@
33
*/
44
import { describe, expect, it, vi } from 'vitest'
55

6-
vi.unmock('@/tools/registry')
6+
/**
7+
* Only this service's configs are needed; the full registry is ~6,000 modules.
8+
* Registration is asserted through the generated `@/tools/tool-ids`.
9+
*/
10+
vi.mock('@/tools/registry', async () => {
11+
const { partialToolRegistry } = await import('@sim/testing/mocks/tool-registry.mock')
12+
return { tools: partialToolRegistry(await import('@/tools/harmonic')) }
13+
})
714

815
import { HarmonicBlock, HarmonicBlockMeta } from '@/blocks/blocks/harmonic'
916
import { BLOCK_META_REGISTRY, BLOCK_REGISTRY } from '@/blocks/registry-maps'
1017
import { tools } from '@/tools/registry'
18+
import { hasToolId } from '@/tools/tool-ids'
1119

1220
describe('HarmonicBlock', () => {
1321
const buildParams = HarmonicBlock.tools.config!.params!
@@ -44,7 +52,8 @@ describe('HarmonicBlock', () => {
4452

4553
for (const operation of operationIds) {
4654
const tool = tools[operation]
47-
expect(tool?.id, `missing registry entry ${operation}`).toBe(operation)
55+
expect(hasToolId(operation), `missing registry entry ${operation}`).toBe(true)
56+
expect(tool?.id).toBe(operation)
4857

4958
const blockOutputs = Object.entries(HarmonicBlock.outputs)
5059
.filter(([, output]) => {

apps/sim/blocks/blocks/outlook.test.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { describe, expect, it } from 'vitest'
4+
import { describe, expect, it, vi } from 'vitest'
55
import { tools as toolRegistry } from '@/tools/registry'
66
import { OutlookBlock } from './outlook'
77

88
/**
9-
* Uses the real tool registry: these assertions are about tool registration and
10-
* params, which the global `@/tools/registry` mock in vitest.setup.ts empties.
9+
* Only this service's configs are needed; the full registry is ~6,000 modules.
10+
* Registration is asserted through the generated `@/tools/tool-ids`.
1111
*/
12-
vi.unmock('@/tools/registry')
12+
vi.mock('@/tools/registry', async () => {
13+
const { partialToolRegistry } = await import('@sim/testing/mocks/tool-registry.mock')
14+
return { tools: partialToolRegistry(await import('@/tools/outlook')) }
15+
})
1316

1417
const block = OutlookBlock
1518

apps/sim/blocks/brand-icon.test.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import { OAUTH_PROVIDERS } from '@/lib/oauth'
88
import { BrandIcon, withBrandIcon } from '@/blocks/brand-icon'
99
import { getAllBlocks } from '@/blocks/registry'
1010

11+
/** Compares real icon components by identity; the global `@/components/icons` stub in vitest.setup.ts would make that vacuous. */
12+
vi.unmock('@/components/icons')
13+
1114
vi.mocked(getAllBlocks).mockReturnValue([
1215
{ icon: DropboxIcon, iconColor: '#0061FF' },
1316
] as unknown as ReturnType<typeof getAllBlocks>)

apps/sim/lib/billing/core/limit-notifications.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,13 @@ import { member, organization, settings, user, userStats } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
55
import { and, eq, sql } from 'drizzle-orm'
6-
import { getLimitEmailSubject, renderLimitThresholdEmail } from '@/components/emails'
76
import type { HighestPrioritySubscription } from '@/lib/billing/core/plan'
87
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
98
import type { BillingEntity } from '@/lib/billing/core/usage-log'
109
import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
1110
import { buildUpgradeHref, type UpgradeReason } from '@/lib/billing/upgrade-reasons'
1211
import { isBillingEnabled } from '@/lib/core/config/env-flags'
1312
import { getBaseUrl } from '@/lib/core/utils/urls'
14-
import { sendEmail } from '@/lib/messaging/email/mailer'
1513
import { getEmailPreferences } from '@/lib/messaging/email/unsubscribe'
1614

1715
const logger = createLogger('LimitNotifications')
@@ -218,6 +216,11 @@ export async function maybeSendLimitThresholdEmail(params: {
218216
const percentUsed = Math.min(100, Math.round(percent))
219217
const upgradeLink = `${getBaseUrl()}${buildUpgradeHref(params.workspaceId, category)}`
220218

219+
const [{ getLimitEmailSubject, renderLimitThresholdEmail }, { sendEmail }] = await Promise.all([
220+
import('@/components/emails'),
221+
import('@/lib/messaging/email/mailer'),
222+
])
223+
221224
let sent = 0
222225
for (const r of recipients) {
223226
try {

apps/sim/lib/billing/core/usage.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,6 @@ import { createLogger } from '@sim/logger'
44
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
55
import { generateId } from '@sim/utils/id'
66
import { and, eq, isNull, sql } from 'drizzle-orm'
7-
import {
8-
getEmailSubject,
9-
getLimitEmailSubject,
10-
renderCreditsExhaustedEmail,
11-
renderFreeTierUpgradeEmail,
12-
renderUsageLimitReachedEmail,
13-
renderUsageThresholdEmail,
14-
} from '@/components/emails'
157
import { getEffectiveBillingStatus } from '@/lib/billing/core/access'
168
import { defaultBillingPeriod } from '@/lib/billing/core/billing-period'
179
import {
@@ -45,11 +37,23 @@ import { Decimal, toDecimal, toNumber } from '@/lib/billing/utils/decimal'
4537
import { isBillingEnabled } from '@/lib/core/config/env-flags'
4638
import { getBaseUrl } from '@/lib/core/utils/urls'
4739
import type { DbClient } from '@/lib/db/types'
48-
import { sendEmail } from '@/lib/messaging/email/mailer'
4940
import { getEmailPreferences } from '@/lib/messaging/email/unsubscribe'
5041

5142
const logger = createLogger('UsageManagement')
5243

44+
/**
45+
* Email rendering pulls the React templates and every mail provider into the
46+
* module graph, which is ~1.2s of imports on every route that reaches billing
47+
* attribution. Load it only when a threshold email is actually being sent.
48+
*/
49+
async function loadEmailDelivery() {
50+
const [emails, mailer] = await Promise.all([
51+
import('@/components/emails'),
52+
import('@/lib/messaging/email/mailer'),
53+
])
54+
return { ...emails, sendEmail: mailer.sendEmail }
55+
}
56+
5357
export interface OrgUsageLimitResult {
5458
limit: number
5559
minimum: number
@@ -773,6 +777,7 @@ export async function maybeSendUsageThresholdEmail(params: {
773777
const prefs = await getEmailPreferences(email)
774778
if (prefs?.unsubscribeAll || prefs?.unsubscribeNotifications) return
775779

780+
const { renderUsageThresholdEmail, getEmailSubject, sendEmail } = await loadEmailDelivery()
776781
const html = await renderUsageThresholdEmail({
777782
userName: name,
778783
planName: params.planName,
@@ -798,6 +803,7 @@ export async function maybeSendUsageThresholdEmail(params: {
798803
const prefs = await getEmailPreferences(email)
799804
if (prefs?.unsubscribeAll || prefs?.unsubscribeNotifications) return
800805

806+
const { renderFreeTierUpgradeEmail, getEmailSubject, sendEmail } = await loadEmailDelivery()
801807
const html = await renderFreeTierUpgradeEmail({
802808
userName: name,
803809
percentUsed: Math.min(100, Math.round(params.percentAfter)),
@@ -830,6 +836,13 @@ export async function maybeSendUsageThresholdEmail(params: {
830836
const prefs = await getEmailPreferences(email)
831837
if (prefs?.unsubscribeAll || prefs?.unsubscribeNotifications) return
832838

839+
const {
840+
renderCreditsExhaustedEmail,
841+
renderUsageLimitReachedEmail,
842+
getEmailSubject,
843+
getLimitEmailSubject,
844+
sendEmail,
845+
} = await loadEmailDelivery()
833846
const html = useFreeCopy
834847
? await renderCreditsExhaustedEmail({
835848
userName: name,

apps/sim/lib/catalog/projection/catalog-sweep.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ import { getBlockRegistry } from '@/blocks/registry'
4141
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
4242
import { getToolIds } from '@/tools/tool-ids'
4343

44+
/**
45+
* Asserts real tool params and outputs, which the global `@/tools/metadata`
46+
* and `@/tools/metadata-outputs` mocks in vitest.setup.ts empty.
47+
*/
48+
vi.unmock('@/tools/metadata')
49+
vi.unmock('@/tools/metadata-outputs')
50+
4451
/** Hosted deployment: the state under which every declared hosted key is published. */
4552
const HOSTED: CatalogDeployment = { hostedKeys: true }
4653

apps/sim/lib/copilot/request/tools/executor.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,8 @@ async function executeToolAndReportInner(
505505
})
506506
}
507507

508+
// Loads the handler map on first use; the abort check below covers that wait.
509+
await ensureHandlersRegistered()
508510
if (abortRequested(context, execContext, options)) {
509511
markToolCallCancelled('Request aborted before tool execution')
510512
markToolResultSeen(toolCall.id)
@@ -608,7 +610,6 @@ async function executeToolAndReportInner(
608610
}
609611

610612
try {
611-
ensureHandlersRegistered()
612613
let result = await executeToolWithWatchdog(toolCall, toolExecutionContext)
613614
if (toolCall.endTime || isTerminalToolCallStatus(toolCall.status)) {
614615
endToolSpanFromTerminalState()

0 commit comments

Comments
 (0)