Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

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

- **断言的内容落在 `React.lazy` / 动态 `import()` 边界之后 → 在测试文件的模块作用域直接 `import` 该模块**(`import '@object-ui/plugin-charts';` + 一行注释说明原因)。成本进入 import 阶段,**不受任何 test/hook 超时约束**。specifier 必须与被测组件里的**完全一致** —— ESM 按解析后的 specifier 缓存,这样组件自己的 `React.lazy` 工厂才会立刻 resolve。
- **不要用 `beforeAll` 预热**:它受 `hookTimeout`(**10s**)约束,比它取代的 `testTimeout`(**15s**)**更窄**,那只是把问题挪个窝。
- **不要用 `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 时存在的状态,提到模块作用域反而会改坏测试)。
- **禁止**用「调高超时」或「把文件塞进 `vitest.config.mts` 的 `heavyDomTests`」来修 flaky —— 两者都只是把竞态藏起来。`heavyDomTests` 只用于 registry「`<type>` not registered」这类失败。
- 失败现场会直接指认根因:dump 里若仍是 Suspense fallback(如 `Loading report renderer…`),就是本条;**hook 超时表现为「失败的*文件* + 0 个失败*测试*」**(其余全部 skipped),别误读成断言失败。
- 顺手体检:别把 `keysOf(x)` 这类整体计算写在 `.filter()` 谓词里(每个元素重算一遍)。`all-locales-key-parity` 曾因此 7.51s,提升出谓词后 **25ms**。
Expand Down
2 changes: 2 additions & 0 deletions eslint-rules/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
import noSyntheticEventTrigger from './no-synthetic-event-trigger.js';
import noInlineSpecConfig from './no-inline-spec-config.js';
import noTryCatchAroundHook from './no-try-catch-around-hook.js';
import noDynamicImportInTestHook from './no-dynamic-import-in-test-hook.js';

export default {
rules: {
'no-synthetic-event-trigger': noSyntheticEventTrigger,
'no-inline-spec-config': noInlineSpecConfig,
'no-try-catch-around-hook': noTryCatchAroundHook,
'no-dynamic-import-in-test-hook': noDynamicImportInTestHook,
},
};
143 changes: 143 additions & 0 deletions eslint-rules/no-dynamic-import-in-test-hook.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/**
* ObjectUI ESLint rule: no-dynamic-import-in-test-hook
*
* Bans `await import(…)` in a `beforeAll`/`beforeEach` body. Import the module
* at module scope instead.
*
* A dynamic import inside a hook bills the module's cold Vite transform to
* `hookTimeout`. That transform is unbounded work — under full-suite
* parallelism the transform pipeline is saturated (the `dom-heavy` project
* alone spends ~60s in transform) — so the hook's budget is really a bet on
* machine load, and the test's reliability stops depending on the code it
* covers. Imported at module scope the same cost lands in the file's import
* phase, which no test or hook timeout applies to.
*
* This exists because raising the timeout is the intuitive "fix" and it does
* not work. objectui#3010 found five load-sensitive flaky tests of this shape;
* a sweep then found 36 more files, EVERY one already carrying a raised
* timeout, on an escalating ladder — 15s → 30s → 60s. One of them even left
* the escalation as advice (`// Increase timeout to 30 seconds for heavy
* renderer imports`), and `plugin-kanban` blew its raised 15s budget anyway at
* 15021ms. #3021 converted the four riskiest. The prose rule in AGENTS.md §9
* did not hold on its own, which is why this is mechanical.
*
* Scope, deliberately narrow to stay false-positive free:
* - Only `beforeAll` / `beforeEach` as BARE identifiers. A `foo.beforeAll()`
* member call is somebody else's API.
* - The hook frame RESETS inside a nested function, so a lazy FACTORY —
* `registerLazy('x', () => import('./x'))` — is fine: the hook registers
* the loader, it does not run the import.
* - A hook that also establishes per-run module state (`vi.resetModules`,
* `vi.doMock`, `vi.doUnmock`, `vi.stubEnv`, `vi.stubGlobal`) is EXEMPT. The
* re-import is the point there — it has to observe state that only exists
* at hook time, so hoisting it would break the test rather than speed it up.
*
* No autofixer on purpose. The mechanical cases are 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 — not a
* rewrite a fixer should make unattended.
*
* @type {import('eslint').Rule.RuleModule}
*/

/** Vitest lifecycle hooks whose body is billed to `hookTimeout`. */
const HOOKS = new Set(['beforeAll', 'beforeEach']);

/**
* `vi.*` calls that establish per-run module state. A dynamic import in the
* same hook is deliberately re-reading that state, so it cannot be hoisted.
*/
const MODULE_STATE_HELPERS = new Set([
'resetModules',
'doMock',
'doUnmock',
'stubEnv',
'stubGlobal',
]);

/** True for `beforeAll(fn)` / `beforeEach(fn, timeout)` — bare identifier only. */
function isHookCall(node) {
return (
node?.type === 'CallExpression' &&
node.callee.type === 'Identifier' &&
HOOKS.has(node.callee.name)
);
}

/** True for `vi.resetModules()` / `vitest.doMock(…)` and friends. */
function isModuleStateHelper(node) {
const callee = node.callee;
return (
callee.type === 'MemberExpression' &&
callee.object.type === 'Identifier' &&
(callee.object.name === 'vi' || callee.object.name === 'vitest') &&
callee.property.type === 'Identifier' &&
MODULE_STATE_HELPERS.has(callee.property.name)
);
}

export default {
meta: {
type: 'problem',
docs: {
description:
'Disallow `await import(…)` inside a beforeAll/beforeEach body — it bills an unbounded module transform to hookTimeout, making the test load-sensitive (objectui#3010/#3021).',
recommended: true,
},
schema: [],
messages: {
banned:
"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.",
},
},
create(context) {
// One frame per enclosing function. `hook` is the hook name when this
// function is *directly* the hook's callback, else undefined — so the frame
// resets inside a nested function and a lazy factory never reports.
const frames = [{ hook: undefined, candidates: [], exempt: false }];
const top = () => frames[frames.length - 1];

return {
':function'(node) {
const parent = node.parent;
const hook = isHookCall(parent) && parent.arguments.includes(node)
? parent.callee.name
: undefined;
frames.push({ hook, candidates: [], exempt: false });
},

':function:exit'() {
const frame = frames.pop();
if (!frame.hook || frame.exempt) return;
for (const { node, source } of frame.candidates) {
context.report({
node,
messageId: 'banned',
data: { hook: frame.hook, source },
});
}
},

// `import('…')` — parsers spell this `ImportExpression`.
ImportExpression(node) {
const frame = top();
if (!frame.hook) return;
frame.candidates.push({
node,
source: node.source?.type === 'Literal' ? String(node.source.value) : 'the-module',
});
},

CallExpression(node) {
// Mark the whole hook exempt, wherever in its body the helper sits.
if (!isModuleStateHelper(node)) return;
for (let i = frames.length - 1; i >= 0; i -= 1) {
if (frames[i].hook) {
frames[i].exempt = true;
return;
}
}
},
};
},
};
94 changes: 94 additions & 0 deletions eslint-rules/no-dynamic-import-in-test-hook.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* Pins `no-dynamic-import-in-test-hook` against the shapes that must NOT report,
* every one of them real code in this repo:
*
* - `registerLazy('x', () => import('./x'))` inside a hook — the hook installs
* a loader, it never runs the import. This is how the console registers its
* plugin layer, so a rule that flagged it would be unusable.
* - `vi.resetModules(); mod = await import('./x')` — the re-import is the whole
* point; hoisting it would break the test rather than speed it up.
* - a dynamic import at module scope, or in a test body — only hook bodies
* carry `hookTimeout`.
*
* The invalid cases are the exact shapes objectui#3010/#3021 converted,
* including the `}, 15000)` form that raising the timeout failed to fix.
*/
import { describe, it, afterAll } from 'vitest';
import { RuleTester } from 'eslint';
import rule from './no-dynamic-import-in-test-hook.js';

RuleTester.afterAll = afterAll;
RuleTester.it = it;
RuleTester.describe = describe;

const ruleTester = new RuleTester();

ruleTester.run('no-dynamic-import-in-test-hook', rule, {
valid: [
// The correct shape: module scope, no hook, no raised timeout.
`import './index';
describe('Plugin', () => { it('registers', () => { expect(1).toBe(1); }); });`,
// A lazy FACTORY passed to a registrar: the import is never called here.
`beforeAll(() => { registerLazy('object-chart', () => import('@object-ui/plugin-charts')); });`,
// Same, one nesting level deeper.
`beforeAll(() => { tags.forEach((t) => registerLazy(t, () => import('./' + t))); });`,
// Deliberate re-import against per-run module state — cannot be hoisted.
`beforeEach(async () => { vi.resetModules(); mod = await import('./thing'); });`,
`beforeAll(async () => { vi.doMock('./dep', () => ({})); mod = await import('./thing'); });`,
`beforeEach(async () => { vi.stubEnv('TZ', 'UTC'); mod = await import('./clock'); });`,
// The helper may sit after the import and still exempt the hook.
`beforeEach(async () => { mod = await import('./thing'); vi.resetModules(); });`,
// Module scope is exactly what the rule wants.
`const mod = await import('./thing');`,
// A test body is not a hook body.
`it('loads on demand', async () => { const m = await import('./thing'); expect(m).toBeTruthy(); });`,
// Other hooks are not billed the warm-up cost this rule is about.
`afterAll(async () => { await import('./teardown'); });`,
// A member call that merely shares the name is somebody else's API.
`suite.beforeAll(async () => { await import('./thing'); });`,
// A hook with no dynamic import at all.
`beforeAll(() => { ComponentRegistry.reset(); });`,
],
invalid: [
// The shape #3021 converted, raised timeout and all.
{
code: `describe('Plugin Markdown', () => {
beforeAll(async () => { await import('./index'); }, 15000);
});`,
errors: [{ messageId: 'banned' }],
},
// The 30s `packages/components` cluster.
{
code: `beforeAll(async () => { await import('../../../renderers'); }, 30000);`,
errors: [{ messageId: 'banned' }],
},
// Capturing into a variable is still the same cost.
{
code: `let mod; beforeAll(async () => { mod = await import('./index'); });`,
errors: [{ messageId: 'banned' }],
},
// `beforeEach` pays it once per test, which is worse.
{
code: `beforeEach(async () => { await import('./index'); });`,
errors: [{ messageId: 'banned' }],
},
// Two imports in one hook report twice — each is a separate move.
{
code: `beforeAll(async () => {
await import('@object-ui/components');
await import('../register-plugins');
}, 60000);`,
errors: [{ messageId: 'banned' }, { messageId: 'banned' }],
},
// Concurrent loads are still loads.
{
code: `beforeAll(async () => { await Promise.all([import('./a'), import('./b')]); });`,
errors: [{ messageId: 'banned' }, { messageId: 'banned' }],
},
// An unrelated `vi.*` call must not buy an exemption.
{
code: `beforeAll(async () => { vi.useFakeTimers(); await import('./index'); });`,
errors: [{ messageId: 'banned' }],
},
],
});
14 changes: 14 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,20 @@ export default tseslint.config({
// this lints clean today.
'object-ui/no-try-catch-around-hook': 'error',
},
}, {
// objectui#3010/#3021 ratchet — a module loaded inside beforeAll/beforeEach
// bills its cold Vite transform to `hookTimeout`, so the test passes or fails
// on machine load. Raising the timeout is the intuitive fix and it does not
// work: all 37 files found this way already had a raised timeout, escalating
// 15s -> 30s -> 60s, and plugin-kanban blew its raised 15s anyway at 15021ms.
// Scoped to test files — a dynamic import in app code is normal code
// splitting. Error so a new one fails CI; every existing site was converted
// first, so this lints clean today.
files: ['**/*.test.{ts,tsx}', '**/__tests__/**/*.{ts,tsx}'],
plugins: { 'object-ui': objectUi },
rules: {
'object-ui/no-dynamic-import-in-test-hook': 'error',
},
}, {
// Type-discipline ratchet, scoped to the canonical view-schema file: a
// spec-backed view-config field must reference its @objectstack/spec type,
Expand Down
11 changes: 5 additions & 6 deletions packages/components/src/__tests__/basic-renderers.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,17 @@
* LICENSE file in the root directory of this source tree.
*/

import { describe, it, expect, beforeAll } from 'vitest';
import { describe, it, expect } from 'vitest';
import {
renderComponent,
validateComponentRegistration,
getAllDisplayIssues,
checkDOMStructure,
} from './test-utils';

// Import renderers to ensure registration
beforeAll(async () => {
await import('../renderers');
}, 30000); // Increase timeout to 30 seconds for heavy renderer imports
// Registers the renderers at module scope, NOT inside a `beforeAll` — there the
// cold transform is billed to `hookTimeout`, which is why this carried a raised
// timeout. See object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021).
import '../renderers';

/**
* Comprehensive tests for basic renderer components
Expand Down
10 changes: 5 additions & 5 deletions packages/components/src/__tests__/data-list.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@
* LICENSE file in the root directory of this source tree.
*/

import { describe, it, expect, beforeAll } from 'vitest';
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import React from 'react';
import { ComponentRegistry } from '@object-ui/core';

beforeAll(async () => {
await import('../renderers');
}, 30000);
// Registers the renderers at module scope, NOT inside a `beforeAll` — there the
// cold transform is billed to `hookTimeout`, which is why this carried a raised
// timeout. See object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021).
import '../renderers';

function renderType(schema: any) {
const Component = ComponentRegistry.get(schema.type);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,15 @@
* date columns could only be typed by hand. A `type: 'date'` column must
* render a native date picker (<input type="date">).
*/
import { describe, it, expect, vi, beforeAll } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';
import { renderComponent } from './test-utils';

beforeAll(async () => {
await import('../renderers');
}, 30000);
// Registers the renderers at module scope, NOT inside a `beforeAll` — there the
// cold transform is billed to `hookTimeout`, which is why this carried a raised
// timeout. See object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021).
import '../renderers';

const editableSchema = {
type: 'data-table' as const,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@
* navigation is reported via `onPageChange` instead of mutating internal state.
* This is what lets a grid reach records beyond the first batch.
*/
import { describe, it, expect, vi, beforeAll } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { renderComponent } from './test-utils';

beforeAll(async () => {
await import('../renderers');
}, 30000);
// Registers the renderers at module scope, NOT inside a `beforeAll` — there the
// cold transform is billed to `hookTimeout`, which is why this carried a raised
// timeout. See object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021).
import '../renderers';

describe('data-table — manual (server-side) pagination', () => {
// 5 rows that represent page 2 of a 3125-row result set, page size 50.
Expand Down
10 changes: 5 additions & 5 deletions packages/components/src/__tests__/data-table-row-click.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,15 @@
* page because the click event bubbled up through React's synthetic tree to
* the <TableRow> onClick handler which calls onRowClick.
*/
import { describe, it, expect, vi, beforeAll } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';
import { renderComponent } from './test-utils';

beforeAll(async () => {
await import('../renderers');
}, 30000);
// Registers the renderers at module scope, NOT inside a `beforeAll` — there the
// cold transform is billed to `hookTimeout`, which is why this carried a raised
// timeout. See object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021).
import '../renderers';

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