@zhushanwen/pi-provider-live-sync 0.2.1 → 0.3.1
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/README.md +25 -4
- package/package.json +5 -3
- package/skills/provider-live-sync-ext-config/SKILL.md +53 -15
- package/src/__tests__/catalog-bridge.test.ts +891 -0
- package/src/__tests__/model-sync.test.ts +6 -32
- package/src/__tests__/pi-runtime-testkit.ts +57 -0
- package/src/__tests__/pi-semantics-catalog-injection.test.ts +632 -0
- package/src/__tests__/sdk-contract.test.ts +290 -0
- package/src/__tests__/tick-harness.ts +48 -0
- package/src/catalog-bridge.ts +482 -0
- package/src/index.ts +93 -29
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SDK 契约测试 —— 锁设计 §2.5 前提 7:
|
|
3
|
+
* 「内置基线的 Model 对象放进 `ProviderConfigInput.models` 经 pi 组合后字段等价
|
|
4
|
+
* (Model 对象是定义形状的超集)」。
|
|
5
|
+
*
|
|
6
|
+
* 为什么值得一个独立测试文件:前提 7 是「不成立」列写明「会改 bridge 注入构造」的那条
|
|
7
|
+
* ——若 pi 的组合入口对 Model 对象做字段裁剪/重命名,目录投喂模块(catalog-bridge)
|
|
8
|
+
* 就不能直接把 `getBuiltinModels(providerId)` 的基线对象并进注入集,必须改成显式挑
|
|
9
|
+
* 字段构造。该推断此前只读过 `extensionModelFromDefinition` 的展开逻辑、没跑真进程,
|
|
10
|
+
* 本文件把它变成可重跑的机器断言(pi bump 后由 pi-semantics 门禁强制重验)。
|
|
11
|
+
*
|
|
12
|
+
* 被测面 = pi 官方 SDK 入口 `ModelRuntime`(`registerProvider` 内部走
|
|
13
|
+
* `validateExtensionProvider` → `composeModelProvider` → `applyExtension` →
|
|
14
|
+
* `extensionModelFromDefinition`,即 `dist/core/provider-composer.js` 的组合链)。
|
|
15
|
+
* 不 mock pi 本体:本测试的全部价值就在于「实装版 pi 的真实组合行为」。
|
|
16
|
+
*
|
|
17
|
+
* 隔离方式:`ModelRuntime.create({ modelsPath: null, refreshOnCreate: false, authPath })`
|
|
18
|
+
* —— modelsPath null → 内存 ModelsStore(不落 models-store.json)、refreshOnCreate false
|
|
19
|
+
* → 不发起网络刷新、authPath 指 tmp(凭据读取不触真实 ~/.pi/agent)。
|
|
20
|
+
*
|
|
21
|
+
* 实测结论(pi@1.0.0,2026-10-20,本文件断言即结论):
|
|
22
|
+
* - **等价成立**:Model 对象的全部字段经组合后保留(含 `thinkingLevelMap` /
|
|
23
|
+
* `inputLimits` / `compat` / `promptCache` / `samplingParams`,以及定义形状里没有的
|
|
24
|
+
* `enabled` / `lab` / `providers` —— 超集属性由 `{...definition}` 展开保证)。
|
|
25
|
+
* - **唯一规范化**:组合后 Model 对象的 `headers` 恒为 `undefined`(pi 在
|
|
26
|
+
* `extensionModelFromDefinition` 里硬置)。对请求头无功能损失——请求期头解析
|
|
27
|
+
* (`getCompatibilityRequestConfig`)读的是原始定义里的 `models[].headers`,实测仍返回
|
|
28
|
+
* 完整头集合;只是组合对象的 `headers` 字段不再承载它。bridge 侧无需为此改构造。
|
|
29
|
+
* - **缺 `api`/`baseUrl` 不必然抛错**:pi 有四级兜底继承(同 id → 同 api →
|
|
30
|
+
* openai-completions → 该 provider 首个同类型模型)。仅当四级全空(该 provider 无任何
|
|
31
|
+
* 同类型基线模型)才抛。这正是设计 R3 取保守档(只认同 id 继承)的实据——pi 会从
|
|
32
|
+
* 「任意同 provider 模型」静默继承,语义未经上游目录声明。
|
|
33
|
+
*/
|
|
34
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
|
35
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
36
|
+
import { tmpdir } from 'node:os'
|
|
37
|
+
import { join } from 'node:path'
|
|
38
|
+
import { ModelRuntime } from '@earendil-works/pi-coding-agent'
|
|
39
|
+
import { getBuiltinModels, getBuiltinProviders } from '@earendil-works/pi-ai/providers/all'
|
|
40
|
+
import type { ProviderConfigInput } from '@earendil-works/pi-coding-agent'
|
|
41
|
+
|
|
42
|
+
import { fullBaselineConfig, runtimeWithFullBaseline, swapToSingleExpectShrink } from './pi-runtime-testkit.ts'
|
|
43
|
+
|
|
44
|
+
/** 被测 provider:catalog provider,内置基线 3 个 chat 模型(M2.7 / M2.7-highspeed / M3)。 */
|
|
45
|
+
const PROVIDER = 'minimax-cn'
|
|
46
|
+
|
|
47
|
+
let tmpDir: string
|
|
48
|
+
/** 每个用例一个独立 runtime:`registerProvider` 的合并语义跨用例不串味。 */
|
|
49
|
+
async function newRuntime(): Promise<ModelRuntime> {
|
|
50
|
+
return ModelRuntime.create({
|
|
51
|
+
modelsPath: null,
|
|
52
|
+
refreshOnCreate: false,
|
|
53
|
+
authPath: join(tmpDir, 'auth.json'),
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
beforeAll(() => {
|
|
58
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'sdk-contract-'))
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
afterAll(() => {
|
|
62
|
+
rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('① 完整 Model 对象入注 → 组合结果字段等价(前提 7 主体)', () => {
|
|
66
|
+
it('内置基线全量入注:每个模型的全部字段与源对象一致', async () => {
|
|
67
|
+
const baseline = getBuiltinModels(PROVIDER)
|
|
68
|
+
expect(baseline.length).toBeGreaterThan(0)
|
|
69
|
+
|
|
70
|
+
const rt = await newRuntime()
|
|
71
|
+
rt.registerProvider(PROVIDER, { models: baseline.map((m) => ({ ...m })) })
|
|
72
|
+
|
|
73
|
+
const composed = rt.getModels(PROVIDER)
|
|
74
|
+
expect(composed.map((m) => m.id)).toEqual(baseline.map((m) => m.id))
|
|
75
|
+
for (const source of baseline) {
|
|
76
|
+
const got = rt.getModel(PROVIDER, source.id)
|
|
77
|
+
expect(got, `模型 ${source.id} 应在组合结果中`).toBeDefined()
|
|
78
|
+
// toEqual 忽略 undefined 属性——pi 硬置的 headers:undefined 由下一个用例单独断言
|
|
79
|
+
expect(got).toEqual(source)
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('超集属性:定义形状未声明的字段(enabled / lab / providers)同样保留', async () => {
|
|
84
|
+
// radius/balanced 是内置目录里可选字段最全的模型之一:thinkingLevelMap /
|
|
85
|
+
// inputLimits / enabled / lab / providers 俱全,其中后三者不在 ProviderChatModelConfig
|
|
86
|
+
// 的声明形状里——它们能存活即证明「Model 对象是定义形状的超集」。
|
|
87
|
+
const source = getBuiltinModels('radius').find((m) => m.id === 'balanced')
|
|
88
|
+
expect(source).toBeDefined()
|
|
89
|
+
|
|
90
|
+
const rt = await newRuntime()
|
|
91
|
+
rt.registerProvider('radius', { models: [{ ...source! }] })
|
|
92
|
+
|
|
93
|
+
const got = rt.getModel('radius', 'balanced')
|
|
94
|
+
expect(got).toEqual(source)
|
|
95
|
+
expect(got!.thinkingLevelMap).toEqual(source!.thinkingLevelMap)
|
|
96
|
+
expect(got!.inputLimits).toEqual(source!.inputLimits)
|
|
97
|
+
// 定义形状外的字段:靠 {...definition} 展开透传
|
|
98
|
+
expect((got as Record<string, unknown>).enabled).toBe((source as Record<string, unknown>).enabled)
|
|
99
|
+
expect((got as Record<string, unknown>).lab).toBe((source as Record<string, unknown>).lab)
|
|
100
|
+
expect((got as Record<string, unknown>).providers).toEqual((source as Record<string, unknown>).providers)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('规范化登记:组合对象的 headers 恒为 undefined,但请求期头解析仍读原始定义', async () => {
|
|
104
|
+
// github-copilot/claude-fable-5 是内置目录里少数自带 headers 的模型——用它才能观测到
|
|
105
|
+
// pi 对 headers 的硬置行为(无 headers 的模型上该字段本来就是 undefined,测不出差异)。
|
|
106
|
+
const source = getBuiltinModels('github-copilot').find((m) => m.id === 'claude-fable-5')
|
|
107
|
+
expect(source?.headers).toBeDefined()
|
|
108
|
+
|
|
109
|
+
const rt = await newRuntime()
|
|
110
|
+
rt.registerProvider('github-copilot', { models: [{ ...source! }] })
|
|
111
|
+
|
|
112
|
+
const got = rt.getModel('github-copilot', 'claude-fable-5')!
|
|
113
|
+
// 组合对象上 headers 被清空(pi 硬置)——这是唯一与源对象不一致的字段
|
|
114
|
+
expect(Object.prototype.hasOwnProperty.call(got, 'headers')).toBe(true)
|
|
115
|
+
expect(got.headers).toBeUndefined()
|
|
116
|
+
// 但请求期头解析读原始定义,功能无损失:bridge 侧无需为 headers 改注入构造
|
|
117
|
+
expect(rt.getCompatibilityRequestConfig(got).headers).toEqual(source!.headers)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('models 数组即最终可见集合(映射替换,非与内置层合并)——注入策略的直接依据', async () => {
|
|
121
|
+
const { rt, baseline } = await runtimeWithFullBaseline(PROVIDER, tmpDir)
|
|
122
|
+
|
|
123
|
+
// 只注一个模型 → 该 provider 的可见集合收缩为这一个(内置层未被保留)
|
|
124
|
+
swapToSingleExpectShrink(rt, PROVIDER, baseline[0])
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe('② 极简定义形状入注 → pi 展开语义(四级兜底继承路径)', () => {
|
|
129
|
+
/** 定义形状的必填最小集:id + name + input + cost + reasoning + contextWindow + maxTokens。 */
|
|
130
|
+
const minimal = (id: string) => ({
|
|
131
|
+
id,
|
|
132
|
+
name: id,
|
|
133
|
+
input: ['text' as const],
|
|
134
|
+
cost: { input: 1, output: 2 },
|
|
135
|
+
reasoning: false,
|
|
136
|
+
contextWindow: 4096,
|
|
137
|
+
maxTokens: 512,
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('四级兜底第 4 级:无同 id / 同 api / openai-completions 可继承 → 取该 provider 首个同类型模型', async () => {
|
|
141
|
+
const baseline = getBuiltinModels(PROVIDER)
|
|
142
|
+
const rt = await newRuntime()
|
|
143
|
+
rt.registerProvider(PROVIDER, { models: [minimal('Overlay-Only-New')] })
|
|
144
|
+
|
|
145
|
+
const got = rt.getModel(PROVIDER, 'Overlay-Only-New')!
|
|
146
|
+
// api / baseUrl 来自 candidates[0](首个同类型模型),非定义自带
|
|
147
|
+
expect(got.api).toBe(baseline[0].api)
|
|
148
|
+
expect(got.baseUrl).toBe(baseline[0].baseUrl)
|
|
149
|
+
// 定义自带的字段原样保留,未带的走 pi 缺省
|
|
150
|
+
expect(got.id).toBe('Overlay-Only-New')
|
|
151
|
+
expect(got.contextWindow).toBe(4096)
|
|
152
|
+
expect(got.provider).toBe(PROVIDER)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('四级兜底第 1 级:同 id 基线存在 → 从该模型继承 api / baseUrl', async () => {
|
|
156
|
+
const baseline = getBuiltinModels(PROVIDER)
|
|
157
|
+
const target = baseline[baseline.length - 1]
|
|
158
|
+
const rt = await newRuntime()
|
|
159
|
+
// 先注入完整基线,再以「同 id 但缺 api/baseUrl」的极简定义覆盖
|
|
160
|
+
rt.registerProvider(PROVIDER, fullBaselineConfig(PROVIDER))
|
|
161
|
+
rt.registerProvider(PROVIDER, { models: [minimal(target.id)] })
|
|
162
|
+
|
|
163
|
+
const got = rt.getModel(PROVIDER, target.id)!
|
|
164
|
+
expect(got.api).toBe(target.api)
|
|
165
|
+
expect(got.baseUrl).toBe(target.baseUrl)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('四级兜底第 2 级:provider 级 api 优先于兜底继承', async () => {
|
|
169
|
+
const rt = await newRuntime()
|
|
170
|
+
rt.registerProvider(PROVIDER, {
|
|
171
|
+
api: 'openai-completions',
|
|
172
|
+
models: [minimal('Provider-Api-Case')],
|
|
173
|
+
})
|
|
174
|
+
const got = rt.getModel(PROVIDER, 'Provider-Api-Case')!
|
|
175
|
+
expect(got.api).toBe('openai-completions')
|
|
176
|
+
// baseUrl 无 provider 级值 → 仍走兜底继承
|
|
177
|
+
expect(got.baseUrl).toBe(getBuiltinModels(PROVIDER)[0].baseUrl)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
// 四级兜底第 2/3 级的构造参数从内置目录动态选定(pi bump 目录数据变化时自适应):
|
|
181
|
+
// 要求基线内 chat 模型 ≥2 种 api,且目标级继承源 ≠ candidates[0](baseUrl 可区分
|
|
182
|
+
// ——否则断言与第 4 级不可区分)。构造条件不成立 = pi 目录数据形态变化,重验者
|
|
183
|
+
// 须重新核对 findExtensionModelDefaults 语义后重找构造。
|
|
184
|
+
const level23 = (() => {
|
|
185
|
+
for (const providerId of getBuiltinProviders()) {
|
|
186
|
+
const chat = getBuiltinModels(providerId).filter((m) => (m.type ?? 'chat') === 'chat')
|
|
187
|
+
const first = chat[0]
|
|
188
|
+
if (!first) continue
|
|
189
|
+
const openaiCompletions = chat.find((m) => m.api === 'openai-completions')
|
|
190
|
+
const level3 =
|
|
191
|
+
openaiCompletions && first.api !== 'openai-completions' && openaiCompletions.baseUrl !== first.baseUrl
|
|
192
|
+
? { openaiCompletions }
|
|
193
|
+
: null
|
|
194
|
+
let level2: { api: string; sameApi: (typeof chat)[number] } | null = null
|
|
195
|
+
for (const api of new Set(chat.map((m) => m.api))) {
|
|
196
|
+
const sameApi = chat.find((m) => m.api === api)
|
|
197
|
+
if (sameApi && sameApi.id !== first.id && sameApi.baseUrl !== first.baseUrl) {
|
|
198
|
+
level2 = { api, sameApi }
|
|
199
|
+
break
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (level2 && level3) return { providerId, first, level2, level3 }
|
|
203
|
+
}
|
|
204
|
+
return null
|
|
205
|
+
})()
|
|
206
|
+
|
|
207
|
+
it('四级兜底第 2 级:定义带 api → baseUrl 继承基线首个同 api 模型(非 candidates[0])', async () => {
|
|
208
|
+
expect(
|
|
209
|
+
level23,
|
|
210
|
+
'内置目录找不到「同 api 继承源 ≠ candidates[0]」的构造——pi 目录数据形态变化后须重核 findExtensionModelDefaults 语义并重找构造',
|
|
211
|
+
).not.toBeNull()
|
|
212
|
+
const { providerId, first, level2 } = level23!
|
|
213
|
+
const rt = await newRuntime()
|
|
214
|
+
rt.registerProvider(providerId, { models: [{ ...minimal('Overlay-L2-Case'), api: level2.api }] })
|
|
215
|
+
|
|
216
|
+
const got = rt.getModel(providerId, 'Overlay-L2-Case')!
|
|
217
|
+
// api 取定义自带值;baseUrl 走第 2 级(基线首个同 api 模型),不是 candidates[0]
|
|
218
|
+
expect(got.api).toBe(level2.api)
|
|
219
|
+
expect(got.baseUrl).toBe(level2.sameApi.baseUrl)
|
|
220
|
+
expect(got.baseUrl).not.toBe(first.baseUrl)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('四级兜底第 3 级:定义无 api、基线含 openai-completions → 继承首个 openai-completions 基线(非 candidates[0])', async () => {
|
|
224
|
+
expect(
|
|
225
|
+
level23,
|
|
226
|
+
'内置目录找不到「openai-completions 继承源 ≠ candidates[0]」的构造——pi 目录数据形态变化后须重核 findExtensionModelDefaults 语义并重找构造',
|
|
227
|
+
).not.toBeNull()
|
|
228
|
+
const { providerId, first, level3 } = level23!
|
|
229
|
+
expect(first.api).not.toBe('openai-completions') // 前置:candidates[0] 不经第 3 级命中,构造有效
|
|
230
|
+
const rt = await newRuntime()
|
|
231
|
+
rt.registerProvider(providerId, { models: [minimal('Overlay-L3-Case')] })
|
|
232
|
+
|
|
233
|
+
const got = rt.getModel(providerId, 'Overlay-L3-Case')!
|
|
234
|
+
// 定义与 provider 级 config 都无 api → api/baseUrl 都取基线首个 openai-completions 模型
|
|
235
|
+
expect(got.api).toBe('openai-completions')
|
|
236
|
+
expect(got.baseUrl).toBe(level3.openaiCompletions.baseUrl)
|
|
237
|
+
expect(got.baseUrl).not.toBe(first.baseUrl)
|
|
238
|
+
})
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
describe('③ 缺 api / baseUrl → validateExtensionProvider 抛错条件(R3 防御的实据)', () => {
|
|
242
|
+
const noApiNoBaseUrl = {
|
|
243
|
+
id: 'No-Api',
|
|
244
|
+
name: 'No-Api',
|
|
245
|
+
input: ['text' as const],
|
|
246
|
+
cost: { input: 0, output: 0 },
|
|
247
|
+
reasoning: false,
|
|
248
|
+
contextWindow: 1,
|
|
249
|
+
maxTokens: 1,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
it('该 provider 无任何同类型基线模型 → 四级兜底全空 → 抛错', async () => {
|
|
253
|
+
const rt = await newRuntime()
|
|
254
|
+
expect(getBuiltinModels('no-such-catalog-provider')).toEqual([])
|
|
255
|
+
expect(() =>
|
|
256
|
+
rt.registerProvider('no-such-catalog-provider', {
|
|
257
|
+
models: [noApiNoBaseUrl],
|
|
258
|
+
} satisfies ProviderConfigInput),
|
|
259
|
+
).toThrow(/no "api" specified/)
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('catalog provider 有基线 → 不抛错,静默从任意同 provider 模型继承(R3 保守档的理由)', async () => {
|
|
263
|
+
const rt = await newRuntime()
|
|
264
|
+
// pi 不抛:api/baseUrl 从 candidates[0] 兜底继承。设计 R3 因此不依赖 pi 的兜底——
|
|
265
|
+
// overlay-only 新模型经兜底继承到的 api/baseUrl 语义未经上游目录声明,宁可提前跳闸。
|
|
266
|
+
expect(() => rt.registerProvider(PROVIDER, { models: [noApiNoBaseUrl] })).not.toThrow()
|
|
267
|
+
const got = rt.getModel(PROVIDER, 'No-Api')!
|
|
268
|
+
expect(got.api).toBe(getBuiltinModels(PROVIDER)[0].api)
|
|
269
|
+
expect(got.baseUrl).toBe(getBuiltinModels(PROVIDER)[0].baseUrl)
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it('image 类型定义在无 image 基线的 provider 上 → 同样抛错(兜底按类型过滤)', async () => {
|
|
273
|
+
const rt = await newRuntime()
|
|
274
|
+
expect(() =>
|
|
275
|
+
rt.registerProvider(PROVIDER, {
|
|
276
|
+
models: [
|
|
277
|
+
{
|
|
278
|
+
type: 'image',
|
|
279
|
+
id: 'No-Api-Image',
|
|
280
|
+
name: 'No-Api-Image',
|
|
281
|
+
input: ['text'],
|
|
282
|
+
output: ['image'],
|
|
283
|
+
cost: { input: 0, output: 0 },
|
|
284
|
+
contextWindow: 1,
|
|
285
|
+
},
|
|
286
|
+
],
|
|
287
|
+
}),
|
|
288
|
+
).toThrow(/no "api" specified/)
|
|
289
|
+
})
|
|
290
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* provider-live-sync 测试的 tick 驱动共用件(catalog-bridge 与 model-sync 的同款夹具)。
|
|
3
|
+
*
|
|
4
|
+
* 驱动约定:fake timers 只替换 setTimeout/clearTimeout,tick 链上的 readFile 与
|
|
5
|
+
* setImmediate 保持真实(I/O 宏任务逐事件循环轮次自然落地)。
|
|
6
|
+
*
|
|
7
|
+
* 拍屏障:每拍等 `getTimerCount() >= 1`——本拍 tick 落地后其 `.finally(schedule)` 必然
|
|
8
|
+
* 注册下一个 fake timer,等它即证明本拍 readFile 链(真实线程池 I/O)已结束。固定轮次
|
|
9
|
+
* flush 不行:线程池完成回调的到达轮次不定,偶发「tick 晚于下一拍的 writeFileSync
|
|
10
|
+
* 完成 → 新内容被当首基线」假红。
|
|
11
|
+
*/
|
|
12
|
+
import { expect, vi } from 'vitest'
|
|
13
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
14
|
+
import providerLiveSync, { POLL_INTERVAL_MS } from '../index.ts'
|
|
15
|
+
|
|
16
|
+
/** 逐真实事件循环轮次(setImmediate 未被 fake)轮询等待谓词成立。 */
|
|
17
|
+
async function waitFor(predicate: () => boolean, maxTurns = 5_000): Promise<void> {
|
|
18
|
+
for (let i = 0; i < maxTurns && !predicate(); i += 1) {
|
|
19
|
+
await new Promise<void>((resolve) => setImmediate(resolve))
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 推进一个轮询周期并等本拍 tick 完整落地(拍屏障说明见模块头注释)。 */
|
|
24
|
+
export async function tickOnce(): Promise<void> {
|
|
25
|
+
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS)
|
|
26
|
+
await waitFor(() => vi.getTimerCount() >= 1)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 启动扩展并注入 fake modelRegistry 会话 ctx(tick 驱动用例的共同骨架):捕获
|
|
31
|
+
* session_start handler、以 `{ refresh, getError }` spy 的 ctx 调用它。`piStub` = 各
|
|
32
|
+
* 测试文件特有的 pi 宿主面(appendEntry / registerProvider 等 spy)。
|
|
33
|
+
*/
|
|
34
|
+
export function startWithRegistryCtx<T extends Record<string, unknown>>(
|
|
35
|
+
piStub: T,
|
|
36
|
+
): { refresh: ReturnType<typeof vi.fn>; on: ReturnType<typeof vi.fn> } {
|
|
37
|
+
const refresh = vi.fn(async () => ({ aborted: false, errors: new Map() }))
|
|
38
|
+
const getError = vi.fn(() => null)
|
|
39
|
+
const ctx = { modelRegistry: { refresh, getError } }
|
|
40
|
+
const on = vi.fn()
|
|
41
|
+
providerLiveSync({ ...piStub, on } as unknown as ExtensionAPI)
|
|
42
|
+
const handler = on.mock.calls.find((call) => call[0] === 'session_start')?.[1] as
|
|
43
|
+
| ((event: unknown, sessionCtx: unknown) => void)
|
|
44
|
+
| undefined
|
|
45
|
+
expect(handler).toBeTypeOf('function')
|
|
46
|
+
handler?.({ type: 'session_start' }, ctx)
|
|
47
|
+
return { refresh, on }
|
|
48
|
+
}
|