@oj-module/cli 0.2.2 → 0.2.5

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 (231) hide show
  1. package/devkit/README.md +14 -3
  2. package/devkit/SKILL.md +16 -4
  3. package/devkit/framework-dev.md +8 -6
  4. package/devkit/manual.md +243 -23
  5. package/package.json +2 -2
  6. package/shell-dist/assets/runtime.js +143 -8
  7. package/shell-dist/index.html +2 -2
  8. package/shell-dist/versions.json +3 -3
  9. package/src/args.ts +67 -0
  10. package/src/build.ts +11 -3
  11. package/src/bundle.ts +197 -0
  12. package/src/contract/__tests__/handler-scan.test.ts +82 -0
  13. package/src/contract/check.ts +2 -1
  14. package/src/contract/handler-scan.ts +32 -0
  15. package/src/deterministic-tar.ts +120 -0
  16. package/src/dev.ts +4 -2
  17. package/src/enable.ts +28 -0
  18. package/src/index.ts +63 -2
  19. package/src/info.ts +3 -2
  20. package/src/init.ts +147 -14
  21. package/src/install.ts +268 -0
  22. package/src/keygen.ts +88 -0
  23. package/src/layout.ts +31 -0
  24. package/src/lifecycle/__tests__/authoring.test.ts +109 -0
  25. package/src/lifecycle/__tests__/bundle.test.ts +150 -0
  26. package/src/lifecycle/__tests__/deterministic-tar.test.ts +145 -0
  27. package/src/lifecycle/__tests__/fixtures/README.md +59 -0
  28. package/src/lifecycle/__tests__/fixtures/fixture-invalid/EXPECTED-VIOLATIONS.md +27 -0
  29. package/src/lifecycle/__tests__/fixtures/fixture-invalid/api/src/Demo/manifest.yaml +6 -0
  30. package/src/lifecycle/__tests__/fixtures/fixture-invalid/manifest.yaml +40 -0
  31. package/src/lifecycle/__tests__/fixtures/fixture-invalid/web/src/Demo/entry.ts +14 -0
  32. package/src/lifecycle/__tests__/fixtures/fixture-invalid/web/src/Demo/locales/en-US.json +12 -0
  33. package/src/lifecycle/__tests__/fixtures/fixture-invalid/web/src/Demo/locales/zh-CN.json +12 -0
  34. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/manifest.yaml +9 -0
  35. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/migrations/0001__init.sql +6 -0
  36. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/ping/api.ts +9 -0
  37. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/schema.yaml +9 -0
  38. package/src/lifecycle/__tests__/fixtures/fixture-valid/manifest.yaml +96 -0
  39. package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/entry.ts +23 -0
  40. package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/locales/en-US.json +29 -0
  41. package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/locales/zh-CN.json +29 -0
  42. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz +0 -0
  43. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.bad.sig +7 -0
  44. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.sig +7 -0
  45. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.unknown.sig +7 -0
  46. package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.d.mts +21 -0
  47. package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.mjs +196 -0
  48. package/src/lifecycle/__tests__/helpers.ts +78 -0
  49. package/src/lifecycle/__tests__/install.test.ts +514 -0
  50. package/src/lifecycle/__tests__/keygen.test.ts +61 -0
  51. package/src/lifecycle/__tests__/manifest-contract.test.ts +228 -0
  52. package/src/lifecycle/__tests__/publish.test.ts +80 -0
  53. package/src/lifecycle/__tests__/sig-fixtures.test.ts +100 -0
  54. package/src/lifecycle/__tests__/sign.test.ts +131 -0
  55. package/src/lifecycle/__tests__/smoke.test.ts +61 -0
  56. package/src/lifecycle/__tests__/trust.test.ts +177 -0
  57. package/src/lifecycle/__tests__/uninstall.test.ts +87 -0
  58. package/src/lifecycle/keygen.ts +40 -0
  59. package/src/lifecycle/sign.ts +74 -0
  60. package/src/manifest-contract.ts +696 -0
  61. package/src/oj-cert.ts +19 -0
  62. package/src/oj-cmd.ts +3 -3
  63. package/src/preview.ts +2 -2
  64. package/src/publish.ts +150 -0
  65. package/src/sign.ts +128 -0
  66. package/src/site.ts +146 -0
  67. package/src/trust.ts +116 -0
  68. package/src/uninstall.ts +29 -0
  69. package/src/usage.ts +35 -1
  70. package/templates/README.md +1 -1
  71. package/templates/api/config.yaml +47 -0
  72. package/templates/api/seed.mysql.sql +1 -1
  73. package/templates/api/seed.pg.sql +1 -1
  74. package/templates/api/src/_platform/_lifecycle/atomic.ts +98 -0
  75. package/templates/api/src/_platform/_lifecycle/history.ts +31 -0
  76. package/templates/api/src/_platform/_lifecycle/installer.ts +1080 -0
  77. package/templates/api/src/_platform/_lifecycle/lock.ts +148 -0
  78. package/templates/api/src/_platform/_lifecycle/semver.ts +61 -0
  79. package/templates/api/src/_platform/_lifecycle/sha384.ts +187 -0
  80. package/templates/api/src/_platform/_lifecycle/state.ts +256 -0
  81. package/templates/api/src/_platform/_lifecycle/steps/deploy-web.ts +182 -0
  82. package/templates/api/src/_platform/_lifecycle/steps/download.ts +90 -0
  83. package/templates/api/src/_platform/_lifecycle/steps/extract.ts +239 -0
  84. package/templates/api/src/_platform/_lifecycle/steps/migrate.ts +363 -0
  85. package/templates/api/src/_platform/_lifecycle/steps/preflight.ts +590 -0
  86. package/templates/api/src/_platform/_lifecycle/steps/register.ts +211 -0
  87. package/templates/api/src/_platform/_lifecycle/steps/reload.ts +81 -0
  88. package/templates/api/src/_platform/_lifecycle/steps/smoke.ts +216 -0
  89. package/templates/api/src/_platform/_lifecycle/steps/verify.ts +207 -0
  90. package/templates/api/src/_platform/_lifecycle/tasks.ts +119 -0
  91. package/templates/api/src/_platform/_lifecycle/text-edit.ts +110 -0
  92. package/templates/api/src/_platform/_lifecycle/tgz-guard.ts +41 -0
  93. package/templates/api/src/_platform/_lifecycle/trust.ts +229 -0
  94. package/templates/api/src/_platform/_lifecycle/types.ts +94 -0
  95. package/templates/api/src/_platform/_lifecycle/uninstall.ts +283 -0
  96. package/templates/api/src/_platform/_lifecycle/verify-checks.ts +228 -0
  97. package/templates/api/src/_platform/_lifecycle/verify-report.ts +92 -0
  98. package/templates/api/src/_platform/_shared/service/access.ts +2 -2
  99. package/templates/api/src/_platform/_shared/service/settings.ts +10 -1
  100. package/templates/api/src/_platform/_shared/service/solution-perms.ts +14 -7
  101. package/templates/api/src/_platform/_shared/site-facade.ts +44 -0
  102. package/templates/api/src/_platform/manifest.yaml +1 -0
  103. package/templates/api/src/_platform/market/api.ts +883 -0
  104. package/templates/api/src/_platform/market/bootstrap.ts +39 -0
  105. package/templates/api/src/_platform/migrations/0010__sys_solution_route.mysql.sql +17 -0
  106. package/templates/api/src/_platform/migrations/0010__sys_solution_route.pg.sql +16 -0
  107. package/templates/api/src/_platform/migrations/0010__sys_solution_route.sql +18 -0
  108. package/templates/api/src/_platform/migrations/0011__sys_setting_group_label.mysql.sql +2 -0
  109. package/templates/api/src/_platform/migrations/0011__sys_setting_group_label.sql +4 -0
  110. package/templates/api/src/_platform/migrations/0012__market_history.mysql.sql +13 -0
  111. package/templates/api/src/_platform/migrations/0012__market_history.pg.sql +12 -0
  112. package/templates/api/src/_platform/migrations/0012__market_history.sql +14 -0
  113. package/templates/api/src/_platform/schema.yaml +21 -4
  114. package/templates/api/src/_platform/seed.sql +2 -2
  115. package/templates/api/src/_platform/solutions/api.ts +212 -0
  116. package/templates/api/src/demo/manifest.yaml +5 -0
  117. package/templates/api/src/demo/migrations/0001__create_todos.sql +9 -0
  118. package/templates/api/src/demo/schema.yaml +11 -0
  119. package/templates/api/src/demo/seed.sql +5 -0
  120. package/templates/api/src/demo/todos/api.ts +59 -0
  121. package/templates/api/src/home/line/api.ts +57 -0
  122. package/templates/api/src/home/manifest.yaml +6 -0
  123. package/templates/api/src/home/migrations/0001__create_home_tables.sql +13 -0
  124. package/templates/api/src/home/pie/api.ts +40 -0
  125. package/templates/api/src/home/schema.yaml +14 -0
  126. package/templates/api/src/home/seed.sql +70 -0
  127. package/templates/api/src/system/oplog/api.ts +44 -2
  128. package/templates/api/tests/fixture.ts +16 -0
  129. package/templates/api/tests/lifecycle/atomic-state.spec.ts +183 -0
  130. package/templates/api/tests/lifecycle/bootstrap.spec.ts +116 -0
  131. package/templates/api/tests/lifecycle/deploy-smoke.spec.ts +492 -0
  132. package/templates/api/tests/lifecycle/installer.spec.ts +1218 -0
  133. package/templates/api/tests/lifecycle/market-api.spec.ts +971 -0
  134. package/templates/api/tests/lifecycle/migrate.spec.ts +575 -0
  135. package/templates/api/tests/lifecycle/preflight.spec.ts +255 -0
  136. package/templates/api/tests/lifecycle/register.spec.ts +194 -0
  137. package/templates/api/tests/lifecycle/semver.spec.ts +43 -0
  138. package/templates/api/tests/lifecycle/sha384.spec.ts +48 -0
  139. package/templates/api/tests/lifecycle/sig-fixtures.spec.ts +65 -0
  140. package/templates/api/tests/lifecycle/solutions-api.spec.ts +98 -0
  141. package/templates/api/tests/lifecycle/steps-fs.spec.ts +623 -0
  142. package/templates/api/tests/lifecycle/text-edit.spec.ts +177 -0
  143. package/templates/api/tests/lifecycle/tgz-guard.spec.ts +103 -0
  144. package/templates/api/tests/lifecycle/trust.spec.ts +310 -0
  145. package/templates/api/tests/lifecycle/uninstall.spec.ts +386 -0
  146. package/templates/api/tests/lifecycle/verify-checks.spec.ts +385 -0
  147. package/templates/api/tests/lifecycle/verify-report.spec.ts +290 -0
  148. package/templates/api/tests/rbac-settings.spec.ts +8 -0
  149. package/templates/init.include.yaml +56 -0
  150. package/templates/openapi.json +527 -0
  151. package/templates/package.json +52 -0
  152. package/templates/web/src/_platform/__client/api.schemas.ts +8 -0
  153. package/templates/web/src/_platform/__client/api.ts +335 -0
  154. package/templates/web/src/about/entry.ts +42 -0
  155. package/templates/web/src/about/locales/en-US.json +16 -0
  156. package/templates/web/src/about/locales/zh-CN.json +16 -0
  157. package/templates/web/src/about/pages/constants.ts +20 -0
  158. package/templates/web/src/about/pages/index.tsx +135 -0
  159. package/templates/web/src/access/entry.ts +74 -0
  160. package/templates/web/src/access/locales/en-US.json +46 -0
  161. package/templates/web/src/access/locales/zh-CN.json +46 -0
  162. package/templates/web/src/access/pages/access-mode/index.tsx +16 -0
  163. package/templates/web/src/access/pages/admin-visible/index.tsx +16 -0
  164. package/templates/web/src/access/pages/button-control/index.tsx +181 -0
  165. package/templates/web/src/access/pages/common-visible/index.tsx +16 -0
  166. package/templates/web/src/access/pages/page-control/index.tsx +142 -0
  167. package/templates/web/src/demo/__client/api.schemas.ts +20 -0
  168. package/templates/web/src/demo/__client/api.ts +89 -0
  169. package/templates/web/src/exception/entry.ts +80 -0
  170. package/templates/web/src/exception/locales/en-US.json +13 -0
  171. package/templates/web/src/exception/locales/zh-CN.json +13 -0
  172. package/templates/web/src/exception/pages/403/index.tsx +30 -0
  173. package/templates/web/src/exception/pages/404/index.tsx +30 -0
  174. package/templates/web/src/exception/pages/500/index.tsx +30 -0
  175. package/templates/web/src/exception/pages/unknown-component/index.tsx +38 -0
  176. package/templates/web/src/home/__client/api.schemas.ts +22 -0
  177. package/templates/web/src/home/__client/api.ts +113 -0
  178. package/templates/web/src/login/locales/en-US.json +6 -0
  179. package/templates/web/src/login/locales/zh-CN.json +6 -0
  180. package/templates/web/src/login/pages/login.tsx +67 -18
  181. package/templates/web/src/oj-market/__tests__/CardGrid.test.tsx +93 -0
  182. package/templates/web/src/oj-market/__tests__/HistoryDrawer.test.tsx +28 -0
  183. package/templates/web/src/oj-market/__tests__/InstallDialog.test.tsx +69 -0
  184. package/templates/web/src/oj-market/__tests__/RollbackDialog.test.tsx +43 -0
  185. package/templates/web/src/oj-market/__tests__/UninstallConfirm.test.tsx +30 -0
  186. package/templates/web/src/oj-market/__tests__/marketClient.fake.ts +70 -0
  187. package/templates/web/src/oj-market/__tests__/poll.test.ts +24 -0
  188. package/templates/web/src/oj-market/__tests__/render.tsx +14 -0
  189. package/templates/web/src/oj-market/__tests__/runtime-stub.ts +14 -0
  190. package/templates/web/src/oj-market/__tests__/semver.test.ts +26 -0
  191. package/templates/web/src/oj-market/api/MarketClientProvider.tsx +12 -0
  192. package/templates/web/src/oj-market/api/client.ts +135 -0
  193. package/templates/web/src/oj-market/api/context.ts +11 -0
  194. package/templates/web/src/oj-market/api/defaultClient.ts +7 -0
  195. package/templates/web/src/oj-market/components/CardGrid.tsx +76 -0
  196. package/templates/web/src/oj-market/components/CardItem.tsx +102 -0
  197. package/templates/web/src/oj-market/components/DetailDrawer.tsx +53 -0
  198. package/templates/web/src/oj-market/components/HistoryDrawer.tsx +75 -0
  199. package/templates/web/src/oj-market/components/InstallDialog.tsx +112 -0
  200. package/templates/web/src/oj-market/components/RollbackDialog.tsx +79 -0
  201. package/templates/web/src/oj-market/components/UninstallConfirm.tsx +74 -0
  202. package/templates/web/src/oj-market/entry.ts +27 -0
  203. package/templates/web/src/oj-market/hooks/useMarket.ts +91 -0
  204. package/templates/web/src/oj-market/locales/en-US.json +100 -0
  205. package/templates/web/src/oj-market/locales/zh-CN.json +100 -0
  206. package/templates/web/src/oj-market/pages/MarketApp.tsx +136 -0
  207. package/templates/web/src/oj-market/types.ts +84 -0
  208. package/templates/web/src/oj-market/utils/poll.ts +36 -0
  209. package/templates/web/src/oj-market/utils/semver.ts +14 -0
  210. package/templates/web/src/outside/entry.ts +80 -0
  211. package/templates/web/src/outside/locales/en-US.json +10 -0
  212. package/templates/web/src/outside/locales/zh-CN.json +10 -0
  213. package/templates/web/src/personal-center/entry.ts +1 -10
  214. package/templates/web/src/personal-center/locales/en-US.json +1 -5
  215. package/templates/web/src/personal-center/locales/zh-CN.json +1 -5
  216. package/templates/web/src/route-nest/entry.ts +73 -0
  217. package/templates/web/src/route-nest/locales/en-US.json +9 -0
  218. package/templates/web/src/route-nest/locales/zh-CN.json +9 -0
  219. package/templates/web/src/route-nest/pages/menu1/menu1-1/index.tsx +5 -0
  220. package/templates/web/src/route-nest/pages/menu1/menu1-2/index.tsx +5 -0
  221. package/templates/web/src/route-nest/pages/menu2/index.tsx +5 -0
  222. package/templates/web/src/system/__client/api.schemas.ts +12 -0
  223. package/templates/web/src/system/__client/api.ts +25 -1
  224. package/templates/web/src/system/pages/oplog/index.tsx +1 -1
  225. package/templates/web/src/system/pages/roles/index.tsx +1 -0
  226. package/templates/web/src/system/pages/settings/index.tsx +1 -1
  227. package/templates/web/src/system/pages/users/index.tsx +1 -1
  228. package/templates/web.config.ts +32 -40
  229. package/vendor/host-versions.json +3 -3
  230. package/templates/pnpm-workspace.yaml +0 -14
  231. package/templates/web/src/personal-center/pages/settings/index.tsx +0 -5
@@ -0,0 +1,514 @@
1
+ import type { TaskRecord } from "../../site";
2
+ // BDD:CLI 瘦客户端(P1-13,D6/D8)。三块:参数解析 / 轮询退避 / 终态渲染,另加
3
+ // install 双源(file 上传 / cdn)端到端 stub fetch 用例钉住请求形状(信封 {code,msg,data}、
4
+ // Bearer 注入、multipart 字段与服务端 uploadBytes 消费面对齐)。
5
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
9
+ import { parseLifecycleArgs } from "../../args";
10
+ import { setEnabledCommand } from "../../enable";
11
+ import { installCommand, parseTarget, renderOutcome } from "../../install";
12
+ import { pollTask, resolveSite } from "../../site";
13
+
14
+ /** 终态记录工厂(§10.2 形状,测试只需关键字段) */
15
+ function rec(over: Partial<TaskRecord> = {}): TaskRecord {
16
+ return {
17
+ id: "install-1-abc",
18
+ name: "demo-solution",
19
+ version: "0.1.0",
20
+ status: "done",
21
+ stage: "done",
22
+ progress: 100,
23
+ message: "安装完成",
24
+ stages: [],
25
+ startedAt: 1,
26
+ finishedAt: 2,
27
+ ...over,
28
+ };
29
+ }
30
+
31
+ /** fetch stub:按 URL 前缀分发,记录全部调用(url + init) */
32
+ function fetchStub(handlers: Record<string, (url: string, init?: RequestInit) => unknown>): {
33
+ fetchFn: typeof fetch
34
+ calls: { url: string, init?: RequestInit }[]
35
+ } {
36
+ const calls: { url: string, init?: RequestInit }[] = [];
37
+ const fetchFn = (async (url: string | URL, init?: RequestInit) => {
38
+ const u = String(url);
39
+ calls.push({ url: u, init });
40
+ for (const [prefix, handler] of Object.entries(handlers)) {
41
+ if (u.includes(prefix))
42
+ return handler(u, init);
43
+ }
44
+ throw new Error(`fetchStub 未覆盖:${u}`);
45
+ }) as typeof fetch;
46
+ return { fetchFn, calls };
47
+ }
48
+
49
+ const ok = (data: unknown) => ({ ok: true, status: 200, text: async () => JSON.stringify({ code: 0, data }) });
50
+
51
+ let logs: string[];
52
+
53
+ beforeEach(() => {
54
+ logs = [];
55
+ vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => {
56
+ logs.push(args.map(String).join(" "));
57
+ });
58
+ });
59
+
60
+ afterEach(() => {
61
+ vi.restoreAllMocks();
62
+ });
63
+
64
+ describe("parseLifecycleArgs · 参数解析", () => {
65
+ it("位置参数与值式 flag 分离:--url 等的值不被误当位置参数(P6 同款坑)", () => {
66
+ const a = parseLifecycleArgs(["demo-solution@0.1.0", "--url", "http://x:1", "--user", "u", "--pass", "p", "--locale", "en-US"]);
67
+ expect(a.positionals).toEqual(["demo-solution@0.1.0"]);
68
+ expect(a.url).toBe("http://x:1");
69
+ expect(a.user).toBe("u");
70
+ expect(a.pass).toBe("p");
71
+ expect(a.locale).toBe("en-US");
72
+ });
73
+
74
+ it("install 专属 --name/--version 覆盖位", () => {
75
+ const a = parseLifecycleArgs(["./a.tgz", "--name", "demo-solution", "--version", "0.2.0"]);
76
+ expect(a.positionals).toEqual(["./a.tgz"]);
77
+ expect(a.name).toBe("demo-solution");
78
+ expect(a.version).toBe("0.2.0");
79
+ });
80
+
81
+ it("未知 --flag 忽略不炸", () => {
82
+ expect(parseLifecycleArgs(["x", "--future"]).positionals).toEqual(["x"]);
83
+ });
84
+ });
85
+
86
+ describe("resolveSite · 站点寻址(flag > 环境变量 > 缺省模板 serve 端口 9778)", () => {
87
+ const ENV_BACKUP = { ...process.env };
88
+
89
+ afterEach(() => {
90
+ process.env = { ...ENV_BACKUP };
91
+ });
92
+
93
+ it("缺省 base = http://127.0.0.1:9778(模板 config.yaml server.port)", () => {
94
+ expect(resolveSite({ user: "u", pass: "p" })).toEqual({ base: "http://127.0.0.1:9778", user: "u", pass: "p" });
95
+ });
96
+
97
+ it("环境变量 OJM_URL/OJM_USER/OJM_PASS 兜底;flag 优先;尾斜杠归一", () => {
98
+ process.env.OJM_URL = "http://env:9/";
99
+ process.env.OJM_USER = "eu";
100
+ process.env.OJM_PASS = "ep";
101
+ expect(resolveSite({})).toEqual({ base: "http://env:9", user: "eu", pass: "ep" });
102
+ expect(resolveSite({ url: "http://flag:8/", user: "fu", pass: "fp" }).base).toBe("http://flag:8");
103
+ });
104
+
105
+ it("缺凭据即人话报错(凭据只走内存不落盘)", () => {
106
+ delete process.env.OJM_USER;
107
+ delete process.env.OJM_PASS;
108
+ expect(() => resolveSite({})).toThrow(/OJM_USER\/OJM_PASS/);
109
+ });
110
+ });
111
+
112
+ describe("pollTask · 轮询退避(800ms 起步指数退避、上限 5s、超时报错)", () => {
113
+ const sleepLog: number[] = [];
114
+ const sleep = async (ms: number) => {
115
+ sleepLog.push(ms);
116
+ };
117
+
118
+ it("running 两拍后 done:间隔 800→1600 指数退避,返回终态", async () => {
119
+ sleepLog.length = 0;
120
+ let n = 0;
121
+ const { fetchFn } = fetchStub({
122
+ "/api/_platform/market/task": () => ok(n++ < 2 ? rec({ status: "running", progress: 30 * n }) : rec()),
123
+ });
124
+ const r = await pollTask(fetchFn, "http://x", "tok", "t1", { sleep });
125
+ expect(r.status).toBe("done");
126
+ expect(sleepLog).toEqual([800, 1600]);
127
+ });
128
+
129
+ it("间隔封顶 5s:第 4 拍起不再翻倍", async () => {
130
+ sleepLog.length = 0;
131
+ let n = 0;
132
+ const { fetchFn } = fetchStub({
133
+ "/api/_platform/market/task": () => ok(rec({ status: n++ < 5 ? "running" : "done", progress: 10 + n })),
134
+ });
135
+ const r = await pollTask(fetchFn, "http://x", "tok", "t1", { sleep, timeoutMs: 20_000 });
136
+ expect(r.status).toBe("done");
137
+ expect(sleepLog).toEqual([800, 1600, 3200, 5000, 5000]);
138
+ });
139
+
140
+ it("超时报错:总等待预算耗尽即人话退出", async () => {
141
+ sleepLog.length = 0;
142
+ const { fetchFn } = fetchStub({
143
+ "/api/_platform/market/task": () => ok(rec({ status: "running" })),
144
+ });
145
+ await expect(pollTask(fetchFn, "http://x", "tok", "t1", { sleep, timeoutMs: 1000 })).rejects.toThrow(/超时/);
146
+ expect(sleepLog).toEqual([800]);
147
+ });
148
+
149
+ it("查询参数形态:task?id=<id>(非路径段,P1-12 裁决)", async () => {
150
+ const { fetchFn, calls } = fetchStub({
151
+ "/api/_platform/market/task": () => ok(rec()),
152
+ });
153
+ await pollTask(fetchFn, "http://x", "tok", "t1", { sleep });
154
+ expect(calls[0]?.url).toContain("task?id=t1");
155
+ });
156
+ });
157
+
158
+ describe("renderOutcome · 终态渲染(reason + retryable + hintKey 双语文案,契约附录 A)", () => {
159
+ it("done:单行完成文案", () => {
160
+ const text = renderOutcome(rec(), "zh-CN");
161
+ expect(text).toContain("安装完成");
162
+ expect(text).toContain("demo-solution 0.1.0");
163
+ });
164
+
165
+ it("failed zh-CN:原因 + 指引 + 重试标注三行齐", () => {
166
+ const text = renderOutcome(rec({
167
+ status: "failed",
168
+ stage: "preflight",
169
+ error: { reason: "预检未通过:名称被保留", retryable: false, hintKey: "lifecycle.stage.preflight.hint" },
170
+ }), "zh-CN");
171
+ expect(text).toContain("安装失败");
172
+ expect(text).toContain("预检未通过:名称被保留");
173
+ expect(text).toContain("请修正方案清单或释放占用的名称与前缀后重新安装");
174
+ expect(text).toContain("本次失败不可自动重试");
175
+ expect(text).toContain("lifecycle.stage.preflight.hint");
176
+ });
177
+
178
+ it("partial zh-CN:部分生效标注 + smoke 指引", () => {
179
+ const text = renderOutcome(rec({
180
+ status: "partial",
181
+ stage: "smoke",
182
+ error: { reason: "冒烟检查未通过", retryable: false, hintKey: "lifecycle.stage.smoke.hint" },
183
+ }), "zh-CN");
184
+ expect(text).toContain("部分生效");
185
+ expect(text).toContain("可先禁用该方案恢复原状");
186
+ });
187
+
188
+ it("en-US:双语全表取 en 列", () => {
189
+ const text = renderOutcome(rec({
190
+ status: "failed",
191
+ stage: "downloading",
192
+ error: { reason: "download boom", retryable: true, hintKey: "lifecycle.stage.downloading.hint" },
193
+ }), "en-US");
194
+ expect(text).toContain("Install failed");
195
+ expect(text).toContain("Check the network and market URL");
196
+ expect(text).toContain("This failure is retryable");
197
+ });
198
+
199
+ it("未知 stage:兜底指引不炸", () => {
200
+ const text = renderOutcome(rec({
201
+ status: "failed",
202
+ stage: "mystery",
203
+ error: { reason: "boom", retryable: true, hintKey: "lifecycle.stage.mystery.hint" },
204
+ }), "zh-CN");
205
+ expect(text).toContain("boom");
206
+ expect(text).toContain("本次失败可重试");
207
+ });
208
+
209
+ it("needs_trust(P2-3 第五终态):待信任确认渲染——非成功态、非失败崩溃,含指纹分组与确认命令", () => {
210
+ const text = renderOutcome(rec({
211
+ status: "needs_trust",
212
+ stage: "verifying",
213
+ message: "待信任确认",
214
+ trust: { key_id: "stranger.pub", public_key: "b".repeat(64), fingerprint: "0123456789abcdef".repeat(4) },
215
+ error: { reason: "unknown_key", retryable: false, hintKey: "lifecycle.stage.verifying.unknown_key" },
216
+ }), "zh-CN");
217
+ expect(text).toContain("待信任确认");
218
+ expect(text).toContain("stranger.pub");
219
+ expect(text).toContain("ojm trust confirm --task install-1-abc");
220
+ expect(text).toMatch(/01234567 89abcdef 01234567 89abcdef/); // 指纹 8 分组
221
+ expect(text).not.toContain("安装完成");
222
+ const en = renderOutcome(rec({
223
+ status: "needs_trust",
224
+ stage: "verifying",
225
+ message: "trust",
226
+ trust: { key_id: "stranger.pub", public_key: "b".repeat(64), fingerprint: "0123456789abcdef".repeat(4) },
227
+ }), "en-US");
228
+ expect(en).toContain("Trust confirmation required");
229
+ });
230
+ });
231
+
232
+ describe("schemaVersion 旧记录兼容(P2-10 升版纪律读方侧:高版本降级提示 + 不误渲染成功)", () => {
233
+ let dir: string;
234
+
235
+ beforeEach(() => {
236
+ dir = mkdtempSync(join(tmpdir(), "ojm-schema-"));
237
+ });
238
+
239
+ afterEach(() => {
240
+ rmSync(dir, { recursive: true, force: true });
241
+ });
242
+
243
+ it("renderOutcome:schemaVersion=99 即使 status=done 也只出降级提示,不报「安装完成」", () => {
244
+ const text = renderOutcome(rec({ status: "done", schemaVersion: 99 }), "zh-CN");
245
+ expect(text).toContain("schema 版本 99");
246
+ expect(text).toContain("升级 ojm");
247
+ expect(text).not.toContain("安装完成");
248
+ const en = renderOutcome(rec({ status: "done", schemaVersion: 99 }), "en-US");
249
+ expect(en).toContain("newer than this CLI supports");
250
+ expect(en).not.toContain("Install complete");
251
+ });
252
+
253
+ it("renderOutcome:旧记录(schemaVersion 缺省)照常渲染 done 成功文案", () => {
254
+ expect(renderOutcome(rec(), "zh-CN")).toContain("安装完成");
255
+ });
256
+
257
+ it("pollTask:高版本记录原样返回不炸(轮询消费面兼容)", async () => {
258
+ const { fetchFn } = fetchStub({
259
+ "/api/_platform/market/task": () => ok(rec({ status: "done", schemaVersion: 99 })),
260
+ });
261
+ const r = await pollTask(fetchFn, "http://x", "tok", "t1", { sleep: async () => {} });
262
+ expect(r.status).toBe("done");
263
+ expect(r.schemaVersion).toBe(99);
264
+ });
265
+
266
+ it("installCommand:终态 done 但 schemaVersion=99 → exitCode=1(非成功态)+ 降级提示", async () => {
267
+ const tgz = join(dir, "demo-solution-0.1.0.tgz");
268
+ writeFileSync(tgz, "fake-tgz-bytes");
269
+ const { fetchFn } = fetchStub({
270
+ "/api/auth/login": () => ok({ access_token: "tok", refresh_token: "r", expires_in: 1 }),
271
+ "/api/_platform/market/install": () => ok({ task_id: "t1" }),
272
+ "/api/_platform/market/task": () => ok(rec({ status: "done", schemaVersion: 99 })),
273
+ });
274
+ const exitBackup = process.exitCode;
275
+ process.exitCode = 0;
276
+ try {
277
+ await installCommand(tgz, { url: "http://x", user: "u", pass: "p", fetchFn, sleep: async () => {} });
278
+ expect(process.exitCode).toBe(1);
279
+ }
280
+ finally {
281
+ process.exitCode = exitBackup;
282
+ }
283
+ expect(logs.join("\n")).toContain("升级 ojm");
284
+ });
285
+ });
286
+
287
+ describe("renderOutcome · 六码双语(P2-5:reason 走 key,按 locale 取附录 A.2;fail-fast 实测渲染而非仅 key 存在)", () => {
288
+ const ZH = [
289
+ ["sig_missing", "缺少签名"],
290
+ ["sig_alg", "签名算法不被接受"],
291
+ ["sig_key", "公钥标识(key_id)不合法"],
292
+ ["tampered", "验签失败"],
293
+ ["unknown_key", "未知发布方"],
294
+ ["sig_format", "签名编码违规"],
295
+ ] as const;
296
+ const EN = [
297
+ ["sig_missing", "Missing signature"],
298
+ ["sig_alg", "Unsupported signature algorithm"],
299
+ ["sig_key", "Key identifier (key_id) is invalid"],
300
+ ["tampered", "Signature verification failed"],
301
+ ["unknown_key", "Unknown publisher"],
302
+ ["sig_format", "Signature encoding violation"],
303
+ ] as const;
304
+
305
+ for (const [code, needle] of ZH) {
306
+ it(`zh-CN 渲染 ${code} 双语文案`, () => {
307
+ const text = renderOutcome(rec({
308
+ status: "failed",
309
+ stage: "verifying",
310
+ error: { reason: code, retryable: false, hintKey: `lifecycle.stage.verifying.${code}` },
311
+ }), "zh-CN");
312
+ expect(text).toContain(needle);
313
+ expect(text).toContain("本次失败不可自动重试");
314
+ expect(text).toContain(`lifecycle.stage.verifying.${code}`);
315
+ });
316
+ }
317
+ for (const [code, needle] of EN) {
318
+ it(`en-US 渲染 ${code} 双语文案`, () => {
319
+ const text = renderOutcome(rec({
320
+ status: "failed",
321
+ stage: "verifying",
322
+ error: { reason: code, retryable: false, hintKey: `lifecycle.stage.verifying.${code}` },
323
+ }), "en-US");
324
+ expect(text).toContain(needle);
325
+ expect(text).toContain("This failure is not retryable");
326
+ });
327
+ }
328
+
329
+ it("reason 走 key:en-US 用户不再读到服务端中文串(reason=code 时取双语文案,非中文 fallback)", () => {
330
+ const text = renderOutcome(rec({
331
+ status: "failed",
332
+ stage: "verifying",
333
+ error: { reason: "sig_missing", retryable: false, hintKey: "lifecycle.stage.verifying.sig_missing" },
334
+ }), "en-US");
335
+ expect(text).not.toContain("缺少签名");
336
+ expect(text).toContain("Missing signature");
337
+ });
338
+ });
339
+
340
+ describe("parseTarget · 安装目标解析", () => {
341
+ it("name[@version] → cdn 源", () => {
342
+ expect(parseTarget("demo-solution")).toEqual({ kind: "cdn", name: "demo-solution", version: null });
343
+ expect(parseTarget("demo-solution@0.1.0")).toEqual({ kind: "cdn", name: "demo-solution", version: "0.1.0" });
344
+ });
345
+
346
+ it(".tgz 路径 → file 源;<name>-<version>.tgz 文件名推导身份", () => {
347
+ expect(parseTarget("dist/demo-solution-0.1.0.tgz")).toEqual({ kind: "file", path: "dist/demo-solution-0.1.0.tgz", name: "demo-solution", version: "0.1.0" });
348
+ expect(parseTarget("weird.tgz")).toEqual({ kind: "file", path: "weird.tgz", name: null, version: null });
349
+ });
350
+
351
+ it("prerelease 文件名推导:demo-solution-1.0.0-beta.tgz → version 含 -beta 段(Finding C)", () => {
352
+ expect(parseTarget("demo-solution-1.0.0-beta.tgz")).toEqual({ kind: "file", path: "demo-solution-1.0.0-beta.tgz", name: "demo-solution", version: "1.0.0-beta" });
353
+ expect(parseTarget("demo-1.0.0-rc.1.tgz")).toEqual({ kind: "file", path: "demo-1.0.0-rc.1.tgz", name: "demo", version: "1.0.0-rc.1" });
354
+ });
355
+
356
+ it("cdn 名含非法形状(空名)即报错", () => {
357
+ expect(() => parseTarget("@0.1.0")).toThrow();
358
+ });
359
+ });
360
+
361
+ describe("installCommand · file 源端到端(stub fetch:登录 → multipart 上传 → 轮询终态)", () => {
362
+ let dir: string;
363
+
364
+ beforeEach(() => {
365
+ dir = mkdtempSync(join(tmpdir(), "ojm-install-"));
366
+ });
367
+
368
+ afterEach(() => {
369
+ rmSync(dir, { recursive: true, force: true });
370
+ });
371
+
372
+ it("登录后 Bearer 注入;FormData 字段与服务端 http.files 消费面对齐;终态 done 打印", async () => {
373
+ const tgz = join(dir, "demo-solution-0.1.0.tgz");
374
+ writeFileSync(tgz, "fake-tgz-bytes");
375
+ const { fetchFn, calls } = fetchStub({
376
+ "/api/auth/login": () => ok({ access_token: "tok-1", refresh_token: "r", expires_in: 1 }),
377
+ "/api/_platform/market/install": () => ok({ task_id: "t1" }),
378
+ "/api/_platform/market/task": () => ok(rec()),
379
+ });
380
+ await installCommand(tgz, { url: "http://x", user: "u", pass: "p", fetchFn, sleep: async () => {} });
381
+ // 登录体
382
+ const loginBody = JSON.parse(String(calls[0]?.init?.body));
383
+ expect(loginBody).toEqual({ username: "u", password: "p" });
384
+ // install:multipart 字段 + Bearer
385
+ const installCall = calls[1]!;
386
+ expect(installCall.init?.method).toBe("POST");
387
+ expect((installCall.init?.headers as Record<string, string>).authorization).toBe("Bearer tok-1");
388
+ const form = installCall.init?.body as FormData;
389
+ expect(form.get("name")).toBe("demo-solution");
390
+ expect(form.get("version")).toBe("0.1.0");
391
+ expect(form.get("source")).toBe("file");
392
+ expect(form.get("file")).toBeInstanceOf(File);
393
+ // 轮询带 Bearer;终态渲染
394
+ expect(calls[2]?.url).toContain("task?id=t1");
395
+ expect((calls[2]?.init?.headers as Record<string, string>).authorization).toBe("Bearer tok-1");
396
+ expect(logs.join("\n")).toContain("安装完成");
397
+ });
398
+
399
+ it("终态 failed:非零退出码(exitCode),多行人话已打印", async () => {
400
+ const tgz = join(dir, "demo-solution-0.1.0.tgz");
401
+ writeFileSync(tgz, "fake-tgz-bytes");
402
+ const { fetchFn } = fetchStub({
403
+ "/api/auth/login": () => ok({ access_token: "tok", refresh_token: "r", expires_in: 1 }),
404
+ "/api/_platform/market/install": () => ok({ task_id: "t1" }),
405
+ "/api/_platform/market/task": () => ok(rec({
406
+ status: "failed",
407
+ stage: "migrating",
408
+ error: { reason: "迁移失败", retryable: true, hintKey: "lifecycle.stage.migrating.hint" },
409
+ })),
410
+ });
411
+ const exitBackup = process.exitCode;
412
+ process.exitCode = 0;
413
+ try {
414
+ await installCommand(tgz, { url: "http://x", user: "u", pass: "p", fetchFn, sleep: async () => {} });
415
+ expect(process.exitCode).toBe(1);
416
+ }
417
+ finally {
418
+ process.exitCode = exitBackup;
419
+ }
420
+ expect(logs.join("\n")).toContain("数据已保留原状,请修复后重试");
421
+ });
422
+
423
+ it("detached 侧车自动探测(P2-3,G4):同目录 .sig 随 multipart 第二文件上传", async () => {
424
+ const tgz = join(dir, "demo-solution-0.1.0.tgz");
425
+ writeFileSync(tgz, "fake-tgz-bytes");
426
+ writeFileSync(`${tgz}.sig`, JSON.stringify({ alg: "Ed25519", key_id: "test.publisher", public_key: "a".repeat(64), value: "AAAA", canon: "v1" }));
427
+ const { fetchFn, calls } = fetchStub({
428
+ "/api/auth/login": () => ok({ access_token: "tok", refresh_token: "r", expires_in: 1 }),
429
+ "/api/_platform/market/install": () => ok({ task_id: "t1" }),
430
+ "/api/_platform/market/task": () => ok(rec()),
431
+ });
432
+ await installCommand(tgz, { url: "http://x", user: "u", pass: "p", fetchFn, sleep: async () => {} });
433
+ const form = calls[1]?.init?.body as FormData;
434
+ expect(form.get("sig")).toBeInstanceOf(File);
435
+ expect((form.get("sig") as File).name).toBe("demo-solution-0.1.0.tgz.sig");
436
+ expect(logs.join("\n")).toContain("已随包上传签名侧车");
437
+ });
438
+
439
+ it("无 .sig 侧车:提示将被宿主 G4 拒(不静默)", async () => {
440
+ const tgz = join(dir, "demo-solution-0.1.0.tgz");
441
+ writeFileSync(tgz, "fake-tgz-bytes");
442
+ const { fetchFn, calls } = fetchStub({
443
+ "/api/auth/login": () => ok({ access_token: "tok", refresh_token: "r", expires_in: 1 }),
444
+ "/api/_platform/market/install": () => ok({ task_id: "t1" }),
445
+ "/api/_platform/market/task": () => ok(rec()),
446
+ });
447
+ await installCommand(tgz, { url: "http://x", user: "u", pass: "p", fetchFn, sleep: async () => {} });
448
+ const form = calls[1]?.init?.body as FormData;
449
+ expect(form.get("sig")).toBeNull();
450
+ expect(logs.join("\n")).toContain("未发现签名侧车");
451
+ });
452
+
453
+ it("信封非 0:可读报错(code + msg)", async () => {
454
+ const tgz = join(dir, "demo-solution-0.1.0.tgz");
455
+ writeFileSync(tgz, "x");
456
+ const { fetchFn } = fetchStub({
457
+ "/api/auth/login": () => ({ ok: true, status: 200, text: async () => JSON.stringify({ code: 401, msg: "invalid credentials" }) }),
458
+ });
459
+ await expect(installCommand(tgz, { url: "http://x", user: "u", pass: "bad", fetchFn })).rejects.toThrow(/invalid credentials/);
460
+ });
461
+ });
462
+
463
+ describe("installCommand · cdn 源(JSON 体 name@version)", () => {
464
+ it("source=cdn 的 JSON 体 + 版本缺省 null", async () => {
465
+ const { fetchFn, calls } = fetchStub({
466
+ "/api/auth/login": () => ok({ access_token: "tok", refresh_token: "r", expires_in: 1 }),
467
+ "/api/_platform/market/install": () => ok({ task_id: "t1" }),
468
+ "/api/_platform/market/task": () => ok(rec()),
469
+ });
470
+ await installCommand("demo-solution", { url: "http://x", user: "u", pass: "p", fetchFn, sleep: async () => {} });
471
+ const installCall = calls[1]!;
472
+ expect((installCall.init?.headers as Record<string, string>)["content-type"]).toBe("application/json");
473
+ expect(JSON.parse(String(installCall.init?.body))).toEqual({ name: "demo-solution", version: null, source: "cdn" });
474
+ });
475
+
476
+ it("@version 透传", async () => {
477
+ const { fetchFn, calls } = fetchStub({
478
+ "/api/auth/login": () => ok({ access_token: "tok", refresh_token: "r", expires_in: 1 }),
479
+ "/api/_platform/market/install": () => ok({ task_id: "t1" }),
480
+ "/api/_platform/market/task": () => ok(rec()),
481
+ });
482
+ await installCommand("demo-solution@0.2.0", { url: "http://x", user: "u", pass: "p", fetchFn, sleep: async () => {} });
483
+ expect(JSON.parse(String(calls[1]?.init?.body)).version).toBe("0.2.0");
484
+ });
485
+ });
486
+
487
+ describe("setEnabledCommand · enable/disable 瘦客户端(D8)", () => {
488
+ it("pOST enable {name, enabled} → 打印服务端 message;ok:false 非零退出", async () => {
489
+ const { fetchFn, calls } = fetchStub({
490
+ "/api/auth/login": () => ok({ access_token: "tok", refresh_token: "r", expires_in: 1 }),
491
+ "/api/_platform/market/enable": () => ok({ ok: true, message: "启用完成:四处同写" }),
492
+ });
493
+ await setEnabledCommand("demo-solution", true, { url: "http://x", user: "u", pass: "p", fetchFn });
494
+ const call = calls[1]!;
495
+ expect(call.url).toContain("/api/_platform/market/enable");
496
+ expect(JSON.parse(String(call.init?.body))).toEqual({ name: "demo-solution", enabled: true });
497
+ expect(logs.join("\n")).toContain("启用完成:四处同写");
498
+
499
+ const exitBackup = process.exitCode;
500
+ process.exitCode = 0;
501
+ try {
502
+ const { fetchFn: fetchFn2 } = fetchStub({
503
+ "/api/auth/login": () => ok({ access_token: "tok", refresh_token: "r", expires_in: 1 }),
504
+ "/api/_platform/market/enable": () => ok({ ok: false, message: "禁用失败:「x」未安装" }),
505
+ });
506
+ await setEnabledCommand("x", false, { url: "http://x", user: "u", pass: "p", fetchFn: fetchFn2 });
507
+ expect(process.exitCode).toBe(1);
508
+ }
509
+ finally {
510
+ process.exitCode = exitBackup;
511
+ }
512
+ expect(logs.join("\n")).toContain("禁用失败:「x」未安装");
513
+ });
514
+ });
@@ -0,0 +1,61 @@
1
+ // 密钥工具 spec(P2-1):生成 / PEM 加载 / 与 sign.ts 的接续。
2
+ import { Buffer } from "node:buffer";
3
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import path from "node:path";
6
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
7
+ import { generateKeyPair, readPrivateKeyPem } from "../keygen";
8
+ import { exportRawPublicKeyHex, signCanonical, verifyCanonical } from "../sign";
9
+
10
+ let dir: string;
11
+ beforeEach(() => {
12
+ dir = mkdtempSync(path.join(tmpdir(), "ojm-keygen-"));
13
+ });
14
+ afterEach(() => {
15
+ rmSync(dir, { recursive: true, force: true });
16
+ });
17
+
18
+ describe("generateKeyPair(P2-1)", () => {
19
+ it("ed25519 32B raw 公钥 hex + PKCS#8 PEM;两次生成不同 key", () => {
20
+ const a = generateKeyPair();
21
+ const b = generateKeyPair();
22
+ expect(a.publicKeyHex).toMatch(/^[0-9a-f]{64}$/);
23
+ expect(b.publicKeyHex).toMatch(/^[0-9a-f]{64}$/);
24
+ expect(a.publicKeyHex).not.toBe(b.publicKeyHex);
25
+ expect(a.privateKeyPem).toContain("PRIVATE KEY");
26
+ });
27
+
28
+ it("生成 key 与 sign.ts 往返:export/sign/verify 全通", () => {
29
+ const kp = generateKeyPair();
30
+ expect(exportRawPublicKeyHex(kp.privateKeyPem)).toBe(kp.publicKeyHex);
31
+ const sig = signCanonical(kp.privateKeyPem, `demo-solution@0.1.0:${"0".repeat(64)}`);
32
+ expect(verifyCanonical(kp.publicKeyHex, `demo-solution@0.1.0:${"0".repeat(64)}`, sig)).toBe(true);
33
+ });
34
+
35
+ it("pem 落盘 0600 后可读回(keygen 文件落点的前置面;命令接线属 P2-4)", () => {
36
+ const kp = generateKeyPair();
37
+ const file = path.join(dir, "test.publisher.pem");
38
+ writeFileSync(file, kp.privateKeyPem, { mode: 0o600 });
39
+ const loaded = readPrivateKeyPem(file);
40
+ expect(exportRawPublicKeyHex(loaded)).toBe(kp.publicKeyHex);
41
+ });
42
+ });
43
+
44
+ describe("readPrivateKeyPem fail-closed", () => {
45
+ it("不存在 → 可读错误(不静默)", () => {
46
+ expect(() => readPrivateKeyPem(path.join(dir, "nope.pem"))).toThrow(/私钥文件读取失败/);
47
+ });
48
+
49
+ it("非 PEM 内容 → 可读错误", () => {
50
+ const file = path.join(dir, "bad.pem");
51
+ writeFileSync(file, "not a pem");
52
+ expect(() => readPrivateKeyPem(file)).toThrow(/不是合法 PEM 私钥文件/);
53
+ });
54
+
55
+ it("buffer 形态 pem 亦可(调用方直传 readFileSync 产物的场景)", () => {
56
+ const kp = generateKeyPair();
57
+ const file = path.join(dir, "k.pem");
58
+ writeFileSync(file, Buffer.from(kp.privateKeyPem));
59
+ expect(readPrivateKeyPem(file)).toBe(kp.privateKeyPem);
60
+ });
61
+ });