Skip to content

Commit 462a25e

Browse files
committed
✅ 补充 synchronize 测试并保留 orphan 状态
- 新增 deleteScript/pushScript 等待 digest 更新的回归测试 - 新增 orphan uuid cloudStatus 保留的回归测试 - syncOnce 加 try/catch 避免错误冒泡破坏队列后续任务 - 跳过 orphan 时保留其云端 status,避免覆盖另一台设备的半上传状态
1 parent 652e9a0 commit 462a25e

2 files changed

Lines changed: 243 additions & 24 deletions

File tree

src/app/service/service_worker/synchronize.test.ts

Lines changed: 224 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ const createFs = (overrides: Partial<FileSystem> = {}): FileSystem =>
2929
...overrides,
3030
}) as unknown as FileSystem;
3131

32+
// 等待若干轮微任务,确保所有已就绪的 await 都被推进
33+
const flushMicrotasks = async (rounds = 10) => {
34+
for (let i = 0; i < rounds; i++) {
35+
await Promise.resolve();
36+
}
37+
};
38+
3239
describe("SynchronizeService", () => {
3340
beforeEach(() => {
3441
vi.clearAllMocks();
@@ -41,25 +48,26 @@ describe("SynchronizeService", () => {
4148
releaseFirst = resolve;
4249
});
4350
const order: string[] = [];
44-
const fs1 = createFs({
45-
list: vi
46-
.fn()
47-
.mockImplementationOnce(async () => {
48-
order.push("first:start");
49-
await firstGate;
50-
order.push("first:end");
51-
return [];
52-
})
53-
.mockResolvedValue([]),
54-
});
51+
// gate 放在第一轮的最后一步(updateFileDigest 内部的 fs.list)
52+
// 这样如果未来锁粒度被改小,第二轮提前进入也会被这个测试捕获
53+
const fs1List = vi
54+
.fn()
55+
.mockImplementationOnce(async () => {
56+
order.push("first:list");
57+
return [];
58+
})
59+
.mockImplementationOnce(async () => {
60+
order.push("first:digest");
61+
await firstGate;
62+
order.push("first:end");
63+
return [];
64+
});
65+
const fs1 = createFs({ list: fs1List });
5566
const fs2 = createFs({
56-
list: vi
57-
.fn()
58-
.mockImplementationOnce(async () => {
59-
order.push("second:start");
60-
return [];
61-
})
62-
.mockResolvedValue([]),
67+
list: vi.fn().mockImplementation(async () => {
68+
order.push("second:list");
69+
return [];
70+
}),
6371
});
6472
const service = new SynchronizeService(
6573
{} as any,
@@ -76,17 +84,18 @@ describe("SynchronizeService", () => {
7684
);
7785

7886
const first = service.syncOnce(syncConfig, fs1);
79-
await Promise.resolve();
8087
const second = service.syncOnce(syncConfig, fs2);
81-
await Promise.resolve();
88+
await flushMicrotasks();
8289

83-
expect(order).toEqual(["first:start"]);
90+
// 第一轮已经跑到末尾的 updateFileDigest,第二轮一步都没开始
91+
expect(order).toEqual(["first:list", "first:digest"]);
8492
expect((fs2.list as any).mock.calls.length).toBe(0);
8593

8694
releaseFirst();
8795
await Promise.all([first, second]);
8896

89-
expect(order).toEqual(["first:start", "first:end", "second:start"]);
97+
// 第一轮整体结束(first:end)后第二轮才能开始(second:list)
98+
expect(order.slice(0, 4)).toEqual(["first:list", "first:digest", "first:end", "second:list"]);
9099
});
91100

92101
it("does not delete orphan cloud script without meta", async () => {
@@ -121,6 +130,61 @@ describe("SynchronizeService", () => {
121130
expect(fs.delete).not.toHaveBeenCalled();
122131
});
123132

133+
it("preserves cloudStatus for skipped orphan uuid when writing scriptcat-sync.json", async () => {
134+
const orphanStatus = { enable: false, sort: 7, updatetime: 100 };
135+
const writeMock = vi.fn().mockResolvedValue(undefined);
136+
const fs = createFs({
137+
list: vi.fn().mockResolvedValue([
138+
{
139+
name: "orphan.user.js",
140+
path: "orphan.user.js",
141+
size: 1,
142+
digest: "d1",
143+
createtime: 1,
144+
updatetime: 1,
145+
},
146+
{
147+
name: "scriptcat-sync.json",
148+
path: "scriptcat-sync.json",
149+
size: 1,
150+
digest: "d2",
151+
createtime: 1,
152+
updatetime: 1,
153+
},
154+
]),
155+
open: vi.fn().mockResolvedValue({
156+
read: vi.fn().mockResolvedValue(
157+
JSON.stringify({
158+
version: "1.0.0",
159+
status: { scripts: { orphan: orphanStatus } },
160+
})
161+
),
162+
}),
163+
create: vi.fn().mockResolvedValue({ write: writeMock }),
164+
});
165+
const service = new SynchronizeService(
166+
{} as any,
167+
{} as any,
168+
{} as any,
169+
{} as any,
170+
{} as any,
171+
{} as any,
172+
{} as any,
173+
{
174+
scriptCodeDAO: {},
175+
all: vi.fn().mockResolvedValue([]),
176+
} as any
177+
);
178+
179+
await service.syncOnce(syncConfig, fs);
180+
181+
// 第一次 write 是 scriptcat-sync.json 的内容
182+
expect(writeMock).toHaveBeenCalled();
183+
const writtenContent = writeMock.mock.calls[0][0] as string;
184+
const written = JSON.parse(writtenContent);
185+
expect(written.status.scripts.orphan).toEqual(orphanStatus);
186+
});
187+
124188
it("waits for installScript during pullScript", async () => {
125189
let releaseInstall!: () => void;
126190
const installGate = new Promise<void>((resolve) => {
@@ -193,4 +257,142 @@ console.log("ok");`
193257
expect(installScript).toHaveBeenCalledTimes(1);
194258
expect(settled).toBe(true);
195259
});
260+
261+
it("waits for deleteScript before updating file digest", async () => {
262+
let releaseDelete!: () => void;
263+
const deleteGate = new Promise<void>((resolve) => {
264+
releaseDelete = resolve;
265+
});
266+
const order: string[] = [];
267+
const deleteScript = vi.fn().mockImplementation(async () => {
268+
order.push("delete:start");
269+
await deleteGate;
270+
order.push("delete:end");
271+
});
272+
// fs.list 第二次调用对应 updateFileDigest,这是 syncOnce 的最后一步
273+
const fsList = vi
274+
.fn()
275+
.mockImplementationOnce(async () => [
276+
{
277+
name: "del-uuid.meta.json",
278+
path: "del-uuid.meta.json",
279+
size: 1,
280+
digest: "d1",
281+
createtime: 1,
282+
updatetime: 1,
283+
},
284+
])
285+
.mockImplementationOnce(async () => {
286+
order.push("digest:list");
287+
return [];
288+
});
289+
const fs = createFs({
290+
list: fsList,
291+
open: vi.fn().mockResolvedValue({
292+
read: vi.fn().mockResolvedValue(JSON.stringify({ uuid: "del-uuid", isDeleted: true })),
293+
}),
294+
});
295+
const service = new SynchronizeService(
296+
{} as any,
297+
{} as any,
298+
{ deleteScript } as any,
299+
{} as any,
300+
{} as any,
301+
{} as any,
302+
{} as any,
303+
{
304+
scriptCodeDAO: {},
305+
all: vi.fn().mockResolvedValue([
306+
{
307+
uuid: "del-uuid",
308+
name: "del",
309+
updatetime: 1,
310+
createtime: 1,
311+
status: 1,
312+
sort: 0,
313+
metadata: {},
314+
},
315+
]),
316+
} as any
317+
);
318+
319+
const promise = service.syncOnce(syncConfig, fs);
320+
await flushMicrotasks();
321+
322+
// delete 已经开始但没结束,updateFileDigest 还没被调用
323+
expect(order).toEqual(["delete:start"]);
324+
325+
releaseDelete();
326+
await promise;
327+
328+
// delete 必须在 updateFileDigest 之前完成
329+
expect(order).toEqual(["delete:start", "delete:end", "digest:list"]);
330+
});
331+
332+
it("waits for pushScript before updating file digest", async () => {
333+
let releasePush!: () => void;
334+
const pushGate = new Promise<void>((resolve) => {
335+
releasePush = resolve;
336+
});
337+
const order: string[] = [];
338+
// pushScript 内部第一步是 fs.create(uuid.user.js),gate 在这里就能拦住整个 push
339+
const fsCreate = vi.fn().mockImplementation(async (filename: string) => {
340+
if (filename === "push-uuid.user.js") {
341+
order.push("push:start");
342+
await pushGate;
343+
order.push("push:end");
344+
}
345+
return { write: vi.fn().mockResolvedValue(undefined) };
346+
});
347+
const fsList = vi
348+
.fn()
349+
.mockImplementationOnce(async () => [])
350+
.mockImplementationOnce(async () => {
351+
order.push("digest:list");
352+
return [];
353+
});
354+
const fs = createFs({
355+
list: fsList,
356+
create: fsCreate,
357+
});
358+
const service = new SynchronizeService(
359+
{} as any,
360+
{} as any,
361+
{} as any,
362+
{} as any,
363+
{} as any,
364+
{} as any,
365+
{} as any,
366+
{
367+
scriptCodeDAO: {
368+
get: vi.fn().mockResolvedValue({ code: "// code" }),
369+
},
370+
all: vi.fn().mockResolvedValue([
371+
{
372+
uuid: "push-uuid",
373+
name: "push",
374+
updatetime: 1,
375+
createtime: 1,
376+
status: 1,
377+
sort: 0,
378+
metadata: {},
379+
},
380+
]),
381+
} as any
382+
);
383+
384+
const promise = service.syncOnce(syncConfig, fs);
385+
await flushMicrotasks();
386+
387+
// push 已经开始但没结束,updateFileDigest 还没被调用
388+
expect(order).toEqual(["push:start"]);
389+
390+
releasePush();
391+
await promise;
392+
393+
// push 必须在 updateFileDigest 之前完成
394+
expect(order).toContain("push:end");
395+
expect(order).toContain("digest:list");
396+
expect(order.indexOf("push:end")).toBeLessThan(order.indexOf("digest:list"));
397+
});
196398
});

src/app/service/service_worker/synchronize.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ type ScriptcatSyncStatus = {
6767

6868
type PushScriptParam = TInstallScriptParams;
6969

70-
const SYNC_SERVICE_TASK_KEY = "cloud_sync";
70+
const SYNC_SERVICE_TASK_KEY = "cloud_sync_queue";
7171

7272
export class SynchronizeService {
7373
logger: Logger;
@@ -332,7 +332,13 @@ export class SynchronizeService {
332332

333333
// 同步一次
334334
async syncOnce(syncConfig: CloudSyncConfig, fs: FileSystem) {
335-
return stackAsyncTask(SYNC_SERVICE_TASK_KEY, () => this.syncOnceInternal(syncConfig, fs));
335+
return stackAsyncTask(SYNC_SERVICE_TASK_KEY, async () => {
336+
try {
337+
await this.syncOnceInternal(syncConfig, fs);
338+
} catch (e) {
339+
this.logger.error("sync once error", Logger.E(e));
340+
}
341+
});
336342
}
337343

338344
private async syncOnceInternal(syncConfig: CloudSyncConfig, fs: FileSystem) {
@@ -393,6 +399,9 @@ export class SynchronizeService {
393399
// 对比脚本列表和文件列表,进行同步
394400
const result: Promise<void>[] = [];
395401
const updateScript: Map<string, boolean> = new Map();
402+
// 记录被跳过的孤儿云端脚本(仅 .user.js 无 .meta.json)
403+
// 避免本机回写 scriptcat-sync.json 时丢失对应 uuid 的云端 status
404+
const skippedOrphanUuids = new Set<string>();
396405
// 需要是同步操作,后续上传剩下的脚本
397406
// 最后使用 Promise.allSettled 进行等待
398407
uuidMap.forEach((file, uuid) => {
@@ -448,6 +457,7 @@ export class SynchronizeService {
448457
uuid,
449458
file: file.script.name,
450459
});
460+
skippedOrphanUuids.add(uuid);
451461
return;
452462
}
453463
updateScript.set(uuid, true);
@@ -509,6 +519,13 @@ export class SynchronizeService {
509519
}
510520
})
511521
);
522+
// 保留被跳过的 orphan uuid 的云端 status,避免覆盖另一台设备半上传的状态
523+
skippedOrphanUuids.forEach((uuid) => {
524+
const status = cloudStatus[uuid];
525+
if (status) {
526+
scriptcatSync.status.scripts[uuid] = status;
527+
}
528+
});
512529
// 保存脚本猫同步状态
513530
const syncFile = await fs.create("scriptcat-sync.json");
514531
await syncFile.write(JSON.stringify(scriptcatSync, null, 2));

0 commit comments

Comments
 (0)