@oj-module/cli 0.2.2 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/devkit/README.md +14 -3
- package/devkit/SKILL.md +16 -4
- package/devkit/framework-dev.md +8 -6
- package/devkit/manual.md +243 -23
- package/package.json +2 -2
- package/shell-dist/assets/runtime.js +143 -8
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +3 -3
- package/src/args.ts +67 -0
- package/src/build.ts +11 -3
- package/src/bundle.ts +197 -0
- package/src/contract/__tests__/handler-scan.test.ts +82 -0
- package/src/contract/check.ts +2 -1
- package/src/contract/handler-scan.ts +32 -0
- package/src/deterministic-tar.ts +120 -0
- package/src/dev.ts +4 -2
- package/src/enable.ts +28 -0
- package/src/index.ts +63 -2
- package/src/info.ts +3 -2
- package/src/init.ts +189 -14
- package/src/install.ts +268 -0
- package/src/keygen.ts +88 -0
- package/src/layout.ts +31 -0
- package/src/lifecycle/__tests__/authoring.test.ts +109 -0
- package/src/lifecycle/__tests__/bundle.test.ts +150 -0
- package/src/lifecycle/__tests__/deterministic-tar.test.ts +145 -0
- package/src/lifecycle/__tests__/fixtures/README.md +59 -0
- package/src/lifecycle/__tests__/fixtures/fixture-invalid/EXPECTED-VIOLATIONS.md +27 -0
- package/src/lifecycle/__tests__/fixtures/fixture-invalid/api/src/Demo/manifest.yaml +6 -0
- package/src/lifecycle/__tests__/fixtures/fixture-invalid/manifest.yaml +40 -0
- package/src/lifecycle/__tests__/fixtures/fixture-invalid/web/src/Demo/entry.ts +14 -0
- package/src/lifecycle/__tests__/fixtures/fixture-invalid/web/src/Demo/locales/en-US.json +12 -0
- package/src/lifecycle/__tests__/fixtures/fixture-invalid/web/src/Demo/locales/zh-CN.json +12 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/manifest.yaml +9 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/migrations/0001__init.sql +6 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/ping/api.ts +9 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/schema.yaml +9 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/manifest.yaml +96 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/entry.ts +23 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/locales/en-US.json +29 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/locales/zh-CN.json +29 -0
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz +0 -0
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.bad.sig +7 -0
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.sig +7 -0
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.unknown.sig +7 -0
- package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.d.mts +21 -0
- package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.mjs +196 -0
- package/src/lifecycle/__tests__/helpers.ts +78 -0
- package/src/lifecycle/__tests__/install.test.ts +514 -0
- package/src/lifecycle/__tests__/keygen.test.ts +61 -0
- package/src/lifecycle/__tests__/manifest-contract.test.ts +228 -0
- package/src/lifecycle/__tests__/publish.test.ts +80 -0
- package/src/lifecycle/__tests__/sig-fixtures.test.ts +100 -0
- package/src/lifecycle/__tests__/sign.test.ts +131 -0
- package/src/lifecycle/__tests__/smoke.test.ts +61 -0
- package/src/lifecycle/__tests__/trust.test.ts +177 -0
- package/src/lifecycle/__tests__/uninstall.test.ts +87 -0
- package/src/lifecycle/keygen.ts +40 -0
- package/src/lifecycle/sign.ts +74 -0
- package/src/manifest-contract.ts +696 -0
- package/src/oj-cert.ts +19 -0
- package/src/oj-cmd.ts +3 -3
- package/src/preview.ts +2 -2
- package/src/publish.ts +150 -0
- package/src/sign.ts +128 -0
- package/src/site.ts +146 -0
- package/src/trust.ts +116 -0
- package/src/uninstall.ts +29 -0
- package/src/usage.ts +35 -1
- package/templates/README.md +1 -1
- package/templates/api/config.yaml +47 -0
- package/templates/api/seed.mysql.sql +1 -1
- package/templates/api/seed.pg.sql +1 -1
- package/templates/api/src/_platform/_lifecycle/atomic.ts +98 -0
- package/templates/api/src/_platform/_lifecycle/history.ts +31 -0
- package/templates/api/src/_platform/_lifecycle/installer.ts +1080 -0
- package/templates/api/src/_platform/_lifecycle/lock.ts +148 -0
- package/templates/api/src/_platform/_lifecycle/semver.ts +61 -0
- package/templates/api/src/_platform/_lifecycle/sha384.ts +187 -0
- package/templates/api/src/_platform/_lifecycle/state.ts +256 -0
- package/templates/api/src/_platform/_lifecycle/steps/deploy-web.ts +182 -0
- package/templates/api/src/_platform/_lifecycle/steps/download.ts +90 -0
- package/templates/api/src/_platform/_lifecycle/steps/extract.ts +239 -0
- package/templates/api/src/_platform/_lifecycle/steps/migrate.ts +363 -0
- package/templates/api/src/_platform/_lifecycle/steps/preflight.ts +590 -0
- package/templates/api/src/_platform/_lifecycle/steps/register.ts +211 -0
- package/templates/api/src/_platform/_lifecycle/steps/reload.ts +81 -0
- package/templates/api/src/_platform/_lifecycle/steps/smoke.ts +216 -0
- package/templates/api/src/_platform/_lifecycle/steps/verify.ts +207 -0
- package/templates/api/src/_platform/_lifecycle/tasks.ts +119 -0
- package/templates/api/src/_platform/_lifecycle/text-edit.ts +110 -0
- package/templates/api/src/_platform/_lifecycle/tgz-guard.ts +41 -0
- package/templates/api/src/_platform/_lifecycle/trust.ts +229 -0
- package/templates/api/src/_platform/_lifecycle/types.ts +94 -0
- package/templates/api/src/_platform/_lifecycle/uninstall.ts +283 -0
- package/templates/api/src/_platform/_lifecycle/verify-checks.ts +228 -0
- package/templates/api/src/_platform/_lifecycle/verify-report.ts +92 -0
- package/templates/api/src/_platform/_shared/service/access.ts +2 -2
- package/templates/api/src/_platform/_shared/service/settings.ts +10 -1
- package/templates/api/src/_platform/_shared/service/solution-perms.ts +14 -7
- package/templates/api/src/_platform/_shared/site-facade.ts +44 -0
- package/templates/api/src/_platform/manifest.yaml +1 -0
- package/templates/api/src/_platform/market/api.ts +883 -0
- package/templates/api/src/_platform/market/bootstrap.ts +39 -0
- package/templates/api/src/_platform/migrations/0010__sys_solution_route.mysql.sql +17 -0
- package/templates/api/src/_platform/migrations/0010__sys_solution_route.pg.sql +16 -0
- package/templates/api/src/_platform/migrations/0010__sys_solution_route.sql +18 -0
- package/templates/api/src/_platform/migrations/0011__sys_setting_group_label.mysql.sql +2 -0
- package/templates/api/src/_platform/migrations/0011__sys_setting_group_label.sql +4 -0
- package/templates/api/src/_platform/migrations/0012__market_history.mysql.sql +13 -0
- package/templates/api/src/_platform/migrations/0012__market_history.pg.sql +12 -0
- package/templates/api/src/_platform/migrations/0012__market_history.sql +14 -0
- package/templates/api/src/_platform/schema.yaml +21 -4
- package/templates/api/src/_platform/seed.sql +2 -2
- package/templates/api/src/_platform/solutions/api.ts +212 -0
- package/templates/api/src/demo/manifest.yaml +5 -0
- package/templates/api/src/demo/migrations/0001__create_todos.sql +9 -0
- package/templates/api/src/demo/schema.yaml +11 -0
- package/templates/api/src/demo/seed.sql +5 -0
- package/templates/api/src/demo/todos/api.ts +59 -0
- package/templates/api/src/home/line/api.ts +57 -0
- package/templates/api/src/home/manifest.yaml +6 -0
- package/templates/api/src/home/migrations/0001__create_home_tables.sql +13 -0
- package/templates/api/src/home/pie/api.ts +40 -0
- package/templates/api/src/home/schema.yaml +14 -0
- package/templates/api/src/home/seed.sql +70 -0
- package/templates/api/src/system/oplog/api.ts +44 -2
- package/templates/api/tests/fixture.ts +16 -0
- package/templates/api/tests/lifecycle/atomic-state.spec.ts +183 -0
- package/templates/api/tests/lifecycle/bootstrap.spec.ts +116 -0
- package/templates/api/tests/lifecycle/deploy-smoke.spec.ts +492 -0
- package/templates/api/tests/lifecycle/installer.spec.ts +1218 -0
- package/templates/api/tests/lifecycle/market-api.spec.ts +971 -0
- package/templates/api/tests/lifecycle/migrate.spec.ts +575 -0
- package/templates/api/tests/lifecycle/preflight.spec.ts +255 -0
- package/templates/api/tests/lifecycle/register.spec.ts +194 -0
- package/templates/api/tests/lifecycle/semver.spec.ts +43 -0
- package/templates/api/tests/lifecycle/sha384.spec.ts +48 -0
- package/templates/api/tests/lifecycle/sig-fixtures.spec.ts +65 -0
- package/templates/api/tests/lifecycle/solutions-api.spec.ts +98 -0
- package/templates/api/tests/lifecycle/steps-fs.spec.ts +623 -0
- package/templates/api/tests/lifecycle/text-edit.spec.ts +177 -0
- package/templates/api/tests/lifecycle/tgz-guard.spec.ts +103 -0
- package/templates/api/tests/lifecycle/trust.spec.ts +310 -0
- package/templates/api/tests/lifecycle/uninstall.spec.ts +386 -0
- package/templates/api/tests/lifecycle/verify-checks.spec.ts +385 -0
- package/templates/api/tests/lifecycle/verify-report.spec.ts +290 -0
- package/templates/api/tests/rbac-settings.spec.ts +8 -0
- package/templates/init.include.yaml +62 -0
- package/templates/openapi.json +527 -0
- package/templates/package.json +52 -0
- package/templates/web/src/_platform/__client/api.schemas.ts +8 -0
- package/templates/web/src/_platform/__client/api.ts +335 -0
- package/templates/web/src/about/entry.ts +42 -0
- package/templates/web/src/about/locales/en-US.json +16 -0
- package/templates/web/src/about/locales/zh-CN.json +16 -0
- package/templates/web/src/about/pages/constants.ts +20 -0
- package/templates/web/src/about/pages/index.tsx +135 -0
- package/templates/web/src/access/entry.ts +74 -0
- package/templates/web/src/access/locales/en-US.json +46 -0
- package/templates/web/src/access/locales/zh-CN.json +46 -0
- package/templates/web/src/access/pages/access-mode/index.tsx +16 -0
- package/templates/web/src/access/pages/admin-visible/index.tsx +16 -0
- package/templates/web/src/access/pages/button-control/index.tsx +181 -0
- package/templates/web/src/access/pages/common-visible/index.tsx +16 -0
- package/templates/web/src/access/pages/page-control/index.tsx +142 -0
- package/templates/web/src/demo/__client/api.schemas.ts +20 -0
- package/templates/web/src/demo/__client/api.ts +89 -0
- package/templates/web/src/exception/entry.ts +80 -0
- package/templates/web/src/exception/locales/en-US.json +13 -0
- package/templates/web/src/exception/locales/zh-CN.json +13 -0
- package/templates/web/src/exception/pages/403/index.tsx +30 -0
- package/templates/web/src/exception/pages/404/index.tsx +30 -0
- package/templates/web/src/exception/pages/500/index.tsx +30 -0
- package/templates/web/src/exception/pages/unknown-component/index.tsx +38 -0
- package/templates/web/src/home/__client/api.schemas.ts +22 -0
- package/templates/web/src/home/__client/api.ts +113 -0
- package/templates/web/src/login/locales/en-US.json +6 -0
- package/templates/web/src/login/locales/zh-CN.json +6 -0
- package/templates/web/src/login/pages/login.tsx +67 -18
- package/templates/web/src/oj-market/__tests__/CardGrid.test.tsx +93 -0
- package/templates/web/src/oj-market/__tests__/HistoryDrawer.test.tsx +28 -0
- package/templates/web/src/oj-market/__tests__/InstallDialog.test.tsx +69 -0
- package/templates/web/src/oj-market/__tests__/RollbackDialog.test.tsx +43 -0
- package/templates/web/src/oj-market/__tests__/UninstallConfirm.test.tsx +30 -0
- package/templates/web/src/oj-market/__tests__/marketClient.fake.ts +70 -0
- package/templates/web/src/oj-market/__tests__/poll.test.ts +24 -0
- package/templates/web/src/oj-market/__tests__/render.tsx +14 -0
- package/templates/web/src/oj-market/__tests__/runtime-stub.ts +14 -0
- package/templates/web/src/oj-market/__tests__/semver.test.ts +26 -0
- package/templates/web/src/oj-market/api/MarketClientProvider.tsx +12 -0
- package/templates/web/src/oj-market/api/client.ts +135 -0
- package/templates/web/src/oj-market/api/context.ts +11 -0
- package/templates/web/src/oj-market/api/defaultClient.ts +7 -0
- package/templates/web/src/oj-market/components/CardGrid.tsx +76 -0
- package/templates/web/src/oj-market/components/CardItem.tsx +102 -0
- package/templates/web/src/oj-market/components/DetailDrawer.tsx +53 -0
- package/templates/web/src/oj-market/components/HistoryDrawer.tsx +75 -0
- package/templates/web/src/oj-market/components/InstallDialog.tsx +112 -0
- package/templates/web/src/oj-market/components/RollbackDialog.tsx +79 -0
- package/templates/web/src/oj-market/components/UninstallConfirm.tsx +74 -0
- package/templates/web/src/oj-market/entry.ts +27 -0
- package/templates/web/src/oj-market/hooks/useMarket.ts +91 -0
- package/templates/web/src/oj-market/locales/en-US.json +100 -0
- package/templates/web/src/oj-market/locales/zh-CN.json +100 -0
- package/templates/web/src/oj-market/pages/MarketApp.tsx +136 -0
- package/templates/web/src/oj-market/types.ts +84 -0
- package/templates/web/src/oj-market/utils/poll.ts +36 -0
- package/templates/web/src/oj-market/utils/semver.ts +14 -0
- package/templates/web/src/outside/entry.ts +80 -0
- package/templates/web/src/outside/locales/en-US.json +10 -0
- package/templates/web/src/outside/locales/zh-CN.json +10 -0
- package/templates/web/src/personal-center/entry.ts +1 -10
- package/templates/web/src/personal-center/locales/en-US.json +1 -5
- package/templates/web/src/personal-center/locales/zh-CN.json +1 -5
- package/templates/web/src/route-nest/entry.ts +73 -0
- package/templates/web/src/route-nest/locales/en-US.json +9 -0
- package/templates/web/src/route-nest/locales/zh-CN.json +9 -0
- package/templates/web/src/route-nest/pages/menu1/menu1-1/index.tsx +5 -0
- package/templates/web/src/route-nest/pages/menu1/menu1-2/index.tsx +5 -0
- package/templates/web/src/route-nest/pages/menu2/index.tsx +5 -0
- package/templates/web/src/system/__client/api.schemas.ts +12 -0
- package/templates/web/src/system/__client/api.ts +25 -1
- package/templates/web/src/system/pages/oplog/index.tsx +1 -1
- package/templates/web/src/system/pages/roles/index.tsx +1 -0
- package/templates/web/src/system/pages/settings/index.tsx +1 -1
- package/templates/web/src/system/pages/users/index.tsx +1 -1
- package/templates/web.config.ts +32 -40
- package/vendor/host-versions.json +3 -3
- package/templates/pnpm-workspace.yaml +0 -14
- 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
|
+
});
|