从 PR #7514 被合并队列以 CI_FAILURE 踢出而查出。⚠️ 这不是那个 PR 的失败 —— 它删的是 packages/components/src/SchemaRenderer.tsx,与 plugin-timeline 无关。这是一条真实的竞态,不是 flake,而且它现在正在阻塞合并队列。
定 p1:失败模式是随机踢掉任意 PR 的队列条目,与该 PR 的内容无关。承重性,不是严重性。
实测的失败
合并队列分支 gh-readonly-queue/main/pr-7514-fe4e7a9e8…,workflow run 33776772672,job Test (shard 3/4)。615 个测试文件里只有 1 个红,8048 通过:
FAIL dom packages/plugin-timeline/src/ObjectTimeline.colorFieldLadder-7243.test.tsx
> objectui#7243 — timeline colorField ladder (control: green before and after)
> rung 2: 3- and 6-digit hex literals pass through
AssertionError: expected [ '#abc' ] to deeply equal [ '#123456' ]
- Expected "#123456"
+ Received "#abc"
❯ ObjectTimeline.colorFieldLadder-7243.test.tsx:109:72
⭐ 第二个断言收到了第一个断言的值。 这不是「颜色算错了」,是上一次 render 的结果泄漏了过来。
根因,按内容读出
:34 import { render, waitFor } from '@testing-library/react'; // ⛔ 没有 cleanup,没有 afterEach
:38 let lastItems: any[] = []; // 模块级共享
:40 vi.mock('./renderer', () => ({ ... lastItems = schema.items ?? []; ... }))
async function colorsFor(colorField: string, rows: any[] = [ROW]) {
lastItems = []; // 重置共享状态
...
render(<ObjectTimeline schema={schema} dataSource={makeDataSource(rows)} />); // ⛔ 从不 unmount
await waitFor(() => expect(lastItems.length).toBe(rows.length));
return lastItems.map((i) => i.color);
}
而 rung 2 与 rung 3 各自在同一个 it 里调用它两次:
it('rung 2: 3- and 6-digit hex literals pass through', async () => {
expect(await colorsFor('accent', [{ ...ROW, accent: '#abc' }])).toEqual(['#abc']);
expect(await colorsFor('accent', [{ ...ROW, accent: '#123456' }])).toEqual(['#123456']);
});
it('rung 2: rgb() and hsl() literals pass through', async () => { // 同一形状
expect(await colorsFor('accent', [{ ...ROW, accent: 'rgb(1, 2, 3)' }])).toEqual(['rgb(1, 2, 3)']);
expect(await colorsFor('accent', [{ ...ROW, accent: 'hsl(1 2% 3%)' }])).toEqual(['hsl(1 2% 3%)']);
});
testing-library 的自动 cleanup 只在 afterEach 跑,不在同一个 it 内的两次 render 之间。⇒ 第二次调用时,第一个组件仍然挂载着,而它的 find() promise 还在飞。
时序:
- 第一次
colorsFor render 组件 A(#abc),A 的 find() 已 resolve,lastItems = ['#abc'],断言 1 通过 ✓
- 第二次
colorsFor 执行 lastItems = [],render 组件 B(#123456)
- ⚠️ 组件 A 因某次重渲染再次写入
lastItems = ['#abc'](它还活着,mock 的 renderer 每次渲染都写)
waitFor 只检查 lastItems.length === 1 —— 达标,立刻返回
- 返回
['#abc'],断言 2 失败
⇒ 这个 waitFor 的谓词只看长度、不看内容,所以它无法区分「B 画好了」和「A 又写了一次」。在 CPU 空闲时 B 通常先到,测试就绿;队列 CI 跑 615 文件 / 772 秒,负载高,A 的重渲染插了进来。
⛔ 因此这不是 flake。 「flake」意味着没有确定的成因;这里成因是确定的:两个组件同时活着,共享一个模块级数组,而等待谓词分辨不出是谁写的。
建议补丁(未验证,实现者须自行测量)
最小且直击成因 —— 在每次 render 前卸载上一次:
-import { render, waitFor } from '@testing-library/react';
+import { render, waitFor, cleanup } from '@testing-library/react';
async function colorsFor(colorField: string, rows: any[] = [ROW]) {
+ cleanup(); // 卸载上一次 render,杜绝并存组件写同一个 lastItems
lastItems = [];
⚠️ 但只做这一步不够,因为它没有修掉那个分辨不出作者的等待谓词。真正的加固还应当让 waitFor 断言内容而不只是长度 —— 否则下一个在同一个 it 里 render 两次的人会再次踩到。请两条都做,并说明理由。
⛔ 不得把两个断言拆成两个 it 就算完 —— 那只是把竞态挪到 afterEach 的 cleanup 上碰运气,成因未除,而且这个文件里还有别的多 render 的 it。请普查全文件,每一处多次 render 的 it 都要处理。
可逆验证的要求
这条缺陷是负载依赖的,所以「跑一遍绿了」不构成证据。要求:
- 先复现红:在未修改的树上构造出确定性的失败 —— 例如让第一个组件的
find() 延迟 resolve,或直接断言两次 render 之后 DOM 里有两个 data-testid="timeline-renderer"(这一条是成因的直接证据,且与时序无关)。⭐ 那个 DOM 计数就是最好的 pin:它把「两个组件同时活着」从时序问题变成了结构事实。
- 修完之后同一个探针必须变绿,并留一个会点亮的对照。
- ⛔ 不得跳过、禁用或隔离这个测试。
影响面
⚠️ 这一条踢掉的是 PR #7514,但它与 #7514 无关 —— 它会踢掉任何恰好在负载下入队的 PR。在它修好之前,合并队列的失败都要先排除这一条,否则会被误判成入队 PR 自己的问题。
Refs: #7243(该 ladder 的原始卡)· PR #7514(被它踢出队列的第一个已知受害者)
从 PR #7514 被合并队列以⚠️ 这不是那个 PR 的失败 —— 它删的是
CI_FAILURE踢出而查出。packages/components/src/SchemaRenderer.tsx,与plugin-timeline无关。这是一条真实的竞态,不是 flake,而且它现在正在阻塞合并队列。定 p1:失败模式是随机踢掉任意 PR 的队列条目,与该 PR 的内容无关。承重性,不是严重性。
实测的失败
合并队列分支
gh-readonly-queue/main/pr-7514-fe4e7a9e8…,workflow run33776772672,jobTest (shard 3/4)。615 个测试文件里只有 1 个红,8048 通过:⭐ 第二个断言收到了第一个断言的值。 这不是「颜色算错了」,是上一次 render 的结果泄漏了过来。
根因,按内容读出
而 rung 2 与 rung 3 各自在同一个
it里调用它两次:testing-library 的自动 cleanup 只在
afterEach跑,不在同一个it内的两次render之间。⇒ 第二次调用时,第一个组件仍然挂载着,而它的find()promise 还在飞。时序:
colorsForrender 组件 A(#abc),A 的find()已 resolve,lastItems = ['#abc'],断言 1 通过 ✓colorsFor执行lastItems = [],render 组件 B(#123456)lastItems = ['#abc'](它还活着,mock 的 renderer 每次渲染都写)waitFor只检查lastItems.length === 1—— 达标,立刻返回['#abc'],断言 2 失败⇒ 这个
waitFor的谓词只看长度、不看内容,所以它无法区分「B 画好了」和「A 又写了一次」。在 CPU 空闲时 B 通常先到,测试就绿;队列 CI 跑 615 文件 / 772 秒,负载高,A 的重渲染插了进来。⛔ 因此这不是 flake。 「flake」意味着没有确定的成因;这里成因是确定的:两个组件同时活着,共享一个模块级数组,而等待谓词分辨不出是谁写的。
建议补丁(未验证,实现者须自行测量)
最小且直击成因 —— 在每次 render 前卸载上一次:
waitFor断言内容而不只是长度 —— 否则下一个在同一个it里 render 两次的人会再次踩到。请两条都做,并说明理由。⛔ 不得把两个断言拆成两个
it就算完 —— 那只是把竞态挪到afterEach的 cleanup 上碰运气,成因未除,而且这个文件里还有别的多 render 的it。请普查全文件,每一处多次render的it都要处理。可逆验证的要求
这条缺陷是负载依赖的,所以「跑一遍绿了」不构成证据。要求:
find()延迟 resolve,或直接断言两次 render 之后 DOM 里有两个data-testid="timeline-renderer"(这一条是成因的直接证据,且与时序无关)。⭐ 那个 DOM 计数就是最好的 pin:它把「两个组件同时活着」从时序问题变成了结构事实。影响面
Refs: #7243(该 ladder 的原始卡)· PR #7514(被它踢出队列的第一个已知受害者)