@oj-module/cli 0.2.68 → 0.2.71

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.
Files changed (153) hide show
  1. package/devkit/README.md +10 -8
  2. package/devkit/framework-dev.md +1 -1
  3. package/devkit/manual.md +45 -19
  4. package/devkit/module-dev-and-cdn-playground.md +20 -14
  5. package/devkit/publish-walkthrough.md +132 -43
  6. package/devkit/wecom-customer-reauth-guide.md +110 -0
  7. package/package.json +2 -2
  8. package/shell-dist/assets/{runtime.1xzahckt10.js → runtime.7x99hmm020.js} +2 -2
  9. package/shell-dist/bundles/oj-market-0.2.0.tgz +0 -0
  10. package/shell-dist/bundles/oj-market-0.2.0.tgz.sig +8 -0
  11. package/shell-dist/index.html +4 -4
  12. package/shell-dist/versions.json +3 -3
  13. package/src/__tests__/list.test.ts +7 -7
  14. package/src/__tests__/oj-health-host.test.ts +19 -0
  15. package/src/__tests__/site-tenant-header.test.ts +2 -2
  16. package/src/__tests__/templates-builtin-list.test.ts +47 -0
  17. package/src/__tests__/templates-schema-columns.test.ts +11 -2
  18. package/src/__tests__/templates-tenant-scope.test.ts +2 -3
  19. package/src/__tests__/users-roles-display.test.ts +84 -0
  20. package/src/bundle.ts +1 -1
  21. package/src/contract/__tests__/market-client.integration.test.ts +23 -22
  22. package/src/enable.ts +2 -2
  23. package/src/init.ts +45 -0
  24. package/src/install.ts +5 -5
  25. package/src/lifecycle/__tests__/install.test.ts +29 -29
  26. package/src/lifecycle/__tests__/market-bundle.test.ts +87 -0
  27. package/src/lifecycle/__tests__/publish.test.ts +8 -10
  28. package/src/lifecycle/__tests__/trust.test.ts +14 -14
  29. package/src/lifecycle/__tests__/uninstall.test.ts +4 -4
  30. package/src/list.ts +3 -3
  31. package/src/market/__tests__/bootstrap.test.ts +24 -7
  32. package/src/market/__tests__/failure-playbook.test.ts +0 -1
  33. package/src/market/__tests__/remote-push.test.ts +228 -0
  34. package/src/market/__tests__/verify.test.ts +45 -207
  35. package/src/market/bootstrap.ts +12 -7
  36. package/src/market/command.ts +33 -35
  37. package/src/market/db-seed.ts +129 -0
  38. package/src/market/failure-playbook.ts +3 -9
  39. package/src/market/fixture.ts +2 -6
  40. package/src/market/publish.ts +145 -89
  41. package/src/market/remote-push.ts +236 -0
  42. package/src/market/trial.ts +2 -2
  43. package/src/market/verify.ts +17 -270
  44. package/src/market/walkthrough.ts +38 -63
  45. package/src/oj.ts +13 -2
  46. package/src/preview.ts +5 -3
  47. package/src/publish.ts +3 -2
  48. package/src/scaffold-guard.ts +4 -0
  49. package/src/site.ts +1 -1
  50. package/src/trust.ts +8 -8
  51. package/src/uninstall.ts +2 -2
  52. package/src/usage.ts +23 -21
  53. package/templates/config.yaml +22 -3
  54. package/templates/global.d.ts +6 -4
  55. package/templates/init.include.yaml +16 -11
  56. package/templates/openapi.json +3971 -3663
  57. package/templates/src/.ojm-builtin.json +13 -0
  58. package/templates/src/README.md +5 -2
  59. package/templates/src/app/api/_platform/README.md +3 -2
  60. package/templates/src/app/api/_platform/_lifecycle/atomic.ts +7 -2
  61. package/templates/src/app/api/_platform/_lifecycle/bundle-converge.ts +158 -0
  62. package/templates/src/app/api/_platform/_lifecycle/history.ts +3 -2
  63. package/templates/src/app/api/_platform/_lifecycle/installer.ts +25 -5
  64. package/templates/src/app/api/_platform/_lifecycle/local-archive.ts +61 -0
  65. package/templates/src/app/api/_platform/_lifecycle/manifest-of-upload.ts +62 -0
  66. package/templates/src/app/api/_platform/_lifecycle/steps/preflight.ts +12 -2
  67. package/templates/src/app/api/_platform/_lifecycle/steps/reload.ts +4 -1
  68. package/templates/src/app/api/_platform/_lifecycle/steps/smoke.ts +8 -2
  69. package/templates/src/app/api/_platform/_lifecycle/types.ts +1 -1
  70. package/templates/src/app/api/_platform/_lifecycle/uninstall.ts +14 -5
  71. package/templates/src/app/api/_platform/_shared/service/domain/users.ts +7 -1
  72. package/templates/src/app/api/_platform/_shared/site-facade.ts +31 -0
  73. package/templates/src/app/api/_platform/manifest.yaml +1 -2
  74. package/templates/src/app/api/_platform/migrations/0003__market_catalog.mysql.sql +6 -0
  75. package/templates/src/app/api/_platform/migrations/0003__market_catalog.pg.sql +6 -0
  76. package/templates/src/app/api/_platform/migrations/0003__market_catalog.sql +18 -0
  77. package/templates/src/app/api/_platform/migrations/0004__market_drop_source_seq.mysql.sql +6 -0
  78. package/templates/src/app/api/_platform/migrations/0004__market_drop_source_seq.pg.sql +6 -0
  79. package/templates/src/app/api/_platform/migrations/0004__market_drop_source_seq.sql +6 -0
  80. package/templates/src/app/api/_platform/migrations/0005__market_sig_registry.mysql.sql +8 -0
  81. package/templates/src/app/api/_platform/migrations/0005__market_sig_registry.pg.sql +8 -0
  82. package/templates/src/app/api/_platform/migrations/0005__market_sig_registry.sql +8 -0
  83. package/templates/src/app/api/_platform/schema.yaml +8 -11
  84. package/templates/src/app/api/_platform/seed.mysql.sql +1 -1
  85. package/templates/src/app/api/_platform/seed.pg.sql +1 -1
  86. package/templates/src/app/api/_platform/seed.sql +1 -1
  87. package/templates/src/app/api/auth/_shared/wecom/sync.ts +6 -0
  88. package/templates/src/app/api/system/users/api.ts +3 -0
  89. package/templates/src/app/web/_platform/__client/api.schemas.ts +0 -113
  90. package/templates/src/app/web/_platform/__client/index.ts +1 -333
  91. package/templates/src/app/web/system/__client/api.schemas.ts +2 -0
  92. package/templates/src/app/web/system/locales/en-US.json +15 -1
  93. package/templates/src/app/web/system/locales/zh-CN.json +15 -1
  94. package/templates/src/app/web/system/pages/departments/sync-bar.tsx +9 -3
  95. package/templates/src/app/web/system/pages/roles/index.tsx +4 -8
  96. package/templates/src/app/web/system/pages/roles/role-label.ts +26 -0
  97. package/templates/src/app/web/system/pages/solutions/index.tsx +110 -0
  98. package/templates/src/app/web/system/pages/solutions/market-entry.ts +46 -0
  99. package/templates/src/app/web/system/pages/users/index.tsx +12 -2
  100. package/templates/src/app/web/system/pages/users/match-keyword.ts +19 -0
  101. package/templates/src/app/web/system/pages/users/roles.tsx +43 -14
  102. package/templates/src/app/web/system/routes.ts +18 -1
  103. package/templates/src/{app/api/_platform/market → oj-market/api}/api.ts +475 -592
  104. package/templates/src/oj-market/api/backfill.ts +221 -0
  105. package/templates/src/{app/api/_platform/market → oj-market/api}/bootstrap.ts +2 -2
  106. package/templates/src/oj-market/api/cdn/api.ts +312 -0
  107. package/templates/src/oj-market/api/cdn/shared.ts +104 -0
  108. package/templates/src/oj-market/api/cdn/signed-auth.ts +75 -0
  109. package/templates/src/oj-market/api/manifest.yaml +8 -0
  110. package/templates/src/oj-market/solution.yaml +21 -0
  111. package/templates/src/oj-market/web/__client/api.schemas.ts +167 -0
  112. package/templates/src/oj-market/web/__client/index.ts +509 -0
  113. package/templates/src/oj-market/web/__tests__/marketClient.fake.ts +6 -6
  114. package/templates/src/oj-market/web/api/client.ts +6 -6
  115. package/templates/src/oj-market/web/api/defaultClient.ts +31 -25
  116. package/templates/src/oj-market/web/entry.ts +5 -3
  117. package/templates/src/oj-market/web/hooks/useMarket.ts +6 -6
  118. package/templates/src/oj-market/web/pages/MarketApp.tsx +20 -3
  119. package/templates/src/oj-market/web/publish/locales/en-US.json +1 -1
  120. package/templates/src/oj-market/web/publish/locales/zh-CN.json +1 -1
  121. package/templates/src/oj-market/web/publish/pages/history.tsx +2 -11
  122. package/templates/src/oj-market/web/routes.ts +12 -0
  123. package/templates/src/oj-market/web/types.ts +14 -8
  124. package/templates/tests/lifecycle/anonymous-wiring.spec.ts +65 -0
  125. package/templates/tests/lifecycle/backfill.spec.ts +392 -0
  126. package/templates/tests/lifecycle/bootstrap.spec.ts +1 -1
  127. package/templates/tests/lifecycle/bundle-converge.spec.ts +264 -0
  128. package/templates/tests/lifecycle/cdn-upload.spec.ts +661 -0
  129. package/templates/tests/lifecycle/deploy-smoke.spec.ts +25 -0
  130. package/templates/tests/lifecycle/exec-grant.spec.ts +38 -0
  131. package/templates/tests/lifecycle/installer.spec.ts +36 -0
  132. package/templates/tests/lifecycle/market-api.spec.ts +448 -803
  133. package/templates/tests/lifecycle/preflight.spec.ts +18 -0
  134. package/templates/tests/lifecycle/trust.spec.ts +2 -2
  135. package/templates/tests/lifecycle/uninstall.spec.ts +36 -2
  136. package/templates/tests/lifecycle/verify-checks.spec.ts +1 -1
  137. package/templates/tests/rbac-users.spec.ts +8 -0
  138. package/templates/tests/solutions-entry.spec.ts +55 -0
  139. package/templates/tsconfig.paths.json +1 -0
  140. package/templates/types/stream-probes/stream-handlers.ts +34 -0
  141. package/templates/web.config.ts +6 -6
  142. package/vendor/host-versions.json +3 -3
  143. package/bin/oj +0 -4
  144. package/src/market/__tests__/build-index.test.ts +0 -641
  145. package/src/market/__tests__/sharding.test.ts +0 -90
  146. package/src/market/__tests__/yank-ledger.test.ts +0 -60
  147. package/src/market/build-index.ts +0 -526
  148. package/src/market/index-builder.ts +0 -149
  149. package/src/market/sharding.ts +0 -94
  150. package/src/market/yank-ledger.ts +0 -91
  151. package/templates/src/app/api/_platform/_lifecycle/source-seq.ts +0 -88
  152. package/templates/src/app/api/_platform/market/index-source.ts +0 -112
  153. package/templates/tests/lifecycle/source-seq.spec.ts +0 -55
@@ -0,0 +1,392 @@
1
+ // BDD:站点侧目录回填(计划 v4 §六——三用:回填=恢复=对账)。
2
+ // POST /api/oj-market/backfill(superadmin,R9):扫 cdn/ 存量 tgz → 内嵌 manifest 抽取
3
+ // (与上传端点同一 manifestOfUpload 管线)+ 旁车 .sig(key_id/签名恢复)→ upsert
4
+ // market_release。幂等可重入;无 CLI 依赖(远端纯站点经 HTTP 触发)。
5
+ // 降级口径(数据表):.sig 缺失/不可解析的行照常落库但 sig 四列 NULL(安装验签
6
+ // sig_missing fail-closed);坏包进 skipped 不阻断其它文件;已 yank 行不回魂。
7
+
8
+ import type { FsFace } from "../../src/app/api/_platform/_lifecycle/atomic";
9
+ import type { ExecFace } from "../../src/app/api/_platform/_lifecycle/steps/extract";
10
+ import type { MarketEnv } from "../../src/oj-market/api/api";
11
+ import { createHash } from "node:crypto";
12
+ import * as fsp from "node:fs/promises";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
16
+ import { defaultTestKey, nodeVerifyEd25519, signedArchiveFor } from "../../../src/lifecycle/__tests__/helpers";
17
+ import { createMarketApi, marketEnv } from "../../src/oj-market/api/api";
18
+ import { installDb } from "../fixture";
19
+
20
+ const g = globalThis as any;
21
+
22
+ // tgz「字节」即 `<name>|<version>` 文本:manifestOfUpload 的 staging 文件名是常量 upload.tgz,
23
+ // tar stub 从 staged 字节读回 name/version(真实管道以包内 manifest 为真源,stub 同构模拟)。
24
+ // 签名/哈希全按实写字节算,假 tar 不需要真归档格式。
25
+ const TEST_KEY = defaultTestKey();
26
+ function pkgBytes(name: string, version: string): Uint8Array {
27
+ return new TextEncoder().encode(`${name}|${version}\n`);
28
+ }
29
+ function shaOf(bytes: Uint8Array): string {
30
+ return createHash("sha256").update(bytes).digest("hex");
31
+ }
32
+
33
+ /** 捕获 json.ok/json.fail 出口(respond 收口后恰一次调用) */
34
+ let last: { status: number, msg?: string, data?: unknown } | null = null;
35
+
36
+ function nodeFsFace(): FsFace {
37
+ return {
38
+ writeFile: (path, data) => fsp.writeFile(path, data),
39
+ readFile: path => fsp.readFile(path),
40
+ mkdir: (path, opts) => fsp.mkdir(path, opts),
41
+ remove: (path, opts) => fsp.rm(path, opts),
42
+ rename: (oldPath, newPath) => fsp.rename(oldPath, newPath),
43
+ copyFile: (src, dest) => fsp.copyFile(src, dest),
44
+ readdir: path => fsp.readdir(path),
45
+ };
46
+ }
47
+
48
+ /**
49
+ * tar stub:list 恒过;extract 从 staged 字节读回 `<name>|<version>` 写扁平 manifest.yaml。
50
+ * 「not-a-tar」字节模拟坏包(list 即失败)——staging 文件名是常量 upload.tgz,无法按名分流。
51
+ */
52
+ function tarExec(manifestOverride?: (name: string, version: string) => Record<string, unknown>): ExecFace {
53
+ return {
54
+ run: async (argv) => {
55
+ if (argv[0] === "tar" && argv[1] === "-tvzf") {
56
+ const staged = await fsp.readFile(String(argv[2]), "utf-8");
57
+ if (staged === "not-a-tar")
58
+ return { code: 1, stdout: "", stderr: "garbage" };
59
+ return { code: 0, stdout: "manifest.yaml\n", stderr: "" };
60
+ }
61
+ if (argv[0] === "tar" && argv[1] === "-xzf") {
62
+ const dest = argv[4]!;
63
+ const [name = "demo-solution", version = "0.1.0"] = (await fsp.readFile(String(argv[2]), "utf-8")).split("|");
64
+ await fsp.mkdir(dest, { recursive: true });
65
+ await fsp.writeFile(join(dest, "manifest.yaml"), JSON.stringify(
66
+ manifestOverride?.(name, version) ?? { name, version: version.replace(/\n$/, ""), summary: "存量回填包" },
67
+ ));
68
+ return { code: 0, stdout: "", stderr: "" };
69
+ }
70
+ return { code: 0, stdout: "", stderr: "" };
71
+ },
72
+ };
73
+ }
74
+
75
+ let root: string;
76
+ let cdnDir: string;
77
+ let staging: string;
78
+ let dbPath: string;
79
+ let dbo: any;
80
+
81
+ function makeEnv(overrides: Partial<MarketEnv> = {}): MarketEnv {
82
+ return {
83
+ ...marketEnv(),
84
+ sharedRoot: join(root, "dist", "api"),
85
+ staging,
86
+ taskDir: join(root, "modules", ".install-tasks"),
87
+ statePath: join(root, ".solutions", "state.yaml"),
88
+ trustPath: join(root, ".solutions", "trusted-keys.yaml"),
89
+ dbPath,
90
+ siteModulesDir: join(root, "dist", "web", "modules"),
91
+ modulesJsonPath: join(root, "dist", "web", "modules.json"),
92
+ cdnDir,
93
+ exec: tarExec(),
94
+ sha256Hex: d => createHash("sha256").update(d).digest("hex"),
95
+ verifyEd25519: nodeVerifyEd25519,
96
+ ...overrides,
97
+ } as MarketEnv;
98
+ }
99
+
100
+ /** cdn/ 落一个存量包:tgz 字节(`<name>|<version>`)+ 可选 .sig 旁车(六字段) */
101
+ async function dropCdnPackage(name: string, version: string, opts: { sig?: boolean, bytes?: Uint8Array } = {}): Promise<Uint8Array> {
102
+ const bytes = opts.bytes ?? pkgBytes(name, version);
103
+ await fsp.writeFile(join(cdnDir, `${name}-${version}.tgz`), bytes);
104
+ if (opts.sig !== false) {
105
+ const sig = signedArchiveFor(bytes, name, version, { keyPair: TEST_KEY }).sig;
106
+ await fsp.writeFile(join(cdnDir, `${name}-${version}.tgz.sig`), JSON.stringify(sig));
107
+ }
108
+ return bytes;
109
+ }
110
+
111
+ function rows(): Array<Record<string, unknown>> {
112
+ return dbo.prepare("SELECT * FROM market_release ORDER BY product, version").all();
113
+ }
114
+
115
+ beforeEach(async () => {
116
+ root = await fsp.mkdtemp(join(tmpdir(), "ojm-backfill-"));
117
+ cdnDir = join(root, "cdn");
118
+ staging = join(root, "modules", ".staging");
119
+ dbPath = join(root, "db.sqlite");
120
+ await fsp.mkdir(cdnDir, { recursive: true });
121
+ await fsp.writeFile(dbPath, new TextEncoder().encode("sqlite-header\n"));
122
+ g.fs = nodeFsFace();
123
+ dbo = installDb();
124
+ dbo.prepare("INSERT OR IGNORE INTO sys_user_role (user_id, role_name) VALUES (?, ?)").run(1, "superadmin");
125
+ g.yaml = { parse: async (t: string) => JSON.parse(t) };
126
+ g.http = { user: { id: 1 } };
127
+ g.json = {
128
+ ok: (data: unknown) => {
129
+ last = { status: 200, data };
130
+ },
131
+ fail: (status: number, msg: string) => {
132
+ last = { status, msg };
133
+ },
134
+ header: () => {},
135
+ };
136
+ last = null;
137
+ });
138
+
139
+ afterEach(async () => {
140
+ g.fs = undefined;
141
+ g.http = undefined;
142
+ g.json = undefined;
143
+ g.yaml = undefined;
144
+ g.db = undefined;
145
+ await fsp.rm(root, { recursive: true, force: true });
146
+ });
147
+
148
+ describe("场景:回填(cdn/ 存量 → DB 目录行)", () => {
149
+ it("给定 存量 tgz+.sig 无 DB 行,当 回填,则 行落库:published_at_source='file'、sig 四列取旁车", async () => {
150
+ const bytes = await dropCdnPackage("demo-solution", "0.1.0");
151
+ const api = createMarketApi(makeEnv());
152
+ await api.backfill.post();
153
+ expect(last?.status).toBe(200);
154
+ const rs = rows();
155
+ expect(rs).toHaveLength(1);
156
+ expect(rs[0]).toMatchObject({
157
+ product: "demo-solution",
158
+ version: "0.1.0",
159
+ key_id: "test.publisher",
160
+ sha256: shaOf(bytes),
161
+ size: bytes.byteLength,
162
+ published_at_source: "file",
163
+ yanked: 0,
164
+ });
165
+ expect(String(rs[0]!.sig_alg)).toBe("Ed25519");
166
+ expect(typeof rs[0]!.sig_value).toBe("string");
167
+ expect(typeof rs[0]!.sig_registry_id).toBe("string");
168
+ const report = last?.data as { scanned: number, upserted: number, sigMissing: string[] };
169
+ expect(report).toMatchObject({ scanned: 1, upserted: 1, sigMissing: [] });
170
+ });
171
+
172
+ it("数据表 .sig 缺失降级:行照落库,sig 四列 NULL,文件名进 sigMissing", async () => {
173
+ await dropCdnPackage("demo-solution", "0.1.0", { sig: false });
174
+ const api = createMarketApi(makeEnv());
175
+ await api.backfill.post();
176
+ expect(last?.status).toBe(200);
177
+ const rs = rows();
178
+ expect(rs).toHaveLength(1);
179
+ expect(rs[0]).toMatchObject({ sig_alg: null, sig_value: null, sig_public_key: null, sig_registry_id: null, key_id: null });
180
+ expect((last?.data as { sigMissing: string[] }).sigMissing).toEqual(["demo-solution-0.1.0.tgz"]);
181
+ });
182
+
183
+ it("数据表 旁车验签失败(形全但 sha 与字节不绑定,投毒旁车):行照落库,sig 四列 NULL,进 sigMissing(fail-closed 与缺签同口径)", async () => {
184
+ const bytes = pkgBytes("demo-solution", "0.1.0");
185
+ // 形全实毒:对其它字节按同名同版本签出——结构合法、签名有效,唯与实收字节无绑定
186
+ const poisoned = signedArchiveFor(pkgBytes("evil", "9.9.9"), "demo-solution", "0.1.0", { keyPair: TEST_KEY });
187
+ await fsp.writeFile(join(cdnDir, "demo-solution-0.1.0.tgz"), bytes);
188
+ await fsp.writeFile(join(cdnDir, "demo-solution-0.1.0.tgz.sig"), JSON.stringify(poisoned.sig));
189
+ const api = createMarketApi(makeEnv());
190
+ await api.backfill.post();
191
+ expect(last?.status).toBe(200);
192
+ const rs = rows();
193
+ expect(rs).toHaveLength(1);
194
+ expect(rs[0]).toMatchObject({ sig_alg: null, sig_value: null, sig_public_key: null, sig_registry_id: null, key_id: null, sha256: shaOf(bytes) });
195
+ expect((last?.data as { sigMissing: string[] }).sigMissing).toEqual(["demo-solution-0.1.0.tgz"]);
196
+ });
197
+ });
198
+
199
+ describe("场景:幂等可重入(对账)", () => {
200
+ it("给定 已回填,当 重跑,则 行数不变、upserted=0、published_at 等审计列不被改写", async () => {
201
+ await dropCdnPackage("demo-solution", "0.1.0");
202
+ const api = createMarketApi(makeEnv());
203
+ await api.backfill.post();
204
+ const before = rows();
205
+ await api.backfill.post();
206
+ const report = last?.data as { upserted: number, unchanged: number };
207
+ expect(report).toMatchObject({ upserted: 0, unchanged: 1 });
208
+ expect(rows()).toEqual(before);
209
+ });
210
+
211
+ it("给定 遗留行(sig 列 NULL、published_at 旧值、yanked=1),当 回填,则 sig 列补齐且 published_at/first_seen_at/yanked 原样保留(yank 不回魂)", async () => {
212
+ await dropCdnPackage("demo-solution", "0.1.0");
213
+ dbo.prepare("INSERT INTO market_release (product,version,key_id,sha256,size,published_at,published_at_source,yanked,first_seen_at,sig_alg,sig_value,sig_public_key,sig_registry_id,manifest) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)")
214
+ .run("demo-solution", "0.1.0", null, "old-sha", 1, 424242, "file", 1, 424242, null, null, null, null, null);
215
+ const api = createMarketApi(makeEnv());
216
+ await api.backfill.post();
217
+ expect(last?.status).toBe(200);
218
+ const rs = rows();
219
+ expect(rs).toHaveLength(1);
220
+ expect(rs[0]).toMatchObject({
221
+ sha256: shaOf(pkgBytes("demo-solution", "0.1.0")),
222
+ published_at: 424242,
223
+ first_seen_at: 424242,
224
+ yanked: 1,
225
+ key_id: "test.publisher",
226
+ });
227
+ expect(String(rs[0]!.sig_alg)).toBe("Ed25519");
228
+ });
229
+ });
230
+
231
+ describe("场景:灾备恢复(清表 → 扫描恢复)", () => {
232
+ it("给定 表清空 cdn/ 完好,当 回填,则 行恢复且重复跑行数不变", async () => {
233
+ await dropCdnPackage("demo-solution", "0.1.0");
234
+ await dropCdnPackage("other-solution", "2.0.0");
235
+ const api = createMarketApi(makeEnv());
236
+ await api.backfill.post();
237
+ dbo.prepare("DELETE FROM market_release").run();
238
+ await api.backfill.post();
239
+ expect(rows()).toHaveLength(2);
240
+ await api.backfill.post();
241
+ expect(rows()).toHaveLength(2);
242
+ });
243
+ });
244
+
245
+ describe("场景:门禁与坏包分流", () => {
246
+ it("数据表 角色门禁:匿名 401 / 非 superadmin 403", async () => {
247
+ const api = createMarketApi(makeEnv());
248
+ g.http = {};
249
+ await api.backfill.post();
250
+ expect(last?.status).toBe(403); // assertSuperAdmin 对匿名亦 403(不泄露存在性)
251
+ g.http = { user: { id: 2 } };
252
+ await api.backfill.post();
253
+ expect(last?.status).toBe(403);
254
+ });
255
+
256
+ it("给定 坏包与好包并存,当 回填,则 坏包进 skipped 带 reason、好包照落库", async () => {
257
+ await dropCdnPackage("good-solution", "0.1.0");
258
+ await fsp.writeFile(join(cdnDir, "broken-9.9.9.tgz"), new TextEncoder().encode("not-a-tar"));
259
+ const api = createMarketApi(makeEnv({ exec: tarExec() }));
260
+ await api.backfill.post();
261
+ expect(last?.status).toBe(200);
262
+ expect(rows()).toHaveLength(1);
263
+ const report = last?.data as { skipped: Array<{ file: string, reason: string }> };
264
+ expect(report.skipped).toHaveLength(1);
265
+ expect(report.skipped[0]!.file).toBe("broken-9.9.9.tgz");
266
+ expect(report.skipped[0]!.reason).toContain("列表失败");
267
+ });
268
+
269
+ it("给定 manifest 名实不符(文件名 demo-a、包内 manifest name=demo-b),当 回填,则 跳过并给出 reason", async () => {
270
+ await dropCdnPackage("demo-a", "0.1.0");
271
+ // 包内 manifest 写 demo-b → 与文件名 demo-a 名实不符
272
+ const api = createMarketApi(makeEnv({ exec: tarExec(() => ({ name: "demo-b", version: "0.1.0" })) }));
273
+ await api.backfill.post();
274
+ expect(rows()).toHaveLength(0);
275
+ const report = last?.data as { skipped: Array<{ file: string, reason: string }> };
276
+ expect(report.skipped[0]!.reason).toContain("名实不符");
277
+ });
278
+
279
+ it("数据表 并发撞唯一键(select 与 insert 之间他方上传先落行,异字节):该文件进 skipped 带冲突 reason,好包照常落库,扫描不中断(minor M2)", async () => {
280
+ await dropCdnPackage("race-solution", "0.1.0");
281
+ await dropCdnPackage("good-solution", "0.1.0");
282
+ // 拦截 market_release 的 insert:真正写入前先经裸 SQL 落一行他方并发行(异字节)→ 原 insert 撞唯一键
283
+ const origTable = g.db.table.bind(g.db);
284
+ let armed = true;
285
+ g.db.table = (name: string) => {
286
+ const t = origTable(name);
287
+ if (name !== "market_release" || !armed)
288
+ return t;
289
+ return new Proxy(t, {
290
+ get(target, prop) {
291
+ if (prop === "insert") {
292
+ return (row: { product?: string }) => ({
293
+ run: async () => {
294
+ if (row.product === "race-solution" && armed) {
295
+ armed = false;
296
+ dbo.prepare("INSERT INTO market_release (product,version,key_id,sha256,size,published_at,published_at_source,yanked,first_seen_at) VALUES (?,?,?,?,?,?,?,?,?)")
297
+ .run("race-solution", "0.1.0", "other.publisher", "racedeadbeef", 9, 1000, "upload", 0, 1000);
298
+ }
299
+ return target.insert(row).run(); // race 文件撞 (product,version) 唯一键抛错
300
+ },
301
+ });
302
+ }
303
+ const v = (target as never)[prop as never];
304
+ return typeof v === "function" ? v.bind(target) : v;
305
+ },
306
+ });
307
+ };
308
+ const api = createMarketApi(makeEnv());
309
+ await api.backfill.post();
310
+ g.db.table = origTable;
311
+ expect(last?.status).toBe(200);
312
+ // 好包照常落库;race 文件 skipped(reason 含冲突),全量扫描不中断
313
+ expect(rows().map(r => r.product).sort()).toEqual(["good-solution", "race-solution"]);
314
+ const report = last?.data as { skipped: Array<{ file: string, reason: string }>, scanned: number };
315
+ expect(report.scanned).toBe(2);
316
+ expect(report.skipped).toHaveLength(1);
317
+ expect(report.skipped[0]!.file).toBe("race-solution-0.1.0.tgz");
318
+ expect(String(report.skipped[0]!.reason)).toContain("冲突");
319
+ // 他方并发行原样保留(不被回填改写)
320
+ const raced = dbo.prepare("SELECT sha256 FROM market_release WHERE product='race-solution'").get();
321
+ expect(raced.sha256).toBe("racedeadbeef");
322
+ });
323
+
324
+ it("数据表 并发撞唯一键但同字节(他方上传同内容):幂等判 unchanged,不重复落库(minor M2 同字节支路)", async () => {
325
+ const bytes = await dropCdnPackage("same-solution", "0.1.0");
326
+ const sha = shaOf(bytes);
327
+ const racedSig = signedArchiveFor(bytes, "same-solution", "0.1.0", { keyPair: TEST_KEY }).sig;
328
+ const racedManifest = JSON.stringify({ name: "same-solution", version: "0.1.0", summary: "存量回填包" });
329
+ const origTable = g.db.table.bind(g.db);
330
+ let armed = true;
331
+ g.db.table = (name: string) => {
332
+ const t = origTable(name);
333
+ if (name !== "market_release" || !armed)
334
+ return t;
335
+ return new Proxy(t, {
336
+ get(target, prop) {
337
+ if (prop === "insert") {
338
+ return (row: unknown) => ({
339
+ run: async () => {
340
+ armed = false;
341
+ dbo.prepare("INSERT INTO market_release (product,version,key_id,sha256,size,published_at,published_at_source,yanked,first_seen_at,sig_alg,sig_value,sig_public_key,sig_registry_id,manifest) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)")
342
+ .run("same-solution", "0.1.0", racedSig.key_id, sha, bytes.byteLength, 1000, "upload", 0, 1000, racedSig.alg, racedSig.value, racedSig.public_key, racedSig.registry_id, racedManifest);
343
+ return target.insert(row).run();
344
+ },
345
+ });
346
+ }
347
+ const v = (target as never)[prop as never];
348
+ return typeof v === "function" ? v.bind(target) : v;
349
+ },
350
+ });
351
+ };
352
+ const api = createMarketApi(makeEnv());
353
+ await api.backfill.post();
354
+ g.db.table = origTable;
355
+ expect(last?.status).toBe(200);
356
+ expect(rows()).toHaveLength(1);
357
+ const report = last?.data as { unchanged: number, upserted: number };
358
+ expect(report).toMatchObject({ unchanged: 1, upserted: 0 });
359
+ });
360
+
361
+ it("数据表 insert 底层错误(非撞键,如 DB 故障):原样上抛不吞(minor M2 不遮真故障)", async () => {
362
+ await dropCdnPackage("demo-solution", "0.1.0");
363
+ const origTable = g.db.table.bind(g.db);
364
+ g.db.table = (name: string) => {
365
+ const t = origTable(name);
366
+ if (name !== "market_release")
367
+ return t;
368
+ return new Proxy(t, {
369
+ get(target, prop) {
370
+ if (prop === "insert")
371
+ return () => ({ run: async () => { throw new Error("SQLITE_ERROR: disk I/O error"); } });
372
+ const v = (target as never)[prop as never];
373
+ return typeof v === "function" ? v.bind(target) : v;
374
+ },
375
+ });
376
+ };
377
+ const api = createMarketApi(makeEnv());
378
+ await api.backfill.post();
379
+ g.db.table = origTable;
380
+ expect(last?.status).toBe(500);
381
+ expect(rows()).toHaveLength(0);
382
+ });
383
+
384
+ it("给定 孤立 .sig(tgz 缺失),当 回填,则 忽略不报错(容忍口径,不加 GC)", async () => {
385
+ await fsp.writeFile(join(cdnDir, "lonely-1.0.0.tgz.sig"), "{}");
386
+ const api = createMarketApi(makeEnv());
387
+ await api.backfill.post();
388
+ expect(last?.status).toBe(200);
389
+ expect(rows()).toHaveLength(0);
390
+ expect((last?.data as { scanned: number }).scanned).toBe(0);
391
+ });
392
+ });
@@ -12,7 +12,7 @@ import { dirname, join } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
14
14
  import { createTrustStore } from "../../src/app/api/_platform/_lifecycle/trust";
15
- import { BOOTSTRAP_KEY, seedBootstrapKey } from "../../src/app/api/_platform/market/bootstrap";
15
+ import { BOOTSTRAP_KEY, seedBootstrapKey } from "../../src/oj-market/api/bootstrap";
16
16
 
17
17
  const g = globalThis as any;
18
18
 
@@ -0,0 +1,264 @@
1
+ // BDD:随站 bundle 启动收敛(Phase B T3b)——`ojm init --yes` 把第一方 oj-market
2
+ // 包落位 cdn/ 并写意图标记(bundles.json),站点侧幂等对账(方案 §5「上拉不下拉」):
3
+ // 未装 → 安装;bundle > 已装 → 升级;bundle ≤ 已装 → 跳过(市场轨升过不降级,
4
+ // 对齐 rollbackGuard 禁降版红线 OJ-9);任一失败 → 记 failed 续跑,不阻断消费面。
5
+ // 挂点说明:oj 启动段 ext_boot 有 2s 看门狗(oj-bin runtime.rs BOOT_TIMEOUT),
6
+ // 安装全程不可在 boot 内跑——收敛执行 riding 首个编排 API 请求(index 端点对账即
7
+ // 服务先例),本模块对调用方只暴露「跑一遍对账」的纯编排。
8
+ import type { FsFace } from "../../src/app/api/_platform/_lifecycle/atomic";
9
+ import type { SidecarSig } from "../../src/oj-market/api/cdn/shared";
10
+ import * as fsp from "node:fs/promises";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
15
+
16
+ import { bundleConvergePlan, convergeBundledSolutions } from "../../src/app/api/_platform/_lifecycle/bundle-converge";
17
+ import { createStateStore } from "../../src/app/api/_platform/_lifecycle/state";
18
+
19
+ const g = globalThis as any;
20
+
21
+ function nodeFsFace(): FsFace {
22
+ return {
23
+ writeFile: (path, data) => fsp.writeFile(path, data),
24
+ readFile: path => fsp.readFile(path),
25
+ mkdir: (path, opts) => fsp.mkdir(path, opts),
26
+ remove: (path, opts) => fsp.rm(path, opts),
27
+ rename: (oldPath, newPath) => fsp.rename(oldPath, newPath),
28
+ copyFile: (src, dest) => fsp.copyFile(src, dest),
29
+ readdir: path => fsp.readdir(path),
30
+ };
31
+ }
32
+
33
+ describe("bundle 启动收敛:上拉不下拉", () => {
34
+ it("未装(null)→ install", () => {
35
+ expect(bundleConvergePlan(null, "0.2.0")).toBe("install");
36
+ });
37
+
38
+ it("已装 v1,bundle v2 → upgrade", () => {
39
+ expect(bundleConvergePlan("0.1.0", "0.2.0")).toBe("upgrade");
40
+ });
41
+
42
+ it("同版本 → skip(幂等)", () => {
43
+ expect(bundleConvergePlan("0.2.0", "0.2.0")).toBe("skip");
44
+ });
45
+
46
+ it("已装 v3(市场轨升的), bundle v2 → skip(不降级,OJ-9 同源)", () => {
47
+ expect(bundleConvergePlan("0.3.0", "0.2.0")).toBe("skip");
48
+ });
49
+ });
50
+
51
+ describe("bundle 启动收敛:执行编排(真实 marker/cdn/状态文件 + installLocal 注入)", () => {
52
+ let root: string;
53
+ let cdnDir: string;
54
+ let statePath: string;
55
+ let markerPath: string;
56
+ const installLocal = vi.fn<(entry: { name: string, version: string, file: string }, archive: { bytes: Uint8Array, sig: SidecarSig | null }) => Promise<void>>();
57
+
58
+ async function seedMarker(entries: unknown): Promise<void> {
59
+ await fsp.writeFile(markerPath, JSON.stringify({ solutions: entries }));
60
+ }
61
+
62
+ beforeEach(async () => {
63
+ root = await fsp.mkdtemp(join(tmpdir(), "ojm-bundle-converge-"));
64
+ cdnDir = join(root, "cdn");
65
+ statePath = join(root, ".solutions", "state.yaml");
66
+ markerPath = join(cdnDir, "bundles.json");
67
+ await fsp.mkdir(cdnDir, { recursive: true });
68
+ await fsp.mkdir(join(root, ".solutions"), { recursive: true });
69
+ g.fs = nodeFsFace();
70
+ installLocal.mockReset();
71
+ });
72
+
73
+ afterEach(async () => {
74
+ g.fs = undefined;
75
+ await fsp.rm(root, { recursive: true, force: true });
76
+ });
77
+
78
+ const env = () => ({ markerPath, cdnDir, statePath, installLocal });
79
+
80
+ it("marker 缺失 → 零动作安静返回(无 bundle 的站点/旧工程)", async () => {
81
+ const r = await convergeBundledSolutions(env());
82
+ expect(r).toEqual({ installed: [], upgraded: [], skipped: [], failed: [], cooling: [] });
83
+ expect(installLocal).not.toHaveBeenCalled();
84
+ });
85
+
86
+ it("未装 → 读 cdn 字节与旁车,installLocal 收 install 计划", async () => {
87
+ await seedMarker([{ name: "oj-market", version: "0.2.0", file: "oj-market-0.2.0.tgz" }]);
88
+ await fsp.writeFile(join(cdnDir, "oj-market-0.2.0.tgz"), new Uint8Array([1, 2, 3]));
89
+ await fsp.writeFile(join(cdnDir, "oj-market-0.2.0.tgz.sig"), JSON.stringify({ alg: "Ed25519", key_id: "platform.release", public_key: "ab".repeat(32), value: "AQID", canon: "v2", registry_id: "oj-registry" }));
90
+ const r = await convergeBundledSolutions(env());
91
+ expect(r.installed).toEqual(["oj-market@0.2.0"]);
92
+ expect(installLocal).toHaveBeenCalledTimes(1);
93
+ const [entry, archive] = installLocal.mock.calls[0]!;
94
+ expect(entry).toEqual({ name: "oj-market", version: "0.2.0", file: "oj-market-0.2.0.tgz" });
95
+ expect(archive.bytes).toEqual(new Uint8Array([1, 2, 3]));
96
+ expect(archive.sig?.key_id).toBe("platform.release");
97
+ });
98
+
99
+ it("已装 v1、bundle v2 → upgrade;另一产品同版 → skip", async () => {
100
+ await seedMarker([
101
+ { name: "oj-market", version: "0.2.0", file: "oj-market-0.2.0.tgz" },
102
+ { name: "wiki", version: "1.0.0", file: "wiki-1.0.0.tgz" },
103
+ ]);
104
+ await fsp.writeFile(join(cdnDir, "oj-market-0.2.0.tgz"), new Uint8Array([1]));
105
+ await fsp.writeFile(join(cdnDir, "wiki-1.0.0.tgz"), new Uint8Array([2]));
106
+ await (await createStateStore(statePath)).write({ solutions: {
107
+ "oj-market": { version: "0.1.0", enabled: true, installedAt: 0 },
108
+ "wiki": { version: "1.0.0", enabled: true, installedAt: 0 },
109
+ } });
110
+ const r = await convergeBundledSolutions(env());
111
+ expect(r.upgraded).toEqual(["oj-market@0.2.0"]);
112
+ expect(r.skipped).toEqual(["wiki@1.0.0"]);
113
+ expect(installLocal).toHaveBeenCalledTimes(1);
114
+ });
115
+
116
+ it("已装 v3(市场轨)> bundle v2 → skip,installLocal 零调用(不降级)", async () => {
117
+ await seedMarker([{ name: "oj-market", version: "0.2.0", file: "oj-market-0.2.0.tgz" }]);
118
+ await (await createStateStore(statePath)).write({ solutions: { "oj-market": { version: "0.3.0", enabled: true, installedAt: 0 } } });
119
+ const r = await convergeBundledSolutions(env());
120
+ expect(r.skipped).toEqual(["oj-market@0.2.0"]);
121
+ expect(installLocal).not.toHaveBeenCalled();
122
+ });
123
+
124
+ it("installLocal 抛错 → 记 failed 且续跑后续条目(降级不阻断)", async () => {
125
+ await seedMarker([
126
+ { name: "oj-market", version: "0.2.0", file: "oj-market-0.2.0.tgz" },
127
+ { name: "oj-market", version: "0.2.0", file: "oj-market-0.2.0.tgz" },
128
+ ]);
129
+ await fsp.writeFile(join(cdnDir, "oj-market-0.2.0.tgz"), new Uint8Array([1]));
130
+ installLocal.mockRejectedValueOnce(new Error("验签失败"));
131
+ const r = await convergeBundledSolutions(env());
132
+ expect(r.failed).toEqual(["oj-market@0.2.0(验签失败)"]);
133
+ expect(r.installed).toEqual(["oj-market@0.2.0"]);
134
+ expect(installLocal).toHaveBeenCalledTimes(2);
135
+ });
136
+
137
+ it("旁车缺失 → sig 传 null(无签名包交安装器 verify 六码分流,不本地预判)", async () => {
138
+ await seedMarker([{ name: "oj-market", version: "0.2.0", file: "oj-market-0.2.0.tgz" }]);
139
+ await fsp.writeFile(join(cdnDir, "oj-market-0.2.0.tgz"), new Uint8Array([1]));
140
+ await convergeBundledSolutions(env());
141
+ expect(installLocal.mock.calls[0]![1].sig).toBeNull();
142
+ });
143
+ });
144
+
145
+ describe("bundle 收敛:卸载墓碑(终审 Important-1)", () => {
146
+ let root: string;
147
+
148
+ beforeEach(async () => {
149
+ root = await fsp.mkdtemp(join(tmpdir(), "ojm-bundle-prune-"));
150
+ g.fs = nodeFsFace();
151
+ });
152
+
153
+ afterEach(async () => {
154
+ g.fs = undefined;
155
+ await fsp.rm(root, { recursive: true, force: true });
156
+ });
157
+
158
+ it("pruneBundledMarker:摘除指定条目、其余保留;marker 缺失/损坏安静 no-op", async () => {
159
+ const { pruneBundledMarker } = await import("../../src/app/api/_platform/_lifecycle/bundle-converge");
160
+ const marker = join(root, "bundles.json");
161
+ await fsp.writeFile(marker, JSON.stringify({ solutions: [
162
+ { name: "oj-market", version: "0.2.0", file: "oj-market-0.2.0.tgz" },
163
+ { name: "wiki", version: "1.0.0", file: "wiki-1.0.0.tgz" },
164
+ ] }));
165
+ await pruneBundledMarker(marker, "oj-market");
166
+ const after = JSON.parse(await fsp.readFile(marker, "utf-8"));
167
+ expect(after.solutions).toEqual([{ name: "wiki", version: "1.0.0", file: "wiki-1.0.0.tgz" }]);
168
+ // 幂等:再摘同名(条目已不在)不报错
169
+ await pruneBundledMarker(marker, "oj-market");
170
+ // marker 缺失 no-op
171
+ await pruneBundledMarker(join(root, "nope.json"), "oj-market");
172
+ });
173
+
174
+ it("卸载摘碑后收敛不再自动重装(installLocal 零调用)", async () => {
175
+ const { pruneBundledMarker } = await import("../../src/app/api/_platform/_lifecycle/bundle-converge");
176
+ const cdnDir = join(root, "cdn");
177
+ const statePath = join(root, ".solutions", "state.yaml");
178
+ const markerPath = join(cdnDir, "bundles.json");
179
+ await fsp.mkdir(cdnDir, { recursive: true });
180
+ await fsp.mkdir(join(root, ".solutions"), { recursive: true });
181
+ await fsp.writeFile(markerPath, JSON.stringify({ solutions: [{ name: "oj-market", version: "0.2.0", file: "oj-market-0.2.0.tgz" }] }));
182
+ await fsp.writeFile(join(cdnDir, "oj-market-0.2.0.tgz"), new Uint8Array([1]));
183
+ const installLocal = vi.fn(async () => {});
184
+ // 未装 → 装
185
+ const cdnDirArg = cdnDir;
186
+ const r1 = await convergeBundledSolutions({ markerPath, cdnDir: cdnDirArg, statePath, installLocal });
187
+ expect(r1.installed).toEqual(["oj-market@0.2.0"]);
188
+ // 卸载 = 摘碑(端点侧 runUninstall 成功后调用;状态条目由卸载面清除,此处模拟无条目)
189
+ await pruneBundledMarker(markerPath, "oj-market");
190
+ const r2 = await convergeBundledSolutions({ markerPath, cdnDir: cdnDirArg, statePath, installLocal });
191
+ expect(r2).toEqual({ installed: [], upgraded: [], skipped: [], failed: [], cooling: [] });
192
+ expect(installLocal).toHaveBeenCalledTimes(1);
193
+ });
194
+ });
195
+
196
+ describe("bundle 收敛:失败冷却(终审 Important-2)", () => {
197
+ let root: string;
198
+ let cdnDir: string;
199
+ let statePath: string;
200
+ let markerPath: string;
201
+ let cooldownPath: string;
202
+ const installLocal = vi.fn<(entry: { name: string, version: string, file: string }, archive: { bytes: Uint8Array, sig: unknown | null }) => Promise<void>>();
203
+
204
+ const env = (now: () => number) => ({
205
+ markerPath,
206
+ cdnDir,
207
+ statePath,
208
+ installLocal,
209
+ cooldownPath,
210
+ now,
211
+ });
212
+
213
+ beforeEach(async () => {
214
+ root = await fsp.mkdtemp(join(tmpdir(), "ojm-bundle-cooldown-"));
215
+ cdnDir = join(root, "cdn");
216
+ statePath = join(root, ".solutions", "state.yaml");
217
+ markerPath = join(cdnDir, "bundles.json");
218
+ cooldownPath = join(root, ".converge-cooldown.json");
219
+ await fsp.mkdir(cdnDir, { recursive: true });
220
+ await fsp.mkdir(join(root, ".solutions"), { recursive: true });
221
+ await fsp.writeFile(markerPath, JSON.stringify({ solutions: [{ name: "oj-market", version: "0.2.0", file: "oj-market-0.2.0.tgz" }] }));
222
+ await fsp.writeFile(join(cdnDir, "oj-market-0.2.0.tgz"), new Uint8Array([1]));
223
+ g.fs = nodeFsFace();
224
+ installLocal.mockReset();
225
+ });
226
+
227
+ afterEach(async () => {
228
+ g.fs = undefined;
229
+ await fsp.rm(root, { recursive: true, force: true });
230
+ });
231
+
232
+ it("失败后在冷却窗内不再重试(cooling 桶),出窗恢复重试;成功清除冷却", async () => {
233
+ let t = 1_000_000;
234
+ installLocal.mockRejectedValueOnce(new Error("验签失败"));
235
+ const r1 = await convergeBundledSolutions(env(() => t));
236
+ expect(r1.failed).toHaveLength(1);
237
+ // 窗内(10 分钟 = 600_000ms):cooling,installLocal 不再被调
238
+ t += 60_000;
239
+ const r2 = await convergeBundledSolutions(env(() => t));
240
+ expect(r2.cooling).toEqual(["oj-market@0.2.0"]);
241
+ expect(installLocal).toHaveBeenCalledTimes(1);
242
+ // 出窗:重试并成功 → 冷却清除;此后同版本 skip
243
+ t += 600_000;
244
+ const r3 = await convergeBundledSolutions(env(() => t));
245
+ expect(r3.installed).toEqual(["oj-market@0.2.0"]);
246
+ expect(installLocal).toHaveBeenCalledTimes(2);
247
+ // 成功清除冷却:冷却文件已无该键
248
+ const cd = JSON.parse(await fsp.readFile(cooldownPath, "utf-8"));
249
+ expect(cd).toEqual({});
250
+ });
251
+
252
+ it("无 cooldownPath(调用方不启用)→ 行为与既有口径一致(全部进 failed 重试)", async () => {
253
+ let calls = 0;
254
+ installLocal.mockImplementation(async () => {
255
+ calls++;
256
+ throw new Error("boom");
257
+ });
258
+ const envNoCd = () => ({ markerPath, cdnDir, statePath, installLocal });
259
+ await convergeBundledSolutions(envNoCd());
260
+ const r2 = await convergeBundledSolutions(envNoCd());
261
+ expect(r2.failed).toHaveLength(1);
262
+ expect(calls).toBe(2);
263
+ });
264
+ });