foliko 2.0.31 → 2.0.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.editorconfig +56 -56
- package/.lintstagedrc +7 -7
- package/.prettierignore +29 -29
- package/.prettierrc +11 -11
- package/Dockerfile +63 -63
- package/install.ps1 +129 -129
- package/install.sh +121 -121
- package/package.json +1 -1
- package/plugins/core/workflow/context.js +941 -941
- package/plugins/core/workflow/engine.js +66 -66
- package/plugins/core/workflow/js-runner.js +318 -318
- package/plugins/core/workflow/json-runner.js +323 -323
- package/plugins/core/workflow/stages/choice.js +74 -74
- package/plugins/core/workflow/stages/each.js +123 -123
- package/plugins/core/workflow/stages/parallel.js +69 -69
- package/skills/find-skills/SKILL.md +133 -133
- package/src/agent/chat.js +31 -9
- package/src/agent/sub.js +17 -3
- package/src/agent/tool-loop.js +119 -25
- package/src/common/atomic-write.js +33 -0
- package/src/context/compressor.js +22 -18
- package/src/framework/framework.js +19 -1
- package/src/plugin/manager.js +12 -2
- package/src/session/session.js +9 -4
- package/src/storage/manager.js +3 -1
- package/tests/core/chat-tool.test.js +187 -187
- package/tests/core/latest-message-not-filtered.test.js +143 -0
- package/tests/core/p0-fixes.test.js +129 -0
- package/tests/core/p1-fixes.test.js +76 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const { ToolLoop } = require('../../src/agent/tool-loop.js');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 验证:"每次发送消息都是最新的,不会被过滤掉"
|
|
7
|
+
* 即:无论历史消息如何被 sanitize / 补 reasoning / 去重,
|
|
8
|
+
* 用户最新的一条消息永远作为最后一条被发送给 API,绝不丢失。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// 一个最小可捕获的 OpenAI 兼容客户端 mock
|
|
12
|
+
function mockClient(responses) {
|
|
13
|
+
const calls = [];
|
|
14
|
+
return {
|
|
15
|
+
calls,
|
|
16
|
+
chat: {
|
|
17
|
+
completions: {
|
|
18
|
+
create: async (params) => {
|
|
19
|
+
// 深拷贝快照,避免后续 loop 复用引用导致断言失真
|
|
20
|
+
calls.push(JSON.parse(JSON.stringify(params)));
|
|
21
|
+
const r = responses.shift();
|
|
22
|
+
if (r instanceof Error) throw r;
|
|
23
|
+
return r;
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 非流式最终答复(无 tool_calls)
|
|
31
|
+
function finalAnswer(text) {
|
|
32
|
+
return {
|
|
33
|
+
choices: [{ message: { content: text }, finish_reason: 'stop' }],
|
|
34
|
+
usage: { prompt_tokens: 1, completion_tokens: 1 },
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const lastOf = (arr) => arr[arr.length - 1];
|
|
39
|
+
|
|
40
|
+
describe('最新消息一定被发送(_buildApiMessages 层)', () => {
|
|
41
|
+
it('纯文本历史 + 最新 user → 最新 user 永远在最后', () => {
|
|
42
|
+
const loop = new ToolLoop({ tools: {}, maxSteps: 1 });
|
|
43
|
+
const api = loop._buildApiMessages([
|
|
44
|
+
{ role: 'user', content: '第一条' },
|
|
45
|
+
{ role: 'assistant', content: '回复第一条' },
|
|
46
|
+
{ role: 'user', content: '最新消息-abc' },
|
|
47
|
+
], 'sys');
|
|
48
|
+
expect(lastOf(api)).toEqual({ role: 'user', content: '最新消息-abc' });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('历史含 tool_calls / tool 结果 / reasoning 时,最新 user 仍在最后且内容不变', () => {
|
|
52
|
+
const loop = new ToolLoop({ tools: {}, maxSteps: 1, thinkingMode: true });
|
|
53
|
+
const api = loop._buildApiMessages([
|
|
54
|
+
{ role: 'user', content: '早前的问题' },
|
|
55
|
+
{
|
|
56
|
+
role: 'assistant',
|
|
57
|
+
content: '',
|
|
58
|
+
reasoning_content: '先读文件',
|
|
59
|
+
tool_calls: [{ id: 'c1', type: 'function', function: { name: 'read', arguments: '{}' } }],
|
|
60
|
+
},
|
|
61
|
+
{ role: 'tool', tool_call_id: 'c1', content: 'file data' },
|
|
62
|
+
{ role: 'assistant', content: '读完了' },
|
|
63
|
+
{ role: 'user', content: '这是我最新发的-xyz' },
|
|
64
|
+
], 'sys');
|
|
65
|
+
const last = lastOf(api);
|
|
66
|
+
expect(last.role).toBe('user');
|
|
67
|
+
expect(last.content).toBe('这是我最新发的-xyz');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('最新 user 内容为空字符串也不会被丢弃', () => {
|
|
71
|
+
const loop = new ToolLoop({ tools: {}, maxSteps: 1 });
|
|
72
|
+
const api = loop._buildApiMessages([
|
|
73
|
+
{ role: 'assistant', content: '上一轮' },
|
|
74
|
+
{ role: 'user', content: '' },
|
|
75
|
+
], 'sys');
|
|
76
|
+
expect(lastOf(api)).toEqual({ role: 'user', content: '' });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('最新 user 是数组内容(含 text part)时,文本被保留为最后一条', () => {
|
|
80
|
+
const loop = new ToolLoop({ tools: {}, maxSteps: 1 });
|
|
81
|
+
const api = loop._buildApiMessages([
|
|
82
|
+
{ role: 'user', content: '历史' },
|
|
83
|
+
{ role: 'assistant', content: 'ok' },
|
|
84
|
+
{ role: 'user', content: [{ type: 'text', text: '多模态最新-777' }] },
|
|
85
|
+
], 'sys');
|
|
86
|
+
const last = lastOf(api);
|
|
87
|
+
expect(last.role).toBe('user');
|
|
88
|
+
expect(last.content).toBe('多模态最新-777');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('最新消息一定被发送(generate 端到端,mock client)', () => {
|
|
93
|
+
it('实际发给 API 的 messages 末尾就是最新 user 输入', async () => {
|
|
94
|
+
const loop = new ToolLoop({ tools: {}, maxSteps: 3 });
|
|
95
|
+
const client = mockClient([finalAnswer('这是回复')]);
|
|
96
|
+
|
|
97
|
+
const messages = [
|
|
98
|
+
{ role: 'user', content: '旧消息1' },
|
|
99
|
+
{ role: 'assistant', content: '旧回复1' },
|
|
100
|
+
{ role: 'user', content: '★最新用户输入★' },
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
const result = await loop.generate({ messages, systemPrompt: 'sys', model: 'm', client });
|
|
104
|
+
|
|
105
|
+
expect(result.text).toBe('这是回复');
|
|
106
|
+
expect(client.calls).toHaveLength(1);
|
|
107
|
+
const sent = lastOf(client.calls[0].messages);
|
|
108
|
+
expect(sent.role).toBe('user');
|
|
109
|
+
expect(sent.content).toBe('★最新用户输入★');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('触发 reasoning_content 自愈重试后,最新 user 依旧在最后(两次请求都保住)', async () => {
|
|
113
|
+
const loop = new ToolLoop({ tools: {}, maxSteps: 3 });
|
|
114
|
+
const err = new Error('400 The reasoning_content in the thinking mode must be passed back to the API.');
|
|
115
|
+
const client = mockClient([err, finalAnswer('重试成功的回复')]);
|
|
116
|
+
|
|
117
|
+
const messages = [
|
|
118
|
+
{ role: 'user', content: '历史问' },
|
|
119
|
+
{
|
|
120
|
+
role: 'assistant',
|
|
121
|
+
content: '',
|
|
122
|
+
tool_calls: [{ id: 'c1', type: 'function', function: { name: 'read', arguments: '{}' } }],
|
|
123
|
+
},
|
|
124
|
+
{ role: 'tool', tool_call_id: 'c1', content: 'done' },
|
|
125
|
+
{ role: 'user', content: '重试也要带上的最新消息-999' },
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
const result = await loop.generate({ messages, systemPrompt: 'sys', model: 'm', client });
|
|
129
|
+
|
|
130
|
+
expect(result.text).toBe('重试成功的回复');
|
|
131
|
+
// 一次失败 + 一次自愈重试 = 2 次调用
|
|
132
|
+
expect(client.calls).toHaveLength(2);
|
|
133
|
+
for (const call of client.calls) {
|
|
134
|
+
const last = lastOf(call.messages);
|
|
135
|
+
expect(last.role).toBe('user');
|
|
136
|
+
expect(last.content).toBe('重试也要带上的最新消息-999');
|
|
137
|
+
}
|
|
138
|
+
// 自愈生效:重试请求里 assistant 已补上 reasoning_content
|
|
139
|
+
const retried = client.calls[1].messages;
|
|
140
|
+
const assistantMsg = retried.find((m) => m.role === 'assistant' && m.tool_calls);
|
|
141
|
+
expect(assistantMsg.reasoning_content).toBeTruthy();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const { ToolLoop } = require('../../src/agent/tool-loop.js');
|
|
4
|
+
const { ContextCompressor } = require('../../src/context/compressor.js');
|
|
5
|
+
|
|
6
|
+
// 最小可捕获的 OpenAI 兼容客户端 mock
|
|
7
|
+
function mockClient(responses) {
|
|
8
|
+
const calls = [];
|
|
9
|
+
return {
|
|
10
|
+
calls,
|
|
11
|
+
chat: { completions: { create: async (p) => { calls.push(p); const r = responses.shift(); if (r instanceof Error) throw r; return r; } } },
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
const finalAnswer = (text, reasoning) => ({
|
|
15
|
+
choices: [{ message: { content: text, ...(reasoning ? { reasoning_content: reasoning } : {}) }, finish_reason: 'stop' }],
|
|
16
|
+
usage: { prompt_tokens: 1, completion_tokens: 1 },
|
|
17
|
+
});
|
|
18
|
+
const toolThenAnswer = (toolName) => ({
|
|
19
|
+
choices: [{ message: { content: '', tool_calls: [{ id: 'c1', type: 'function', function: { name: toolName, arguments: '{}' } }] }, finish_reason: 'tool_calls' }],
|
|
20
|
+
usage: { prompt_tokens: 1, completion_tokens: 1 },
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('P0#2 — 最终 assistant 回复被写进 messages(generate)', () => {
|
|
24
|
+
it('无工具时,最终回复作为 assistant 消息追加到 messages', async () => {
|
|
25
|
+
const loop = new ToolLoop({ tools: {}, maxSteps: 3 });
|
|
26
|
+
const client = mockClient([finalAnswer('这是最终答复', '我的思考')]);
|
|
27
|
+
const messages = [{ role: 'user', content: '问题' }];
|
|
28
|
+
|
|
29
|
+
const result = await loop.generate({ messages, systemPrompt: 'sys', model: 'm', client });
|
|
30
|
+
|
|
31
|
+
expect(result.text).toBe('这是最终答复');
|
|
32
|
+
const last = messages[messages.length - 1];
|
|
33
|
+
expect(last.role).toBe('assistant');
|
|
34
|
+
expect(last.content).toBe('这是最终答复');
|
|
35
|
+
expect(last.reasoning_content).toBe('我的思考');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('工具轮 + 最终回复:messages 末尾是 assistant 答复(历史不丢答复)', async () => {
|
|
39
|
+
const loop = new ToolLoop({ tools: { read: { name: 'read', execute: async () => 'file data' } }, maxSteps: 3 });
|
|
40
|
+
const client = mockClient([toolThenAnswer('read'), finalAnswer('读完了,结果是X')]);
|
|
41
|
+
const messages = [{ role: 'user', content: '读文件' }];
|
|
42
|
+
|
|
43
|
+
await loop.generate({ messages, systemPrompt: 'sys', model: 'm', client });
|
|
44
|
+
|
|
45
|
+
const roles = messages.map(m => m.role);
|
|
46
|
+
// user -> assistant(tool_calls) -> tool -> assistant(final)
|
|
47
|
+
expect(roles[0]).toBe('user');
|
|
48
|
+
expect(roles[roles.length - 1]).toBe('assistant');
|
|
49
|
+
expect(messages[messages.length - 1].content).toBe('读完了,结果是X');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('P0#2/#3 — stream 追加最终回复 + thinking 只发增量', () => {
|
|
54
|
+
async function drain(gen) {
|
|
55
|
+
const events = [];
|
|
56
|
+
let ret;
|
|
57
|
+
while (true) {
|
|
58
|
+
const { value, done } = await gen.next();
|
|
59
|
+
if (done) { ret = value; break; }
|
|
60
|
+
events.push(value);
|
|
61
|
+
}
|
|
62
|
+
return { events, ret };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 流式 chunk 构造
|
|
66
|
+
const chunk = (delta, finish = null) => ({ choices: [{ delta, finish_reason: finish }] });
|
|
67
|
+
|
|
68
|
+
it('thinking 事件是增量而非累积(不重复)', async () => {
|
|
69
|
+
const loop = new ToolLoop({ tools: {}, maxSteps: 2, thinkingMode: true });
|
|
70
|
+
async function* streamGen() {
|
|
71
|
+
yield chunk({ reasoning_content: '我先' });
|
|
72
|
+
yield chunk({ reasoning_content: '想一下' });
|
|
73
|
+
yield chunk({ content: '答复' });
|
|
74
|
+
yield chunk({}, 'stop');
|
|
75
|
+
yield { choices: [], usage: { prompt_tokens: 1, completion_tokens: 1 } };
|
|
76
|
+
}
|
|
77
|
+
const client = { chat: { completions: { create: async () => streamGen() } } };
|
|
78
|
+
const messages = [{ role: 'user', content: 'hi' }];
|
|
79
|
+
|
|
80
|
+
const { events } = await drain(loop.stream({ messages, systemPrompt: 'sys', model: 'm', client }));
|
|
81
|
+
const thinking = events.filter(e => e.type === 'thinking').map(e => e.text);
|
|
82
|
+
// 增量应为 ['我先','想一下'],而不是 ['我先','我先想一下']
|
|
83
|
+
expect(thinking).toEqual(['我先', '想一下']);
|
|
84
|
+
// 且最终 assistant 回复被追加,reasoning 完整保存
|
|
85
|
+
const last = messages[messages.length - 1];
|
|
86
|
+
expect(last.role).toBe('assistant');
|
|
87
|
+
expect(last.content).toBe('答复');
|
|
88
|
+
expect(last.reasoning_content).toBe('我先想一下');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('P0#4 — 压缩器按 sessionId 隔离在途 promise', () => {
|
|
93
|
+
it('不同 session 并发压缩各自独立执行(不共享 in-flight)', async () => {
|
|
94
|
+
const comp = new ContextCompressor({ agent: null, framework: null, model: 'deepseek-chat', keepRecentMessages: 1, enableSmartCompress: false });
|
|
95
|
+
let calls = 0;
|
|
96
|
+
const orig = comp._doCompress.bind(comp);
|
|
97
|
+
comp._doCompress = async (...a) => { calls++; return orig(...a); };
|
|
98
|
+
const mkMsgs = (tag) => [
|
|
99
|
+
{ role: 'user', content: `${tag}-1` },
|
|
100
|
+
{ role: 'assistant', content: `${tag}-2` },
|
|
101
|
+
{ role: 'user', content: `${tag}-3` },
|
|
102
|
+
];
|
|
103
|
+
const storeA = { messages: mkMsgs('A'), save: async () => {} };
|
|
104
|
+
const storeB = { messages: mkMsgs('B'), save: async () => {} };
|
|
105
|
+
|
|
106
|
+
await Promise.all([
|
|
107
|
+
comp.compress('sessA', storeA.messages, storeA),
|
|
108
|
+
comp.compress('sessB', storeB.messages, storeB),
|
|
109
|
+
]);
|
|
110
|
+
// 两个 session 各自跑一次压缩(旧实现里 B 会拿到 A 的 promise、根本不压缩自己)
|
|
111
|
+
expect(calls).toBe(2);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('同一 session 并发调用只执行一次压缩(复用 in-flight)', async () => {
|
|
115
|
+
const comp = new ContextCompressor({ agent: null, framework: null, model: 'deepseek-chat', keepRecentMessages: 1, enableSmartCompress: false });
|
|
116
|
+
let calls = 0;
|
|
117
|
+
const orig = comp._doCompress.bind(comp);
|
|
118
|
+
comp._doCompress = async (...a) => { calls++; return orig(...a); };
|
|
119
|
+
const msgs = [
|
|
120
|
+
{ role: 'user', content: 'x1' }, { role: 'assistant', content: 'x2' }, { role: 'user', content: 'x3' },
|
|
121
|
+
];
|
|
122
|
+
const store = { messages: msgs, save: async () => {} };
|
|
123
|
+
await Promise.all([
|
|
124
|
+
comp.compress('same', msgs, store),
|
|
125
|
+
comp.compress('same', msgs, store),
|
|
126
|
+
]);
|
|
127
|
+
expect(calls).toBe(1);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const { ToolLoop } = require('../../src/agent/tool-loop.js');
|
|
7
|
+
const { atomicWriteFileSync } = require('../../src/common/atomic-write.js');
|
|
8
|
+
const { SessionManager } = require('../../src/session/session.js');
|
|
9
|
+
|
|
10
|
+
function mockClient(responses) {
|
|
11
|
+
return { chat: { completions: { create: async () => { const r = responses.shift(); if (r instanceof Error) throw r; return r; } } } };
|
|
12
|
+
}
|
|
13
|
+
const toolCall = (name) => ({ choices: [{ message: { content: '', tool_calls: [{ id: 'c1', type: 'function', function: { name, arguments: '{}' } }] }, finish_reason: 'tool_calls' }], usage: {} });
|
|
14
|
+
const answer = (t) => ({ choices: [{ message: { content: t }, finish_reason: 'stop' }], usage: {} });
|
|
15
|
+
|
|
16
|
+
describe('P1#1 — maxSteps 用尽返回 stopReason', () => {
|
|
17
|
+
it('工具轮次耗尽 → stopReason=max_steps(不是成功)', async () => {
|
|
18
|
+
// 工具一直被调用,maxSteps=2 会在两轮后耗尽
|
|
19
|
+
const loop = new ToolLoop({ tools: { loopTool: { name: 'loopTool', execute: async () => 'again' } }, maxSteps: 2 });
|
|
20
|
+
const client = mockClient([toolCall('loopTool'), toolCall('loopTool'), toolCall('loopTool')]);
|
|
21
|
+
const messages = [{ role: 'user', content: 'go' }];
|
|
22
|
+
const result = await loop.generate({ messages, systemPrompt: 's', model: 'm', client });
|
|
23
|
+
expect(result.stopReason).toBe('max_steps');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('正常给出答复 → stopReason=stop', async () => {
|
|
27
|
+
const loop = new ToolLoop({ tools: {}, maxSteps: 3 });
|
|
28
|
+
const client = mockClient([answer('done')]);
|
|
29
|
+
const messages = [{ role: 'user', content: 'go' }];
|
|
30
|
+
const result = await loop.generate({ messages, systemPrompt: 's', model: 'm', client });
|
|
31
|
+
expect(result.stopReason).toBe('stop');
|
|
32
|
+
expect(result.text).toBe('done');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('P1#6 — atomicWriteFileSync 原子写', () => {
|
|
37
|
+
it('写入内容可完整读回', () => {
|
|
38
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'foliko-atomic-'));
|
|
39
|
+
const fp = path.join(dir, 'x.jsonl');
|
|
40
|
+
atomicWriteFileSync(fp, 'line1\nline2\n');
|
|
41
|
+
expect(fs.readFileSync(fp, 'utf8')).toBe('line1\nline2\n');
|
|
42
|
+
// 覆盖写
|
|
43
|
+
atomicWriteFileSync(fp, 'new\n');
|
|
44
|
+
expect(fs.readFileSync(fp, 'utf8')).toBe('new\n');
|
|
45
|
+
// 不残留 tmp 文件
|
|
46
|
+
expect(fs.readdirSync(dir).filter(n => n.includes('.tmp-'))).toHaveLength(0);
|
|
47
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('P1#3 — buildSessionContext 走活跃分支', () => {
|
|
52
|
+
it('切换分支后,非活跃分支的消息不进入上下文', () => {
|
|
53
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'foliko-branch-'));
|
|
54
|
+
const sm = new SessionManager(dir, dir, path.join(dir, 's.jsonl'), true);
|
|
55
|
+
// 主线:user A -> assistant A
|
|
56
|
+
sm.appendMessage({ role: 'user', content: '问题A' });
|
|
57
|
+
const branchPointId = sm.appendMessage({ role: 'assistant', content: '回答A' }); // 从这里分叉
|
|
58
|
+
|
|
59
|
+
// 继续主线:user B -> assistant B
|
|
60
|
+
sm.appendMessage({ role: 'user', content: '问题B-主线' });
|
|
61
|
+
sm.appendMessage({ role: 'assistant', content: '回答B-主线' });
|
|
62
|
+
|
|
63
|
+
// 从 branchPoint 开新分支(leafId 回到分叉点,再追加)
|
|
64
|
+
sm.branch(branchPointId);
|
|
65
|
+
sm.appendMessage({ role: 'user', content: '问题B-新分支' });
|
|
66
|
+
sm.appendMessage({ role: 'assistant', content: '回答B-新分支' });
|
|
67
|
+
|
|
68
|
+
const texts = sm.getMessages().map(m => (typeof m.content === 'string' ? m.content : '')).join('|');
|
|
69
|
+
// 活跃分支应包含新分支内容,不含主线的 B
|
|
70
|
+
expect(texts).toContain('问题B-新分支');
|
|
71
|
+
expect(texts).not.toContain('问题B-主线');
|
|
72
|
+
// 共同前缀仍在
|
|
73
|
+
expect(texts).toContain('问题A');
|
|
74
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
75
|
+
});
|
|
76
|
+
});
|