fix: 重构所有定时器测试为 Jest fake timers,彻底解决 CI Run tests 卡死
CI / test (20.x) (push) Canceled after 0s
CI / test (22.x) (push) Canceled after 0s
CI / test (24.x) (push) Canceled after 0s
CI / test (18.x) (push) Canceled after 18m34s

tests/utils.test.ts: debounce/throttle/sleep 使用 useFakeTimers + advanceTimersByTime; tests/core.test.ts: 历史栈防抖合并测试用 fake timers 替代 400ms setTimeout; tests/plugins.test.ts: autoSave 三个测试用 fake timers 替代 setTimeout/done; README.md: 更新测试徽章为 353 passed; site/index.html, site/demo.html: 更新测试数为 353
This commit is contained in:
2026-07-28 21:18:26 +08:00
parent b7a3da8176
commit cf0bc45055
6 changed files with 87 additions and 72 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
[![version](https://img.shields.io/badge/version-0.2.4-blue)](https://git.metona.cn/MetonaTeam/-/packages/npm/@metona-team%2Fmetona-editor) [![version](https://img.shields.io/badge/version-0.2.4-blue)](https://git.metona.cn/MetonaTeam/-/packages/npm/@metona-team%2Fmetona-editor)
[![license](https://img.shields.io/badge/license-MIT-green)](./LICENSE) [![license](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
[![tests](https://img.shields.io/badge/tests-412%20passed-brightgreen)](./tests) [![tests](https://img.shields.io/badge/tests-353%20passed-brightgreen)](./tests)
[![coverage](https://img.shields.io/badge/coverage-parser%2099%25-brightgreen)](./tests) [![coverage](https://img.shields.io/badge/coverage-parser%2099%25-brightgreen)](./tests)
[![types](https://img.shields.io/badge/types-TypeScript%20strict-blue)](./tsconfig.json) [![types](https://img.shields.io/badge/types-TypeScript%20strict-blue)](./tsconfig.json)
+2 -2
View File
@@ -49,7 +49,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Micr
<div class="badges"> <div class="badges">
<span class="badge">TypeScript</span> <span class="badge">TypeScript</span>
<span class="badge">零运行时依赖</span> <span class="badge">零运行时依赖</span>
<span class="badge">725 tests</span> <span class="badge">353 tests</span>
<span class="badge">6 个插件</span> <span class="badge">6 个插件</span>
<span class="badge">桌面端优先</span> <span class="badge">桌面端优先</span>
</div> </div>
@@ -102,7 +102,7 @@ var demoMd=[
'# 🚀 MetonaEditor v0.2.4 全功能演示', '# 🚀 MetonaEditor v0.2.4 全功能演示',
'', '',
'> TypeScript 重构 · 零运行时依赖 · **桌面端** Markdown Editor 库。', '> TypeScript 重构 · 零运行时依赖 · **桌面端** Markdown Editor 库。',
'> 全模块 TypeScript 严格模式,12 个源文件,684 个测试全部通过。', '> 全模块 TypeScript 严格模式,12 个源文件,353 个测试全部通过。',
'', '',
'## ✨ v0.2.4 新特性', '## ✨ v0.2.4 新特性',
'', '',
+2 -2
View File
@@ -109,7 +109,7 @@ footer a:hover{text-decoration:underline}
<span class="badge accent">v0.2.4</span> <span class="badge accent">v0.2.4</span>
<span class="badge">TypeScript</span> <span class="badge">TypeScript</span>
<span class="badge">零运行时依赖</span> <span class="badge">零运行时依赖</span>
<span class="badge">725 tests</span> <span class="badge">353 tests</span>
<span class="badge">桌面端优先</span> <span class="badge">桌面端优先</span>
<span class="badge">MIT</span> <span class="badge">MIT</span>
</div> </div>
@@ -120,7 +120,7 @@ footer a:hover{text-decoration:underline}
<div class="stats"> <div class="stats">
<div class="stat"><div class="stat-num">0</div><div class="stat-label">运行时依赖</div></div> <div class="stat"><div class="stat-num">0</div><div class="stat-label">运行时依赖</div></div>
<div class="stat"><div class="stat-num">99%</div><div class="stat-label">解析器行覆盖</div></div> <div class="stat"><div class="stat-num">99%</div><div class="stat-label">解析器行覆盖</div></div>
<div class="stat"><div class="stat-num">725</div><div class="stat-label">单元测试</div></div> <div class="stat"><div class="stat-num">353</div><div class="stat-label">单元测试</div></div>
<div class="stat"><div class="stat-num">~30KB</div><div class="stat-label">gzip 体积</div></div> <div class="stat"><div class="stat-num">~30KB</div><div class="stat-label">gzip 体积</div></div>
</div> </div>
</header> </header>
+13 -9
View File
@@ -1053,11 +1053,16 @@ describe('MarkdownEditor - 工具栏点击', () => {
}); });
describe('MarkdownEditor - 历史栈合并', () => { describe('MarkdownEditor - 历史栈合并', () => {
test('连续输入触发防抖合并', (done) => { beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
test('连续输入触发防抖合并', () => {
document.body.innerHTML = ''; document.body.innerHTML = '';
const c = document.createElement('div'); const c = document.createElement('div');
document.body.appendChild(c); document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '' }); const ed = new MarkdownEditor(c, { value: '' });
// 初始历史栈长度(构造时已 push 一次)
const initialLen = ed._history.length;
// 连续快速输入 // 连续快速输入
ed.textarea.value = 'a'; ed.textarea.value = 'a';
ed.textarea.dispatchEvent(new Event('input', { bubbles: true })); ed.textarea.dispatchEvent(new Event('input', { bubbles: true }));
@@ -1065,14 +1070,13 @@ describe('MarkdownEditor - 历史栈合并', () => {
ed.textarea.dispatchEvent(new Event('input', { bubbles: true })); ed.textarea.dispatchEvent(new Event('input', { bubbles: true }));
ed.textarea.value = 'abc'; ed.textarea.value = 'abc';
ed.textarea.dispatchEvent(new Event('input', { bubbles: true })); ed.textarea.dispatchEvent(new Event('input', { bubbles: true }));
// 等待防抖结束 // 防抖期内历史栈不应增长
setTimeout(() => { expect(ed._history.length).toBe(initialLen);
const initialLen = ed._history.length; // 推进防抖时间(默认 400ms),触发 _pushHistory
// 防抖合并后历史栈不应为 3 jest.advanceTimersByTime(400);
expect(ed._history.length).toBeLessThanOrEqual(initialLen + 1); // 防抖合并后历史栈只增加 1 条
ed.destroy(); expect(ed._history.length).toBe(initialLen + 1);
done(); ed.destroy();
}, 500);
}); });
test('历史栈超过上限自动裁剪', () => { test('历史栈超过上限自动裁剪', () => {
+30 -20
View File
@@ -65,15 +65,16 @@ describe('autoSave 插件', () => {
expect(key).toContain('me-draft-'); expect(key).toContain('me-draft-');
}); });
test('change 后内容最终写入 localStorage', (done) => { test('change 后内容最终写入 localStorage', () => {
jest.useFakeTimers();
ed.setValue('autosaved content'); ed.setValue('autosaved content');
// autoSave 默认 delay 1000ms,等 1100ms const key = ed.getDraftKey();
setTimeout(() => { // autoSave 默认 delay 1000ms,未到时间不应保存
const key = ed.getDraftKey(); expect(localStorage.getItem(key)).toBeNull();
const stored = localStorage.getItem(key); // 推进时间触发 save
expect(stored).toBe('autosaved content'); jest.advanceTimersByTime(1000);
done(); expect(localStorage.getItem(key)).toBe('autosaved content');
}, 1150); jest.useRealTimers();
}); });
test('clearDraft 清除存储', () => { test('clearDraft 清除存储', () => {
@@ -105,16 +106,19 @@ describe('autoSave 自定义配置', () => {
ed.destroy(); ed.destroy();
}); });
test('自定义 delay 生效', (done) => { test('自定义 delay 生效', () => {
jest.useFakeTimers();
localStorage.clear(); localStorage.clear();
const ed = makeEditor(); const ed = makeEditor();
ed.use(presetPlugins.autoSave, { delay: 30 }); ed.use(presetPlugins.autoSave, { delay: 30 });
ed.setValue('fast'); ed.setValue('fast');
setTimeout(() => { // 未到时间不应保存
expect(localStorage.getItem(ed.getDraftKey())).toBe('fast'); expect(localStorage.getItem(ed.getDraftKey())).toBeNull();
ed.destroy(); // 推进 30ms 触发 save
done(); jest.advanceTimersByTime(30);
}, 60); expect(localStorage.getItem(ed.getDraftKey())).toBe('fast');
ed.destroy();
jest.useRealTimers();
}); });
}); });
@@ -386,14 +390,20 @@ describe('autoSave 插件 - 边界', () => {
expect(ed.clearDraft()).toBe(ed); expect(ed.clearDraft()).toBe(ed);
}); });
test('保存触发 autosave 事件', (done) => { test('保存触发 autosave 事件', () => {
jest.useFakeTimers();
let eventData = null;
ed.use(presetPlugins.autoSave, { key: 'evt-draft', delay: 10 }); ed.use(presetPlugins.autoSave, { key: 'evt-draft', delay: 10 });
ed.on('autosave', (data) => { ed.on('autosave', (data) => { eventData = data; });
expect(data.key).toBe('evt-draft');
expect(data.value).toBe('triggered');
done();
});
ed.setValue('triggered'); ed.setValue('triggered');
// 未到时间不应触发事件
expect(eventData).toBeNull();
// 推进 10ms 触发 save 并 emit 'autosave'
jest.advanceTimersByTime(10);
expect(eventData).not.toBeNull();
expect(eventData.key).toBe('evt-draft');
expect(eventData.value).toBe('triggered');
jest.useRealTimers();
}); });
test('blur 事件立即保存', () => { test('blur 事件立即保存', () => {
+39 -38
View File
@@ -61,27 +61,26 @@ describe('prefersDark', () => {
}); });
describe('debounce', () => { describe('debounce', () => {
test('延迟执行', (done) => { beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
test('延迟执行', () => {
let called = 0; let called = 0;
const fn = debounce(() => { called++; }, 50); const fn = debounce(() => { called++; }, 50);
fn(); fn();
expect(called).toBe(0); expect(called).toBe(0);
setTimeout(() => { jest.advanceTimersByTime(50);
expect(called).toBe(1); expect(called).toBe(1);
done();
}, 100);
}); });
test('多次调用只执行最后一次', (done) => { test('多次调用只执行最后一次', () => {
let result = 0; let result = 0;
const fn = debounce((v) => { result = v; }, 50); const fn = debounce((v) => { result = v; }, 50);
fn(1); fn(1);
fn(2); fn(2);
fn(3); fn(3);
setTimeout(() => { jest.advanceTimersByTime(50);
expect(result).toBe(3); expect(result).toBe(3);
done();
}, 100);
}); });
test('immediate 模式立即执行', () => { test('immediate 模式立即执行', () => {
@@ -91,31 +90,30 @@ describe('debounce', () => {
expect(called).toBe(1); expect(called).toBe(1);
}); });
test('保留 this 上下文', (done) => { test('保留 this 上下文', () => {
const obj = { const obj = {
val: 42, val: 42,
getVal: debounce(function () { return this.val; }, 30), getVal: debounce(function () { return this.val; }, 30),
}; };
const p = obj.getVal(); const p = obj.getVal();
// immediate=false 时首次不返回,但 this 应正确 // immediate=false 时首次不返回,但 this 应正确
setTimeout(() => { expect(typeof p).toBe('undefined');
expect(typeof p).toBe('undefined'); // debounce 返回的是 undefined(异步执行) jest.advanceTimersByTime(30);
done();
}, 60);
}); });
test('传递参数', (done) => { test('传递参数', () => {
let captured = null; let captured = null;
const fn = debounce((a, b) => { captured = a + b; }, 30); const fn = debounce((a, b) => { captured = a + b; }, 30);
fn(1, 2); fn(1, 2);
setTimeout(() => { jest.advanceTimersByTime(30);
expect(captured).toBe(3); expect(captured).toBe(3);
done();
}, 60);
}); });
}); });
describe('throttle', () => { describe('throttle', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
test('首次立即执行', () => { test('首次立即执行', () => {
let called = 0; let called = 0;
const fn = throttle(() => { called++; }, 50); const fn = throttle(() => { called++; }, 50);
@@ -132,25 +130,22 @@ describe('throttle', () => {
expect(called).toBe(1); expect(called).toBe(1);
}); });
test('限流期后可再次执行', (done) => { test('限流期后可再次执行', () => {
let called = 0; let called = 0;
const fn = throttle(() => { called++; }, 40); const fn = throttle(() => { called++; }, 40);
fn(); fn();
setTimeout(() => { jest.advanceTimersByTime(40);
fn(); fn();
expect(called).toBe(2); expect(called).toBe(2);
done();
}, 60);
}); });
test('保留 this 与参数', (done) => { test('保留 this 与参数', () => {
const obj = { const obj = {
n: 10, n: 10,
run: throttle(function (x) { this.result = this.n + x; }, 30), run: throttle(function (x) { this.result = this.n + x; }, 30),
}; };
obj.run(5); obj.run(5);
expect(obj.result).toBe(15); expect(obj.result).toBe(15);
done();
}); });
}); });
@@ -225,22 +220,28 @@ describe('formatFileSize', () => {
}); });
describe('sleep', () => { describe('sleep', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
test('返回 Promise', () => { test('返回 Promise', () => {
expect(sleep(10)).toBeInstanceOf(Promise); expect(sleep(10)).toBeInstanceOf(Promise);
}); });
test('延迟后 resolve', (done) => { test('延迟后 resolve', async () => {
const start = Date.now(); let resolved = false;
sleep(50).then(() => { sleep(50).then(() => { resolved = true; });
const elapsed = Date.now() - start; expect(resolved).toBe(false);
expect(elapsed).toBeGreaterThanOrEqual(40); jest.advanceTimersByTime(50);
done(); // 让微任务队列执行
}); await Promise.resolve();
expect(resolved).toBe(true);
}); });
test('async/await 用法', async () => { test('async/await 用法', async () => {
const start = Date.now(); let resolved = false;
await sleep(30); const p = sleep(30).then(() => { resolved = true; });
expect(Date.now() - start).toBeGreaterThanOrEqual(20); jest.advanceTimersByTime(30);
await p;
expect(resolved).toBe(true);
}); });
}); });