@aiti-ai/aiti-draft-keeper 0.0.0-stage → 0.1.7

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.

Potentially problematic release.


This version of @aiti-ai/aiti-draft-keeper might be problematic. Click here for more details.

@@ -0,0 +1,467 @@
1
+ /**
2
+ * DraftKeeperCore 状态机测试(node:test,假 storage + 真实微延时)。
3
+ * 覆盖:写入落盘、清空确认、发送提升(sessionCreated)与 promote 窗口竞态、
4
+ * 切换/刷新恢复、失败写回(rejected,含 alias 解析)、旧 per-tab 迁移。
5
+ */
6
+ import assert from "node:assert/strict";
7
+ import test from "node:test";
8
+ import { createKeeperCore, mergeSubmission } from "./core.mjs";
9
+
10
+ const DELAYS = { flushMs: 5, scanMs: 5, clearConfirmMs: 10 };
11
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
12
+
13
+ /** 槽位原文是 ts:base64(JSON);解码成 JSON 明文再断言 */
14
+ const decodeStored = (raw) => {
15
+ if (!raw) return null;
16
+ const b64 = raw.includes(":") ? raw.slice(raw.indexOf(":") + 1) : raw;
17
+ return new TextDecoder().decode(Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)));
18
+ };
19
+
20
+ function mockStorage(initial = {}) {
21
+ const map = new Map(Object.entries(initial));
22
+ return {
23
+ getItem: (k) => (map.has(k) ? map.get(k) : null),
24
+ setItem: (k, v) => { map.set(k, String(v)); },
25
+ removeItem: (k) => { map.delete(k); },
26
+ keys: () => [...map.keys()],
27
+ _map: map,
28
+ };
29
+ }
30
+
31
+ function makeCore(storage, { restores = [], log = () => {} } = {}) {
32
+ const restoreCalls = restores;
33
+ const core = createKeeperCore({
34
+ storage,
35
+ onRestore: (draft) => { restoreCalls.push({ ...draft }); return true; },
36
+ log,
37
+ delays: DELAYS,
38
+ });
39
+ return { core, restoreCalls };
40
+ }
41
+
42
+ const snap = (value, extra = {}) => ({
43
+ value,
44
+ images: [],
45
+ imageCount: 0,
46
+ hasInput: value.length > 0,
47
+ ...extra,
48
+ });
49
+
50
+ test("输入落盘(debounce 后 WRITE)", async () => {
51
+ const st = mockStorage();
52
+ const { core } = makeCore(st);
53
+ core.onKeyChange(undefined, "sid-1");
54
+ core.onChange(snap("hello"));
55
+ assert.equal(st.getItem("crashrestore-sid-1"), null); // 未到 flush 延时
56
+ await sleep(20);
57
+ assert.match(decodeStored(st.getItem("crashrestore-sid-1")), /hello/);
58
+ });
59
+
60
+ test("清空 → 延迟确认后清槽位;期间新输入则取消清除", async () => {
61
+ const st = mockStorage();
62
+ const { core } = makeCore(st);
63
+ core.onKeyChange(undefined, "sid-1");
64
+ core.onChange(snap("hello"));
65
+ await sleep(20);
66
+ assert.ok(st.getItem("crashrestore-sid-1"));
67
+ core.onChange(snap("")); // 发送/清空
68
+ await sleep(30);
69
+ assert.equal(st.getItem("crashrestore-sid-1"), null);
70
+
71
+ // 期间新输入 → 取消清除
72
+ core.onChange(snap("again"));
73
+ await sleep(20);
74
+ core.onChange(snap(""));
75
+ core.onChange(snap("typing")); // 10ms 确认窗内新输入
76
+ await sleep(30);
77
+ assert.match(decodeStored(st.getItem("crashrestore-sid-1")) ?? "", /typing/);
78
+ });
79
+
80
+ test("清空后切走(新 key 快照非空):确认链到期仍应清槽,草稿不复活", async () => {
81
+ // 回归:确认链裁决曾看全局 lastSnap——切走后它描述的是别的框(有内容),
82
+ // 已清除的槽被误判「仍有输入」而永久保留 → 切回后草稿复活。
83
+ const st = mockStorage();
84
+ const { core } = makeCore(st);
85
+ core.onKeyChange(undefined, "new:d9:/tmp/ws");
86
+ core.onChange(snap("abandoned draft"));
87
+ await sleep(20);
88
+ assert.ok(st.getItem("crashrestore-new:/tmp/ws"));
89
+
90
+ core.onChange(snap("")); // 用户清空 → 确认链启动
91
+ core.onKeyChange("new:d9:/tmp/ws", "sid-a"); // 立即切走
92
+ core.onChange(snap("other box content")); // 另一个框有内容
93
+ await sleep(40); // 确认链到期
94
+ assert.equal(st.getItem("crashrestore-new:/tmp/ws"), null, "已清除的槽应被确认链清掉,不被另一框内容干扰");
95
+
96
+ // 切回也不复活
97
+ core.onKeyChange("sid-a", "new:dz:/tmp/ws");
98
+ core.onChange(snap(""));
99
+ await sleep(40);
100
+ assert.equal(core.getDraft("new:dz:/tmp/ws"), null, "切回后不应恢复已放弃的草稿");
101
+ });
102
+
103
+ test("发送提升:sessionCreated 清 provisional 槽位,promote 窗口新输入落 sid", async () => {
104
+ const st = mockStorage();
105
+ const { core } = makeCore(st);
106
+ const pk = "new:d1:/tmp/ws";
107
+ core.onKeyChange(undefined, pk);
108
+ core.onChange(snap("the message"));
109
+ await sleep(20);
110
+ assert.ok(st.getItem("crashrestore-new:/tmp/ws")); // provisional 草稿已落盘
111
+
112
+ core.onChange(snap("")); // 发送 → clearInput 清框
113
+ core.applyIntent({ type: "sessionCreated", provisionalKey: pk, sessionId: "sid-9" });
114
+ await sleep(30); // 清空确认链稍后到:对已空槽位幂等
115
+ assert.equal(st.getItem("crashrestore-new:/tmp/ws"), null);
116
+
117
+ // promote 窗口:core 的 draftKey prop 还没切换,用户新输入 → 必须落 sid 槽位
118
+ core.onChange(snap("follow-up typing"));
119
+ await sleep(20);
120
+ assert.match(decodeStored(st.getItem("crashrestore-sid-9")) ?? "", /follow-up typing/);
121
+ assert.equal(st.getItem("crashrestore-new:/tmp/ws"), null);
122
+ });
123
+
124
+ test("发送提升竞态:清空确认链在 sessionCreated 之后到,不误清新输入", async () => {
125
+ const st = mockStorage();
126
+ const { core } = makeCore(st);
127
+ const pk = "new:d2:/tmp/ws";
128
+ core.onKeyChange(undefined, pk);
129
+ core.onChange(snap("msg"));
130
+ await sleep(20);
131
+ core.onChange(snap("")); // 清空确认启动(10ms 窗口)
132
+ core.applyIntent({ type: "sessionCreated", provisionalKey: pk, sessionId: "sid-8" });
133
+ core.onChange(snap("new draft right away")); // 3ms 后用户已打字
134
+ await sleep(40);
135
+ assert.match(decodeStored(st.getItem("crashrestore-sid-8")) ?? "", /new draft right away/);
136
+ assert.equal(st.getItem("crashrestore-new:/tmp/ws"), null);
137
+ });
138
+
139
+ test("切换会话再切回:草稿保留并恢复进框", async () => {
140
+ const st = mockStorage();
141
+ const { core, restoreCalls } = makeCore(st);
142
+ const pk = "new:d3:/tmp/ws";
143
+ core.onKeyChange(undefined, pk);
144
+ core.onChange(snap("switch survivor"));
145
+ await sleep(20);
146
+ core.onKeyChange(pk, "sid-other"); // 切走(flushAll 落盘)
147
+ core.onChange(snap("")); // 新会话空框
148
+ await sleep(30);
149
+ assert.match(decodeStored(st.getItem("crashrestore-new:/tmp/ws")) ?? "", /switch survivor/);
150
+ core.onKeyChange("sid-other", "new:d4:/tmp/ws"); // 切回新建页(新 draftId,同 cwd 族槽位)
151
+ core.onChange(snap(""));
152
+ await sleep(40);
153
+ assert.ok(restoreCalls.some((r) => r.value === "switch survivor"), "scan 应恢复草稿进框");
154
+ assert.match(decodeStored(st.getItem("crashrestore-new:/tmp/ws")) ?? "", /switch survivor/, "恢复后槽位必须保留(防恢复即删回归)");
155
+ });
156
+
157
+ test("同 key 切回(同一 session):清屏后草稿恢复进框且槽位保留", async () => {
158
+ // 回归:切回同 key 时旧实现 known 保留旧值 → scan 的 slotVal !== knownVal 恒假:
159
+ // 不恢复,且随后的空快照走清空确认链把槽位删掉(「切换丢草稿」根因)。
160
+ // 旧测试用新 draftId 切回(known 天然为 null)绕开了这条路径,这里钉死同 key。
161
+ const st = mockStorage();
162
+ const { core, restoreCalls } = makeCore(st);
163
+ core.onKeyChange(undefined, "sid-k");
164
+ core.onChange(snap("survivor for sid-k"));
165
+ await sleep(20);
166
+ assert.ok(st.getItem("crashrestore-sid-k"));
167
+
168
+ core.onKeyChange("sid-k", "sid-other"); // 切走
169
+ core.onChange(snap("") ); // 新会话清屏
170
+ await sleep(30);
171
+ assert.ok(st.getItem("crashrestore-sid-k"), "切走后原槽位保留");
172
+
173
+ core.onKeyChange("sid-other", "sid-k"); // 切回同一个 key
174
+ core.onChange(snap("")); // 清屏快照
175
+ await sleep(40);
176
+ assert.ok(restoreCalls.some((r) => r.value === "survivor for sid-k"), "同 key 切回应恢复草稿进框");
177
+ assert.match(decodeStored(st.getItem("crashrestore-sid-k")) ?? "", /survivor for sid-k/, "恢复后槽位必须保留");
178
+ });
179
+
180
+ test("同 key 重挂(ChatWindow 重挂/扩展晚注册补发):草稿恢复且不被清空确认链误删", async () => {
181
+ const st = mockStorage();
182
+ const { core, restoreCalls } = makeCore(st);
183
+ core.onKeyChange(undefined, "sid-r");
184
+ core.onChange(snap("remount survivor"));
185
+ await sleep(20);
186
+
187
+ core.onKeyChange(undefined, "sid-r"); // 重挂:ChatInput 挂载发 (undefined, K)
188
+ core.onChange(snap("")); // 挂载清屏快照
189
+ await sleep(40);
190
+ assert.ok(restoreCalls.some((r) => r.value === "remount survivor"), "重挂后应恢复草稿进框");
191
+ assert.match(decodeStored(st.getItem("crashrestore-sid-r")) ?? "", /remount survivor/, "恢复后槽位必须保留");
192
+ });
193
+
194
+ test("发送清槽路径不受 known 失效影响:发送后确认链正常清槽", async () => {
195
+ const st = mockStorage();
196
+ const { core } = makeCore(st);
197
+ core.onKeyChange(undefined, "sid-c");
198
+ core.onChange(snap("to be sent"));
199
+ await sleep(20);
200
+ assert.ok(st.getItem("crashrestore-sid-c"));
201
+ core.onChange(snap("")); // 发送 → 清空确认链(不走 onKeyChange)
202
+ await sleep(30);
203
+ assert.equal(st.getItem("crashrestore-sid-c"), null, "发送后槽位应被确认链清除");
204
+ });
205
+
206
+ test("发送失败 rejected:merge 进现有草稿并写回框", async () => {
207
+ const st = mockStorage();
208
+ const { core, restoreCalls } = makeCore(st);
209
+ const sid = "sid-r";
210
+ core.onKeyChange(undefined, sid);
211
+ core.onChange(snap("old draft"));
212
+ await sleep(20);
213
+ core.applyIntent({ type: "rejected", key: sid, text: "failed submission" });
214
+ await sleep(20);
215
+ const stored = JSON.parse(decodeStored(st.getItem("crashrestore-sid-r")) ?? "{}");
216
+ assert.match(stored.value, /failed submission/);
217
+ assert.match(stored.value, /old draft/);
218
+ assert.ok(restoreCalls.some((r) => r.value.includes("failed submission")), "当前框应写回合并结果");
219
+ });
220
+
221
+ test("rejected 经 alias 解析:promote 后对 provisional key 的失败写回落 sid", async () => {
222
+ const st = mockStorage();
223
+ const { core, restoreCalls } = makeCore(st);
224
+ const pk = "new:d5:/tmp/ws";
225
+ core.onKeyChange(undefined, pk);
226
+ core.onChange(snap("first"));
227
+ await sleep(20);
228
+ core.onChange(snap(""));
229
+ core.applyIntent({ type: "sessionCreated", provisionalKey: pk, sessionId: "sid-7" });
230
+ core.applyIntent({ type: "rejected", key: pk, text: "was rejected" }); // core 仍持 provisional key
231
+ await sleep(20);
232
+ assert.match(decodeStored(st.getItem("crashrestore-sid-7")) ?? "", /was rejected/);
233
+ assert.ok(restoreCalls.some((r) => r.value.includes("was rejected")));
234
+ });
235
+
236
+ test("刷新模拟:新实例同 storage,打开即 scan 恢复", async () => {
237
+ const st = mockStorage();
238
+ const { core: coreA } = makeCore(st);
239
+ coreA.onKeyChange(undefined, "new:d6:/tmp/ws");
240
+ coreA.onChange(snap("survives reload"));
241
+ await sleep(20);
242
+ coreA.dispose();
243
+
244
+ const { core: coreB, restoreCalls } = makeCore(st);
245
+ coreB.onKeyChange(undefined, "new:d7:/tmp/ws"); // 新 draftId,同 cwd 族
246
+ coreB.onChange(snap(""));
247
+ await sleep(30);
248
+ assert.ok(restoreCalls.some((r) => r.value === "survives reload"));
249
+ });
250
+
251
+ test("旧 per-tab 槽位迁移:crashrestore-<key>-<tabId> → 共享槽位", async () => {
252
+ const st = mockStorage();
253
+ const legacy = Buffer.from(JSON.stringify({ value: "legacy text", images: [] }), "utf8").toString("base64");
254
+ st.setItem("crashrestore-new:oldtab:/tmp/ws-tab42", `123:${legacy}`);
255
+ const { core, restoreCalls } = makeCore(st);
256
+ core.onKeyChange(undefined, "new:any:/tmp/ws");
257
+ core.onChange(snap(""));
258
+ await sleep(30);
259
+ assert.ok(restoreCalls.some((r) => r.value === "legacy text"), "迁移后应恢复");
260
+ assert.match(decodeStored(st.getItem("crashrestore-new:/tmp/ws")) ?? "", /legacy text/);
261
+ assert.equal(st.getItem("crashrestore-new:oldtab:/tmp/ws-tab42"), null, "旧槽位应被清理");
262
+ });
263
+
264
+ test("mergeSubmission 语义:提交在前、原稿在后、重复不加", () => {
265
+ assert.equal(mergeSubmission("failed", [], "old", []).value, "failed\n\nold");
266
+ assert.equal(mergeSubmission("same", [], "same", []).value, "same");
267
+ assert.equal(mergeSubmission("only", [], "", []).value, "only");
268
+ });
269
+
270
+ test("suppress 快照不写不清", async () => {
271
+ const st = mockStorage();
272
+ const { core } = makeCore(st);
273
+ core.onKeyChange(undefined, "sid-s");
274
+ core.onChange(snap("temp prefill", { suppress: true }));
275
+ await sleep(20);
276
+ assert.equal(st.getItem("crashrestore-sid-s"), null);
277
+ core.onChange(snap("", { suppress: true }));
278
+ await sleep(30);
279
+ assert.equal(st.getItem("crashrestore-sid-s"), null);
280
+ });
281
+
282
+ test("发送清空确认窗口内 scan 不复活刚发走的草稿(edit-from-here 发送链路)", async () => {
283
+ const st = mockStorage();
284
+ const { core, restoreCalls } = makeCore(st);
285
+ core.onKeyChange(undefined, "sid-1");
286
+ core.onChange(snap(""));
287
+ // suppress 被宿主旧广播击穿的形态:预填文本落进槽位
288
+ core.onChange(snap("MSG"));
289
+ await sleep(10); // flush 落盘
290
+ assert.ok(st.getItem("crashrestore-sid-1"));
291
+ // 发送:清空 + enhancersVersion 补偿 effect 重跑(onKeyChange(S,S) 重挂 scan)
292
+ core.onChange(snap(""));
293
+ core.onKeyChange("sid-1", "sid-1");
294
+ core.onChange(snap("")); // pendingScan → scan:确认窗口内必须否决
295
+ assert.equal(restoreCalls.length, 0); // 不恢复
296
+ await sleep(30); // 清空确认到期
297
+ assert.equal(st.getItem("crashrestore-sid-1"), null); // 槽位已清
298
+ });
299
+
300
+ test("确认窗口过后 scan 恢复能力不受否决影响(他窗新写的草稿仍能同步进框)", async () => {
301
+ const st = mockStorage();
302
+ const { core, restoreCalls } = makeCore(st);
303
+ core.onKeyChange(undefined, "sid-2");
304
+ core.onChange(snap(""));
305
+ core.onChange(snap("draft"));
306
+ await sleep(10);
307
+ core.onChange(snap("")); // 清空 → 确认窗口
308
+ core.onKeyChange("sid-2", "sid-2");
309
+ core.onChange(snap("")); // 窗口内 scan:否决
310
+ assert.equal(restoreCalls.length, 0);
311
+ await sleep(30); // 确认走完,槽位已清
312
+ assert.equal(st.getItem("crashrestore-sid-2"), null);
313
+ // 他窗后来写入的新草稿 → 仍能正常恢复
314
+ st.setItem("crashrestore-sid-2", `${Date.now()}:${btoa(JSON.stringify({ value: "from other window", images: [] }))}`);
315
+ core.onKeyChange("sid-2", "sid-2");
316
+ core.onChange(snap(""));
317
+ assert.equal(restoreCalls.length, 1);
318
+ assert.equal(restoreCalls[0].value, "from other window");
319
+ });
320
+
321
+ test("clearAllDrafts 清本会话全部槽位(含旧 per-tab 残留)", async () => {
322
+ const st = mockStorage();
323
+ const { core } = makeCore(st);
324
+ core.onKeyChange(undefined, "sid-x");
325
+ core.onChange(snap("live"));
326
+ await sleep(20);
327
+ st.setItem("crashrestore-sid-x-tab1", "1:abcd");
328
+ core.clearAllDrafts("sid-x");
329
+ assert.equal(st.getItem("crashrestore-sid-x"), null);
330
+ assert.equal(st.getItem("crashrestore-sid-x-tab1"), null);
331
+ });
332
+
333
+ test("pruneStale:过期槽位清理、新槽位保留、活跃会话不受影响", async () => {
334
+ const st = mockStorage();
335
+ const { core } = makeCore(st);
336
+ // 活跃槽位(新写入)
337
+ core.onKeyChange(undefined, "sid-live");
338
+ core.onChange(snap("live draft"));
339
+ await sleep(20);
340
+ // 8 天前的陈旧槽位 + 无 ts 旧格式
341
+ const old = Buffer.from(JSON.stringify({ value: "stale", images: [] }), "utf8").toString("base64");
342
+ st.setItem("crashrestore-sid-old", `${Date.now() - 8 * 24 * 60 * 60 * 1000}:${old}`);
343
+ st.setItem("crashrestore-sid-nots", old); // 无 ts 前缀 → 旧格式,清
344
+ const removed = core.pruneStale();
345
+ assert.ok(removed >= 2, `应清理 ≥2 条,实际 ${removed}`);
346
+ assert.equal(st.getItem("crashrestore-sid-old"), null);
347
+ assert.equal(st.getItem("crashrestore-sid-nots"), null);
348
+ assert.ok(st.getItem("crashrestore-sid-live"), "活跃槽位保留");
349
+ });
350
+
351
+ test("sessionCreated 后 core 的 key 广播(pk→sid)幂等:重定向不破坏、新输入仍落 sid", async () => {
352
+ const st = mockStorage();
353
+ const { core, restoreCalls } = makeCore(st);
354
+ const pk = "new:d9:/tmp/ws";
355
+ core.onKeyChange(undefined, pk);
356
+ core.onChange(snap("sent msg"));
357
+ await sleep(20);
358
+ core.onChange(snap(""));
359
+ core.applyIntent({ type: "sessionCreated", provisionalKey: pk, sessionId: "sid-6" });
360
+ // React 稍后补发 key 广播(enhancersVersion 补偿 / draftKey prop 切换)
361
+ core.onKeyChange(pk, "sid-6");
362
+ core.onChange(snap("")); // remount 空框
363
+ await sleep(30);
364
+ assert.equal(st.getItem("crashrestore-new:/tmp/ws"), null, "provisional 保持已清");
365
+ core.onChange(snap("typed after promote"));
366
+ await sleep(20);
367
+ assert.match(decodeStored(st.getItem("crashrestore-sid-6")) ?? "", /typed after promote/);
368
+ assert.ok(restoreCalls.every(r => !r.value.includes("sent msg")), "已发送消息不得复活");
369
+ });
370
+
371
+ test("sessionCreated 未发生(旧 core 回退路径)时 clearDraft 也清 provisional", async () => {
372
+ const st = mockStorage();
373
+ const { core } = makeCore(st);
374
+ const pk = "new:da:/tmp/ws";
375
+ core.onKeyChange(undefined, pk);
376
+ core.onChange(snap("legacy path"));
377
+ await sleep(20);
378
+ core.clearDraft(pk); // deprecated v1 方法(applyDraftIntent 回退用)
379
+ await sleep(20);
380
+ assert.equal(st.getItem("crashrestore-new:/tmp/ws"), null);
381
+ });
382
+
383
+ test("slotKeyOf 归一化:draftId 自身含冒号(?cwd= 引导的 initial:<cwd>)不污染 cwd 段", async () => {
384
+ const st = mockStorage();
385
+ const { core, restoreCalls } = makeCore(st);
386
+ // ?cwd= 引导:draftId = initial:/tmp/ws → draftKey 含双份 cwd
387
+ core.onKeyChange(undefined, "new:initial:/tmp/ws:/tmp/ws");
388
+ core.onChange(snap("guided entry"));
389
+ await sleep(20);
390
+ // 普通 uuid draftId 的新建页(用户日常路径)必须读到同一份草稿
391
+ const st2keys = () => [...st._map.keys()];
392
+ assert.ok(st2keys().some(k => k === "crashrestore-new:/tmp/ws"), `应归一化到共享槽位,实际 ${st2keys()}`);
393
+ core.onKeyChange("new:initial:/tmp/ws:/tmp/ws", "new:uuid-1:/tmp/ws");
394
+ core.onChange(snap(""));
395
+ await sleep(30);
396
+ assert.ok(restoreCalls.some(r => r.value === "guided entry"), "日常路径应恢复引导入口写的草稿");
397
+ });
398
+
399
+ test("废弃 draftId 残留写回修复:发送后 flushAll 不再污染共享槽位", async () => {
400
+ // 回归:归一化槽位 crashrestore-new:<cwd> 按 cwd 共享,drafts 却按 draftId 存。——
401
+ // 旧实现 onKeyChange 只 flushAll 不清理废弃 new:* 副本,发送后 sessionCreated 只清当前
402
+ // pk,导致废弃 draftId 的残留经 flushAll 写回共享槽位(新建会话发送后残留)。
403
+ const st = mockStorage();
404
+ const { core } = makeCore(st);
405
+ // 废弃 draftId,输入后切走(旧实现:drafts[old1] 残留内存)
406
+ core.onKeyChange(undefined, "new:old1:/tmp/ws");
407
+ core.onChange(snap("stale leftover"));
408
+ await sleep(20);
409
+ assert.ok(st.getItem("crashrestore-new:/tmp/ws"), "废弃草稿先落盘一次");
410
+ core.onKeyChange("new:old1:/tmp/ws", "sid-other"); // 切走 → 旧实现残留 drafts[old1]
411
+ // 当前新建页(新 draftId)输入并发送
412
+ core.onKeyChange("sid-other", "new:cur:/tmp/ws");
413
+ core.onChange(snap("fresh"));
414
+ await sleep(20);
415
+ core.onChange(snap(""));
416
+ core.applyIntent({ type: "sessionCreated", provisionalKey: "new:cur:/tmp/ws", sessionId: "sid-9" });
417
+ core.onKeyChange("new:cur:/tmp/ws", "sid-9");
418
+ await sleep(40); // 确认链 + 落盘全部走完
419
+ assert.equal(st.getItem("crashrestore-new:/tmp/ws"), null, "废弃 draftId 的残留不得写回共享槽位");
420
+ });
421
+
422
+ test("清空立即清挂起 flush:确认窗内 onKeyChange 不写回(发送即清的根因回归)", async () => {
423
+ const st = mockStorage();
424
+ const { core } = makeCore(st);
425
+ core.onKeyChange(undefined, "new:w1:/tmp/ws");
426
+ core.onChange(snap("to send"));
427
+ // 不等待 flush 落盘:立即清空(发送)。修复后 onChange("") 应清掉挂起的 flush 定时器
428
+ // 与内存副本,使槽位从未落盘(旧实现:留给 5ms 后 flush 落盘 → 残留)。
429
+ core.onChange(snap(""));
430
+ assert.equal(st.getItem("crashrestore-new:/tmp/ws"), null, "清空即清挂起 flush,槽位不落盘");
431
+ // 确认链窗口内立即切走(onKeyChange 内 flushAll):不得把已清除草稿写回
432
+ core.onKeyChange("new:w1:/tmp/ws", "sid-9");
433
+ assert.equal(st.getItem("crashrestore-new:/tmp/ws"), null, "确认窗内 flushAll 不得写回已清除草稿");
434
+ await sleep(40); // 确认链到期 → clearSlot 幂等
435
+ assert.equal(st.getItem("crashrestore-new:/tmp/ws"), null, "确认链到期后槽位保持已清");
436
+ });
437
+
438
+ test("废弃 new:* 内存副本随 onKeyChange 清理:切回经 scan 恢复仍不丢", async () => {
439
+ const st = mockStorage();
440
+ const { core, restoreCalls } = makeCore(st);
441
+ core.onKeyChange(undefined, "new:keep:/tmp/ws");
442
+ core.onChange(snap("keep me"));
443
+ await sleep(20);
444
+ core.onKeyChange("new:keep:/tmp/ws", "sid-a"); // 切走:flushAll 落盘 + 清内存副本
445
+ // 切回(新 draftId),scan 从共享槽位恢复
446
+ core.onKeyChange("sid-a", "new:any:/tmp/ws");
447
+ core.onChange(snap(""));
448
+ await sleep(40);
449
+ assert.ok(restoreCalls.some((r) => r.value === "keep me"), "切回应经 scan 恢复,不丢草稿");
450
+ assert.match(decodeStored(st.getItem("crashrestore-new:/tmp/ws")) ?? "", /keep me/, "恢复后槽位保留");
451
+ });
452
+
453
+ test("中文/混合草稿编码无损(TextEncoder→btoa 链路)", async () => {
454
+ const st = mockStorage();
455
+ const cn = "外部 agent 的 modesl切换支持。1、拿到外部的models 🤖 emoji too";
456
+ const { core: coreA } = makeCore(st);
457
+ coreA.onKeyChange(undefined, "sid-cn");
458
+ coreA.onChange(snap(cn));
459
+ await sleep(20);
460
+ coreA.dispose();
461
+ // 刷新模拟:新实例同 key 打开
462
+ const { core: coreB, restoreCalls } = makeCore(st);
463
+ coreB.onKeyChange(undefined, "sid-cn");
464
+ coreB.onChange(snap(""));
465
+ await sleep(30);
466
+ assert.ok(restoreCalls.some((r) => r.value === cn), "中文+emoji 草稿应无损恢复");
467
+ });
@@ -0,0 +1,52 @@
1
+ /**
2
+ * deploy 脚本共享「部署状态 commit」助手(pi-web-ext 各扩展 tools/deploy.mjs 统一用)。
3
+ *
4
+ * 背景(2026-08-30 排障,session 0cc6ad47 op264):重复部署同内容时
5
+ * `git add -A && git commit` 以 "nothing to commit" exit 1,而 stdio:"ignore" 吞掉
6
+ * stderr,输出「提交失败(忽略):Command failed: …」假报警(msg-smart 实证);
7
+ * 另有 --allow-empty 族每次部署都造空 commit 噪音。本助手统一三态语义:
8
+ * committed → 有暂存变更,已提交
9
+ * clean → 无变更,跳过(不算失败)
10
+ * failed → git 真失败(带 stderr 首行,不阻断部署——部署本体永远优先)
11
+ *
12
+ * 用法:
13
+ * import { deployCommit } from "../../_shared/deploy-commit.mjs";
14
+ * deployCommit({ repo: extRepo, name: "aiti-msg-smart", paths: ["aiti-msg-smart"],
15
+ * log: (m) => console.log("[deploy] " + m) });
16
+ *
17
+ * 红线:本助手永不 throw、不触碰部署产物路径之外的任何东西;只对 <repo> 做
18
+ * add/diff/commit(默认 --no-verify,extRepo 无 hooks,防未来误挂)。
19
+ */
20
+ import { execFileSync } from "node:child_process";
21
+
22
+ export function deployCommit({ repo, name, paths = null, extraMsg = "", ident = false, log = console.log }) {
23
+ const git = (args) =>
24
+ execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
25
+ const firstLine = (s) => String(s || "").trim().split("\n")[0];
26
+ try {
27
+ // 暂存:默认只收本扩展子目录(与 add <ext> 行为对齐;避免把别的扩展的
28
+ // 未提交改动卷进本扩展的部署 commit);paths=null 时退回 add -A 全仓。
29
+ git(["add", ...(paths && paths.length ? ["--", ...paths] : ["-A"])]);
30
+ let dirty;
31
+ try {
32
+ git(["diff", "--cached", "--quiet"]);
33
+ dirty = false; // exit 0 = 暂存区与 HEAD 一致
34
+ } catch (e) {
35
+ if (e.status !== 1) throw e; // 1 = 有变更;其他才是真错误
36
+ dirty = true;
37
+ }
38
+ if (!dirty) {
39
+ log("扩展仓库无变化(跳过提交)");
40
+ return { status: "clean" };
41
+ }
42
+ const stamp = new Date().toISOString().replace("T", " ").slice(0, 19);
43
+ const msg = `[deploy] ${name} · ${stamp}${extraMsg ? " · " + extraMsg : ""}`;
44
+ git([...(ident ? ["-c", "user.name=deploy", "-c", "user.email=deploy@local"] : []), "commit", "--no-verify", "-m", msg]);
45
+ log("扩展仓库已提交:" + msg);
46
+ return { status: "committed", msg };
47
+ } catch (e) {
48
+ const reason = firstLine(e.stderr) || firstLine(e.message) || "git error";
49
+ log("提交失败(忽略,不影响部署):" + reason);
50
+ return { status: "failed", reason };
51
+ }
52
+ }