diff --git a/.changeset/audit-test-static-imports.md b/.changeset/audit-test-static-imports.md new file mode 100644 index 0000000000..49a5143998 --- /dev/null +++ b/.changeset/audit-test-static-imports.md @@ -0,0 +1,23 @@ +--- +"@objectstack/plugin-audit": patch +--- + +fix(plugin-audit): the localized-summary tests stop charging a cold module load to one test's timeout (#4186) + +`audit-writers.test.ts` resolved `@objectstack/core` and its translation +bundle with `await import(...)` inside the first localized test's helper, so +that single test paid the whole cold-start cost — resolution plus vite +transform of a large barrel — while every later case ran warmed in ~1ms. + +That cost is real work being billed to a per-test timeout budget. The file +already carried a `{ timeout: 20_000 }` override for exactly this reason (its +comment measured the cold start at ~5s on a 4-vCPU runner). Under a full-repo +`pnpm test`, where a dozen packages' vitest workers compete, the cold start +grew past that bound too and the case failed at 20s — reproducibly in CI-like +load, never in isolation, which is the worst shape a red test can have: it +tracks machine load rather than code. + +Both imports are now static. The same work happens during collection, which no +single test's timeout is charged for, so the previously failing case runs in +1ms and the timeout override is gone — the default timeout is now an honest +bound, and a case that exceeds it is a real hang rather than a slow import. diff --git a/packages/plugins/plugin-audit/src/audit-writers.test.ts b/packages/plugins/plugin-audit/src/audit-writers.test.ts index 40c5b79179..297b3d8b79 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.test.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.test.ts @@ -2,6 +2,16 @@ import { describe, it, expect } from 'vitest'; import { installAuditWriters } from './audit-writers.js'; +// STATIC on purpose (#4186). These were `await import(...)` inside the first +// localized test's helper, so that one test paid the whole cold-start cost of +// resolving + transforming @objectstack/core and the translation bundle, while +// every later case ran warmed in ~1ms. That is a per-test timeout budget spent +// on module loading: it grew past the 20s override this file used to carry and +// failed the suite under parallel load. As module-level imports the same work +// happens during collection, which no single test's timeout is charged for. +// Do not make these lazy again. +import { createMemoryI18n } from '@objectstack/core'; +import { AuditTranslations } from './translations/index.js'; /** * Regression coverage for #1532 — on single-tenant stacks the @@ -530,20 +540,15 @@ describe('audit writers — update diff hygiene (objectui detail-history report) }); }); -// timeout: the FIRST localized case pays the one-off cost of dynamically -// importing @objectstack/core + the shipped translation bundle (and, with the -// #3071 src aliases, their vite transforms). On a shared 4-vCPU CI runner that -// cold start alone was measured at ~5s — right at vitest's default timeout — -// while every warmed case runs in ~1ms. 20s bounds the cold start without -// masking a real hang. -describe('audit writers — localized activity summaries (framework#3039)', { timeout: 20_000 }, () => { +// No timeout override: the cold-start cost that used to need one moved to this +// file's static imports (#4186). Every case here runs in ~1ms, so the default +// timeout is the honest bound — a case that exceeds it is a real hang. +describe('audit writers — localized activity summaries (framework#3039)', () => { // Real memory i18n (what the kernel registers as the 'i18n' fallback) loaded // with this plugin's shipped bundle plus an app-contributed object label — // exercises the actual key shapes (`messages.activityCreated`, // `objects.{name}.label`) end to end. - async function makeI18n() { - const { createMemoryI18n } = await import('@objectstack/core'); - const { AuditTranslations } = await import('./translations/index.js'); + function makeI18n() { const i18n = createMemoryI18n(); for (const [locale, data] of Object.entries(AuditTranslations)) { i18n.loadTranslations(locale, data as Record); @@ -580,7 +585,7 @@ describe('audit writers — localized activity summaries (framework#3039)', { ti }); it('localizes verb + object label to the workspace locale (zh-CN)', async () => { - const { fire, created } = setup('zh-CN', await makeI18n()); + const { fire, created } = setup('zh-CN', makeI18n()); await fire('afterInsert', insertCtx()); await fire('afterDelete', { ...insertCtx(), result: null, __previous: { id: 'q-1', name: 'OC-00001' } }); @@ -590,7 +595,7 @@ describe('audit writers — localized activity summaries (framework#3039)', { ti }); it('localizes the generic update fallback', async () => { - const { fire, created } = setup('zh-CN', await makeI18n()); + const { fire, created } = setup('zh-CN', makeI18n()); await fire('afterUpdate', { ...insertCtx(), __previous: { id: 'q-1', name: 'OC-00001', status: 'draft' }, @@ -605,7 +610,7 @@ describe('audit writers — localized activity summaries (framework#3039)', { ti // localized, label falls back to the authored def label. const { fire, created } = setup( 'zh-CN', - await makeI18n(), + makeI18n(), { crm_lead: { label: 'Lead' } }, { ...SINGLE_TENANT, crm_lead: ['id', 'name'] }, ); @@ -631,7 +636,7 @@ describe('audit writers — localized activity summaries (framework#3039)', { ti }); it('memoizes the locale lookup per tenant/user scope (hot-path guard)', async () => { - const { fire, localeCalls } = setup('zh-CN', await makeI18n()); + const { fire, localeCalls } = setup('zh-CN', makeI18n()); await fire('afterInsert', insertCtx()); await fire('afterInsert', { ...insertCtx(), result: { id: 'q-2', name: 'OC-00002' } }); expect(localeCalls()).toBe(1); @@ -662,7 +667,7 @@ describe('audit writers — localized activity summaries (framework#3039)', { ti } it('localizes the @mention notification title to the recipient locale', async () => { - const { fire, emits } = setupWithMessaging('zh-CN', await makeI18n()); + const { fire, emits } = setupWithMessaging('zh-CN', makeI18n()); await fire('afterInsert', { object: 'sys_comment', input: {},