Skip to content

Commit b0e721e

Browse files
os-zhuangclaude
andcommitted
feat(eslint): ban dynamic imports in test hooks, and convert the last 33 sites
The prose rule added in #3015 did not hold on its own, and there is hard evidence for that: sweeping for `beforeAll(async () => { await import(…) })` found 37 files, and EVERY one already carried a raised timeout, on an escalating ladder — 15s -> 30s -> 60s. One even left the escalation as advice (`// Increase timeout to 30 seconds for heavy renderer imports`), and plugin-kanban blew its raised 15s anyway at 15021ms (#3010). A convention that gets re-broken 37 times is a lint rule. Adds `object-ui/no-dynamic-import-in-test-hook` (error, scoped to test files): a module loaded inside `beforeAll`/`beforeEach` bills its cold Vite transform to `hookTimeout`, so the test passes or fails on machine load rather than on the code it covers. Imported at module scope the same cost lands in the import phase, which no test or hook timeout applies to. Kept deliberately narrow, with both exemptions pinned in the RuleTester because both are legitimate and both are real code here: - a lazy FACTORY — `registerLazy('x', () => import('./x'))` — the hook installs a loader, it never runs the import. The hook frame resets inside a nested function, so this never reports. (Without this the rule would flag the console's own plugin registration and be unusable.) - a deliberate re-import against per-run module state (`vi.resetModules`, `doMock`, `doUnmock`, `stubEnv`, `stubGlobal`) — there the re-import is the point, and hoisting it would break the test rather than speed it up. No autofixer on purpose: the mechanical case is a one-line move, but the general case captures the module into a variable the tests read, and hoisting that means introducing top-level await and reordering side effects. Because an `error` ratchet has to lint clean today (the convention the other three rules follow), this also converts the 33 remaining sites — 30 in packages/components, 3 in plugin-dashboard. 27 were the canonical pure warm-up. The other 6 also register overrides that must run AFTER the barrel, so only the import moved out; those keep a (now sync, now untimed) hook, and the ordering still holds because static imports are evaluated before any hook. The side-effect import goes after the last existing import — the faithful equivalent of a hook that ran once every static import had been evaluated. Verified: - rule: 19 RuleTester cases (12 valid / 7 invalid); a fresh violation fails lint as an error with the exact fix in the message. - repo sweep: 33 violations -> 0. The only remaining error-level lint findings are 3 pre-existing ones under `e2e/`, which CI's per-package `turbo run lint` does not cover; untouched here. - tests: the 33 converted files pass (209 tests), and the full suite is 100% green — 733 files passed / 1 skipped, 8552 tests passed / 24 skipped. - the suite's cumulative timed portion dropped 243s -> 127s. Wall clock is unchanged (~229s): the work did not disappear, it moved out of timed windows into the import phase, which is the point. AGENTS.md §9 now points at the rule instead of only describing the convention, including both exemptions so the next agent does not fight the linter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 2baa13f commit b0e721e

38 files changed

Lines changed: 428 additions & 162 deletions

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ export const SchemaRenderer = ({ schema }: { schema: UIComponent }) => {
154154
单跑稳定绿、全量并行下偶发红的测试,**根因几乎总是同一个**:一段**无界的模块加载被计入了一个有界的窗口**。满并行下 Vite 的 transform 管线是饱和的(单 `dom-heavy` 项目就 ~60s transform),实测一次首包 `import()` 可达 **976ms** —— 已吃掉 RTL `findBy`/`waitFor` 默认 **1000ms** 预算的 97.6%。于是断言在和模块加载器抢时间,红绿取决于机器负载而不是被测代码。
155155

156156
- **断言的内容落在 `React.lazy` / 动态 `import()` 边界之后 → 在测试文件的模块作用域直接 `import` 该模块**(`import '@object-ui/plugin-charts';` + 一行注释说明原因)。成本进入 import 阶段,**不受任何 test/hook 超时约束**。specifier 必须与被测组件里的**完全一致** —— ESM 按解析后的 specifier 缓存,这样组件自己的 `React.lazy` 工厂才会立刻 resolve。
157-
- **不要用 `beforeAll` 预热**:它受 `hookTimeout`(**10s**)约束,比它取代的 `testTimeout`(**15s**)**更窄**,那只是把问题挪个窝。
157+
- **不要用 `beforeAll` 预热**:它受 `hookTimeout`(**10s**)约束,比它取代的 `testTimeout`(**15s**)**更窄**,那只是把问题挪个窝。**这一条现在由 lint 机械强制** —— `object-ui/no-dynamic-import-in-test-hook`(error)禁止在 `beforeAll`/`beforeEach` 体内 `await import(…)`。两种写法**不会**被它拦(都是正当的,已在规则的 RuleTester 里钉住):传给注册器的**惰性工厂**(`registerLazy('x', () => import('./x'))` —— hook 只是登记 loader,并不执行导入);以及同一 hook 里调了 `vi.resetModules()`/`doMock`/`stubEnv`/`stubGlobal`**故意重导入**(它必须读取只在 hook 时存在的状态,提到模块作用域反而会改坏测试)。
158158
- **禁止**用「调高超时」或「把文件塞进 `vitest.config.mts``heavyDomTests`」来修 flaky —— 两者都只是把竞态藏起来。`heavyDomTests` 只用于 registry「`<type>` not registered」这类失败。
159159
- 失败现场会直接指认根因:dump 里若仍是 Suspense fallback(如 `Loading report renderer…`),就是本条;**hook 超时表现为「失败的*文件* + 0 个失败*测试***(其余全部 skipped),别误读成断言失败。
160160
- 顺手体检:别把 `keysOf(x)` 这类整体计算写在 `.filter()` 谓词里(每个元素重算一遍)。`all-locales-key-parity` 曾因此 7.51s,提升出谓词后 **25ms**

eslint-rules/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
import noSyntheticEventTrigger from './no-synthetic-event-trigger.js';
55
import noInlineSpecConfig from './no-inline-spec-config.js';
66
import noTryCatchAroundHook from './no-try-catch-around-hook.js';
7+
import noDynamicImportInTestHook from './no-dynamic-import-in-test-hook.js';
78

89
export default {
910
rules: {
1011
'no-synthetic-event-trigger': noSyntheticEventTrigger,
1112
'no-inline-spec-config': noInlineSpecConfig,
1213
'no-try-catch-around-hook': noTryCatchAroundHook,
14+
'no-dynamic-import-in-test-hook': noDynamicImportInTestHook,
1315
},
1416
};
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/**
2+
* ObjectUI ESLint rule: no-dynamic-import-in-test-hook
3+
*
4+
* Bans `await import(…)` in a `beforeAll`/`beforeEach` body. Import the module
5+
* at module scope instead.
6+
*
7+
* A dynamic import inside a hook bills the module's cold Vite transform to
8+
* `hookTimeout`. That transform is unbounded work — under full-suite
9+
* parallelism the transform pipeline is saturated (the `dom-heavy` project
10+
* alone spends ~60s in transform) — so the hook's budget is really a bet on
11+
* machine load, and the test's reliability stops depending on the code it
12+
* covers. Imported at module scope the same cost lands in the file's import
13+
* phase, which no test or hook timeout applies to.
14+
*
15+
* This exists because raising the timeout is the intuitive "fix" and it does
16+
* not work. objectui#3010 found five load-sensitive flaky tests of this shape;
17+
* a sweep then found 36 more files, EVERY one already carrying a raised
18+
* timeout, on an escalating ladder — 15s → 30s → 60s. One of them even left
19+
* the escalation as advice (`// Increase timeout to 30 seconds for heavy
20+
* renderer imports`), and `plugin-kanban` blew its raised 15s budget anyway at
21+
* 15021ms. #3021 converted the four riskiest. The prose rule in AGENTS.md §9
22+
* did not hold on its own, which is why this is mechanical.
23+
*
24+
* Scope, deliberately narrow to stay false-positive free:
25+
* - Only `beforeAll` / `beforeEach` as BARE identifiers. A `foo.beforeAll()`
26+
* member call is somebody else's API.
27+
* - The hook frame RESETS inside a nested function, so a lazy FACTORY —
28+
* `registerLazy('x', () => import('./x'))` — is fine: the hook registers
29+
* the loader, it does not run the import.
30+
* - A hook that also establishes per-run module state (`vi.resetModules`,
31+
* `vi.doMock`, `vi.doUnmock`, `vi.stubEnv`, `vi.stubGlobal`) is EXEMPT. The
32+
* re-import is the point there — it has to observe state that only exists
33+
* at hook time, so hoisting it would break the test rather than speed it up.
34+
*
35+
* No autofixer on purpose. The mechanical cases are a one-line move, but the
36+
* general case captures the module into a variable the tests read, and hoisting
37+
* that means introducing top-level await and reordering side effects — not a
38+
* rewrite a fixer should make unattended.
39+
*
40+
* @type {import('eslint').Rule.RuleModule}
41+
*/
42+
43+
/** Vitest lifecycle hooks whose body is billed to `hookTimeout`. */
44+
const HOOKS = new Set(['beforeAll', 'beforeEach']);
45+
46+
/**
47+
* `vi.*` calls that establish per-run module state. A dynamic import in the
48+
* same hook is deliberately re-reading that state, so it cannot be hoisted.
49+
*/
50+
const MODULE_STATE_HELPERS = new Set([
51+
'resetModules',
52+
'doMock',
53+
'doUnmock',
54+
'stubEnv',
55+
'stubGlobal',
56+
]);
57+
58+
/** True for `beforeAll(fn)` / `beforeEach(fn, timeout)` — bare identifier only. */
59+
function isHookCall(node) {
60+
return (
61+
node?.type === 'CallExpression' &&
62+
node.callee.type === 'Identifier' &&
63+
HOOKS.has(node.callee.name)
64+
);
65+
}
66+
67+
/** True for `vi.resetModules()` / `vitest.doMock(…)` and friends. */
68+
function isModuleStateHelper(node) {
69+
const callee = node.callee;
70+
return (
71+
callee.type === 'MemberExpression' &&
72+
callee.object.type === 'Identifier' &&
73+
(callee.object.name === 'vi' || callee.object.name === 'vitest') &&
74+
callee.property.type === 'Identifier' &&
75+
MODULE_STATE_HELPERS.has(callee.property.name)
76+
);
77+
}
78+
79+
export default {
80+
meta: {
81+
type: 'problem',
82+
docs: {
83+
description:
84+
'Disallow `await import(…)` inside a beforeAll/beforeEach body — it bills an unbounded module transform to hookTimeout, making the test load-sensitive (objectui#3010/#3021).',
85+
recommended: true,
86+
},
87+
schema: [],
88+
messages: {
89+
banned:
90+
"Do not load a module inside '{{hook}}' — the cold transform is billed to hookTimeout, so the test passes or fails on machine load. Import it at module scope instead (`import '{{source}}';`), which moves the cost to the import phase where no test or hook timeout applies. Raising the timeout is not the fix: every one of the 36 files found this way already had a raised timeout, escalating 15s -> 30s -> 60s (objectui#3010/#3021, AGENTS.md section 9). If the import must re-read per-run state, keep it and call vi.resetModules() in the same hook.",
91+
},
92+
},
93+
create(context) {
94+
// One frame per enclosing function. `hook` is the hook name when this
95+
// function is *directly* the hook's callback, else undefined — so the frame
96+
// resets inside a nested function and a lazy factory never reports.
97+
const frames = [{ hook: undefined, candidates: [], exempt: false }];
98+
const top = () => frames[frames.length - 1];
99+
100+
return {
101+
':function'(node) {
102+
const parent = node.parent;
103+
const hook = isHookCall(parent) && parent.arguments.includes(node)
104+
? parent.callee.name
105+
: undefined;
106+
frames.push({ hook, candidates: [], exempt: false });
107+
},
108+
109+
':function:exit'() {
110+
const frame = frames.pop();
111+
if (!frame.hook || frame.exempt) return;
112+
for (const { node, source } of frame.candidates) {
113+
context.report({
114+
node,
115+
messageId: 'banned',
116+
data: { hook: frame.hook, source },
117+
});
118+
}
119+
},
120+
121+
// `import('…')` — parsers spell this `ImportExpression`.
122+
ImportExpression(node) {
123+
const frame = top();
124+
if (!frame.hook) return;
125+
frame.candidates.push({
126+
node,
127+
source: node.source?.type === 'Literal' ? String(node.source.value) : 'the-module',
128+
});
129+
},
130+
131+
CallExpression(node) {
132+
// Mark the whole hook exempt, wherever in its body the helper sits.
133+
if (!isModuleStateHelper(node)) return;
134+
for (let i = frames.length - 1; i >= 0; i -= 1) {
135+
if (frames[i].hook) {
136+
frames[i].exempt = true;
137+
return;
138+
}
139+
}
140+
},
141+
};
142+
},
143+
};
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* Pins `no-dynamic-import-in-test-hook` against the shapes that must NOT report,
3+
* every one of them real code in this repo:
4+
*
5+
* - `registerLazy('x', () => import('./x'))` inside a hook — the hook installs
6+
* a loader, it never runs the import. This is how the console registers its
7+
* plugin layer, so a rule that flagged it would be unusable.
8+
* - `vi.resetModules(); mod = await import('./x')` — the re-import is the whole
9+
* point; hoisting it would break the test rather than speed it up.
10+
* - a dynamic import at module scope, or in a test body — only hook bodies
11+
* carry `hookTimeout`.
12+
*
13+
* The invalid cases are the exact shapes objectui#3010/#3021 converted,
14+
* including the `}, 15000)` form that raising the timeout failed to fix.
15+
*/
16+
import { describe, it, afterAll } from 'vitest';
17+
import { RuleTester } from 'eslint';
18+
import rule from './no-dynamic-import-in-test-hook.js';
19+
20+
RuleTester.afterAll = afterAll;
21+
RuleTester.it = it;
22+
RuleTester.describe = describe;
23+
24+
const ruleTester = new RuleTester();
25+
26+
ruleTester.run('no-dynamic-import-in-test-hook', rule, {
27+
valid: [
28+
// The correct shape: module scope, no hook, no raised timeout.
29+
`import './index';
30+
describe('Plugin', () => { it('registers', () => { expect(1).toBe(1); }); });`,
31+
// A lazy FACTORY passed to a registrar: the import is never called here.
32+
`beforeAll(() => { registerLazy('object-chart', () => import('@object-ui/plugin-charts')); });`,
33+
// Same, one nesting level deeper.
34+
`beforeAll(() => { tags.forEach((t) => registerLazy(t, () => import('./' + t))); });`,
35+
// Deliberate re-import against per-run module state — cannot be hoisted.
36+
`beforeEach(async () => { vi.resetModules(); mod = await import('./thing'); });`,
37+
`beforeAll(async () => { vi.doMock('./dep', () => ({})); mod = await import('./thing'); });`,
38+
`beforeEach(async () => { vi.stubEnv('TZ', 'UTC'); mod = await import('./clock'); });`,
39+
// The helper may sit after the import and still exempt the hook.
40+
`beforeEach(async () => { mod = await import('./thing'); vi.resetModules(); });`,
41+
// Module scope is exactly what the rule wants.
42+
`const mod = await import('./thing');`,
43+
// A test body is not a hook body.
44+
`it('loads on demand', async () => { const m = await import('./thing'); expect(m).toBeTruthy(); });`,
45+
// Other hooks are not billed the warm-up cost this rule is about.
46+
`afterAll(async () => { await import('./teardown'); });`,
47+
// A member call that merely shares the name is somebody else's API.
48+
`suite.beforeAll(async () => { await import('./thing'); });`,
49+
// A hook with no dynamic import at all.
50+
`beforeAll(() => { ComponentRegistry.reset(); });`,
51+
],
52+
invalid: [
53+
// The shape #3021 converted, raised timeout and all.
54+
{
55+
code: `describe('Plugin Markdown', () => {
56+
beforeAll(async () => { await import('./index'); }, 15000);
57+
});`,
58+
errors: [{ messageId: 'banned' }],
59+
},
60+
// The 30s `packages/components` cluster.
61+
{
62+
code: `beforeAll(async () => { await import('../../../renderers'); }, 30000);`,
63+
errors: [{ messageId: 'banned' }],
64+
},
65+
// Capturing into a variable is still the same cost.
66+
{
67+
code: `let mod; beforeAll(async () => { mod = await import('./index'); });`,
68+
errors: [{ messageId: 'banned' }],
69+
},
70+
// `beforeEach` pays it once per test, which is worse.
71+
{
72+
code: `beforeEach(async () => { await import('./index'); });`,
73+
errors: [{ messageId: 'banned' }],
74+
},
75+
// Two imports in one hook report twice — each is a separate move.
76+
{
77+
code: `beforeAll(async () => {
78+
await import('@object-ui/components');
79+
await import('../register-plugins');
80+
}, 60000);`,
81+
errors: [{ messageId: 'banned' }, { messageId: 'banned' }],
82+
},
83+
// Concurrent loads are still loads.
84+
{
85+
code: `beforeAll(async () => { await Promise.all([import('./a'), import('./b')]); });`,
86+
errors: [{ messageId: 'banned' }, { messageId: 'banned' }],
87+
},
88+
// An unrelated `vi.*` call must not buy an exemption.
89+
{
90+
code: `beforeAll(async () => { vi.useFakeTimers(); await import('./index'); });`,
91+
errors: [{ messageId: 'banned' }],
92+
},
93+
],
94+
});

eslint.config.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,20 @@ export default tseslint.config({
6464
// this lints clean today.
6565
'object-ui/no-try-catch-around-hook': 'error',
6666
},
67+
}, {
68+
// objectui#3010/#3021 ratchet — a module loaded inside beforeAll/beforeEach
69+
// bills its cold Vite transform to `hookTimeout`, so the test passes or fails
70+
// on machine load. Raising the timeout is the intuitive fix and it does not
71+
// work: all 37 files found this way already had a raised timeout, escalating
72+
// 15s -> 30s -> 60s, and plugin-kanban blew its raised 15s anyway at 15021ms.
73+
// Scoped to test files — a dynamic import in app code is normal code
74+
// splitting. Error so a new one fails CI; every existing site was converted
75+
// first, so this lints clean today.
76+
files: ['**/*.test.{ts,tsx}', '**/__tests__/**/*.{ts,tsx}'],
77+
plugins: { 'object-ui': objectUi },
78+
rules: {
79+
'object-ui/no-dynamic-import-in-test-hook': 'error',
80+
},
6781
}, {
6882
// Type-discipline ratchet, scoped to the canonical view-schema file: a
6983
// spec-backed view-config field must reference its @objectstack/spec type,

packages/components/src/__tests__/basic-renderers.test.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,17 @@
66
* LICENSE file in the root directory of this source tree.
77
*/
88

9-
import { describe, it, expect, beforeAll } from 'vitest';
9+
import { describe, it, expect } from 'vitest';
1010
import {
1111
renderComponent,
1212
validateComponentRegistration,
1313
getAllDisplayIssues,
1414
checkDOMStructure,
1515
} from './test-utils';
16-
17-
// Import renderers to ensure registration
18-
beforeAll(async () => {
19-
await import('../renderers');
20-
}, 30000); // Increase timeout to 30 seconds for heavy renderer imports
16+
// Registers the renderers at module scope, NOT inside a `beforeAll` — there the
17+
// cold transform is billed to `hookTimeout`, which is why this carried a raised
18+
// timeout. See object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021).
19+
import '../renderers';
2120

2221
/**
2322
* Comprehensive tests for basic renderer components

packages/components/src/__tests__/data-list.test.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@
66
* LICENSE file in the root directory of this source tree.
77
*/
88

9-
import { describe, it, expect, beforeAll } from 'vitest';
9+
import { describe, it, expect } from 'vitest';
1010
import { render } from '@testing-library/react';
1111
import React from 'react';
1212
import { ComponentRegistry } from '@object-ui/core';
13-
14-
beforeAll(async () => {
15-
await import('../renderers');
16-
}, 30000);
13+
// Registers the renderers at module scope, NOT inside a `beforeAll` — there the
14+
// cold transform is billed to `hookTimeout`, which is why this carried a raised
15+
// timeout. See object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021).
16+
import '../renderers';
1717

1818
function renderType(schema: any) {
1919
const Component = ComponentRegistry.get(schema.type);

packages/components/src/__tests__/data-table-inline-edit.test.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,15 @@
1818
* date columns could only be typed by hand. A `type: 'date'` column must
1919
* render a native date picker (<input type="date">).
2020
*/
21-
import { describe, it, expect, vi, beforeAll } from 'vitest';
21+
import { describe, it, expect, vi } from 'vitest';
2222
import { fireEvent, waitFor } from '@testing-library/react';
2323
import '@testing-library/jest-dom';
2424
import React from 'react';
2525
import { renderComponent } from './test-utils';
26-
27-
beforeAll(async () => {
28-
await import('../renderers');
29-
}, 30000);
26+
// Registers the renderers at module scope, NOT inside a `beforeAll` — there the
27+
// cold transform is billed to `hookTimeout`, which is why this carried a raised
28+
// timeout. See object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021).
29+
import '../renderers';
3030

3131
const editableSchema = {
3232
type: 'data-table' as const,

packages/components/src/__tests__/data-table-manual-pagination.test.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,14 @@
1515
* navigation is reported via `onPageChange` instead of mutating internal state.
1616
* This is what lets a grid reach records beyond the first batch.
1717
*/
18-
import { describe, it, expect, vi, beforeAll } from 'vitest';
18+
import { describe, it, expect, vi } from 'vitest';
1919
import { fireEvent } from '@testing-library/react';
2020
import '@testing-library/jest-dom';
2121
import { renderComponent } from './test-utils';
22-
23-
beforeAll(async () => {
24-
await import('../renderers');
25-
}, 30000);
22+
// Registers the renderers at module scope, NOT inside a `beforeAll` — there the
23+
// cold transform is billed to `hookTimeout`, which is why this carried a raised
24+
// timeout. See object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021).
25+
import '../renderers';
2626

2727
describe('data-table — manual (server-side) pagination', () => {
2828
// 5 rows that represent page 2 of a 3125-row result set, page size 50.

packages/components/src/__tests__/data-table-row-click.test.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,15 @@
1616
* page because the click event bubbled up through React's synthetic tree to
1717
* the <TableRow> onClick handler which calls onRowClick.
1818
*/
19-
import { describe, it, expect, vi, beforeAll } from 'vitest';
19+
import { describe, it, expect, vi } from 'vitest';
2020
import { fireEvent } from '@testing-library/react';
2121
import '@testing-library/jest-dom';
2222
import React from 'react';
2323
import { renderComponent } from './test-utils';
24-
25-
beforeAll(async () => {
26-
await import('../renderers');
27-
}, 30000);
24+
// Registers the renderers at module scope, NOT inside a `beforeAll` — there the
25+
// cold transform is billed to `hookTimeout`, which is why this carried a raised
26+
// timeout. See object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021).
27+
import '../renderers';
2828

2929
describe('data-table — row click heuristic ignores overlay items', () => {
3030
const baseSchema = {

0 commit comments

Comments
 (0)