@oj-module/cli 0.2.66 → 0.2.67
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/bin/.oj-version +1 -0
- package/bin/oj +4 -0
- package/devkit/README.md +15 -7
- package/devkit/SKILL.md +30 -1
- package/devkit/framework-dev.md +10 -4
- package/devkit/manual.md +38 -12
- package/devkit/publish-walkthrough.md +110 -37
- package/devkit/wecom-internal-app.md +7 -2
- package/devkit/wecom-multi-corp-links.md +163 -0
- package/package.json +2 -2
- package/shell-dist/assets/{index.i0kqbxp050.js → index.6alqyx6ae0.js} +21 -1
- package/shell-dist/assets/{runtime.1xfsxcajj0.js → runtime.2dgstpjsu0.js} +3449 -3449
- package/shell-dist/index.html +4 -4
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/publish-keygen.test.ts +157 -0
- package/src/__tests__/templates-migration-schema-parity.test.ts +540 -0
- package/src/__tests__/templates-schema-columns.test.ts +15 -2
- package/src/__tests__/templates-tenant-scope.test.ts +6 -2
- package/src/bundle.ts +8 -3
- package/src/cli.ts +17 -5
- package/src/dev.ts +15 -10
- package/src/install.ts +11 -4
- package/src/keygen.ts +54 -0
- package/src/lifecycle/__tests__/bundle.test.ts +21 -3
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/manifest.yaml +1 -1
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/migrations/0001__init.sql +2 -2
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/schema.yaml +5 -2
- package/src/lifecycle/__tests__/fixtures/fixture-valid/manifest.yaml +1 -1
- package/src/lifecycle/__tests__/manifest-contract.test.ts +75 -3
- package/src/lifecycle/__tests__/smoke.test.ts +2 -2
- package/src/manifest-contract.ts +91 -8
- package/src/market/bootstrap.ts +26 -2
- package/src/market/command.ts +1 -0
- package/src/market/publish.ts +5 -1
- package/src/market/walkthrough.ts +24 -8
- package/src/site.ts +2 -0
- package/src/usage.ts +8 -4
- package/src/vendor.ts +5 -1
- package/templates/config.yaml +4 -2
- package/templates/init.include.yaml +8 -3
- package/templates/openapi.json +460 -1
- package/templates/src/app/api/_platform/_lifecycle/compensate.ts +136 -0
- package/templates/src/app/api/_platform/_lifecycle/installer.ts +71 -22
- package/templates/src/app/api/_platform/_lifecycle/lock.ts +23 -0
- package/templates/src/app/api/_platform/_lifecycle/steps/deploy-web.ts +28 -0
- package/templates/src/app/api/_platform/_lifecycle/steps/preflight.ts +46 -0
- package/templates/src/app/api/_platform/_lifecycle/steps/reload.ts +100 -36
- package/templates/src/app/api/_platform/_lifecycle/steps/smoke.ts +7 -15
- package/templates/src/app/api/_platform/_lifecycle/text-edit.ts +71 -19
- package/templates/src/app/api/_platform/_lifecycle/types.ts +26 -2
- package/templates/src/app/api/_platform/_lifecycle/uninstall.ts +93 -14
- package/templates/src/app/api/_platform/_shared/service/domain/settings.ts +8 -4
- package/templates/src/app/api/_platform/manifest.yaml +1 -0
- package/templates/src/app/api/_platform/market/api.ts +235 -10
- package/templates/src/app/api/_platform/migrations/0001__baseline.mysql.sql +178 -0
- package/templates/src/app/api/_platform/migrations/0001__baseline.pg.sql +173 -0
- package/templates/src/app/api/_platform/migrations/0001__baseline.sql +188 -0
- package/templates/src/app/api/_platform/migrations/0002__market_release.mysql.sql +17 -0
- package/templates/src/app/api/_platform/migrations/0002__market_release.pg.sql +16 -0
- package/templates/src/app/api/_platform/migrations/0002__market_release.sql +33 -0
- package/templates/src/app/api/_platform/schema.yaml +18 -0
- package/templates/src/app/api/_platform/seed.mysql.sql +10 -0
- package/templates/src/app/api/_platform/seed.pg.sql +10 -0
- package/templates/src/app/api/auth/_shared/wecom/client.ts +24 -4
- package/templates/src/app/api/home/migrations/0001__create_home_tables.mysql.sql +14 -0
- package/templates/src/app/api/home/migrations/0001__create_home_tables.pg.sql +14 -0
- package/templates/src/app/api/home/migrations/0002__tenant.pg.sql +4 -0
- package/templates/src/app/api/home/seed.mysql.sql +71 -0
- package/templates/src/app/api/home/seed.pg.sql +71 -0
- package/templates/src/app/api/notification/_shared/notify.ts +6 -6
- package/templates/src/app/api/notification/migrations/0001__create_notifications.mysql.sql +11 -0
- package/templates/src/app/api/notification/migrations/0001__create_notifications.pg.sql +11 -0
- package/templates/src/app/api/notification/migrations/0002__notifications_user.mysql.sql +8 -0
- package/templates/src/app/api/notification/migrations/0003__notifications_broadcast.mysql.sql +21 -0
- package/templates/src/app/api/notification/migrations/0003__notifications_broadcast.pg.sql +22 -0
- package/templates/src/app/api/notification/migrations/0004__notifications_broadcast_hidden.mysql.sql +10 -0
- package/templates/src/app/api/notification/migrations/0004__notifications_broadcast_hidden.pg.sql +11 -0
- package/templates/src/app/api/notification/migrations/0005__tenant.pg.sql +6 -0
- package/templates/src/app/api/notification/seed.mysql.sql +8 -0
- package/templates/src/app/api/notification/seed.pg.sql +11 -0
- package/templates/src/app/api/publish/_shared/identity.ts +69 -0
- package/templates/src/app/api/publish/api.ts +349 -0
- package/templates/src/app/api/publish/manifest.yaml +8 -0
- package/templates/src/app/api/publish/migrations/0001__publish_credential.mysql.sql +23 -0
- package/templates/src/app/api/publish/migrations/0001__publish_credential.pg.sql +23 -0
- package/templates/src/app/api/publish/migrations/0001__publish_credential.sql +39 -0
- package/templates/src/app/api/publish/schema.yaml +33 -0
- package/templates/src/app/api/system/login-methods/api.ts +2 -1
- package/templates/src/app/web/_platform/__client/api.schemas.ts +15 -0
- package/templates/src/app/web/_platform/__client/index.ts +25 -1
- package/templates/src/app/web/entry.ts +9 -5
- package/templates/src/app/web/home/routes.ts +5 -0
- package/templates/src/app/web/login/locales/en-US.json +7 -1
- package/templates/src/app/web/login/locales/zh-CN.json +7 -1
- package/templates/src/app/web/login/pages/login.tsx +79 -86
- package/templates/src/app/web/login/routes.ts +54 -1
- package/templates/src/app/web/personal-center/locales/en-US.json +1 -0
- package/templates/src/app/web/personal-center/locales/zh-CN.json +1 -0
- package/templates/src/app/web/personal-center/pages/my-profile/index.tsx +5 -0
- package/templates/src/app/web/system/__client/api.schemas.ts +2 -1
- package/templates/src/app/web/system/locales/en-US.json +1 -8
- package/templates/src/app/web/system/locales/zh-CN.json +1 -8
- package/templates/src/app/web/system/pages/settings/index.tsx +17 -4
- package/templates/src/app/web/system/routes.ts +0 -6
- package/templates/src/oj-market/web/__tests__/CardGrid.test.tsx +33 -0
- package/templates/src/oj-market/web/__tests__/DetailDrawer.test.tsx +69 -0
- package/templates/src/oj-market/web/__tests__/marketClient.fake.ts +7 -1
- package/templates/src/oj-market/web/api/client.ts +3 -0
- package/templates/src/oj-market/web/api/defaultClient.ts +5 -0
- package/templates/src/oj-market/web/components/CardItem.tsx +44 -1
- package/templates/src/oj-market/web/components/DetailDrawer.tsx +17 -1
- package/templates/src/oj-market/web/entry.ts +31 -2
- package/templates/src/oj-market/web/locales/en-US.json +5 -1
- package/templates/src/oj-market/web/locales/zh-CN.json +5 -1
- package/templates/src/oj-market/web/publish/__client/api.schemas.ts +61 -0
- package/templates/src/oj-market/web/publish/__client/index.ts +164 -0
- package/templates/src/oj-market/web/publish/locales/en-US.json +80 -0
- package/templates/src/oj-market/web/publish/locales/zh-CN.json +80 -0
- package/templates/src/oj-market/web/publish/pages/history.tsx +112 -0
- package/templates/src/oj-market/web/publish/pages/index.tsx +196 -0
- package/templates/src/oj-market/web/publish/pages/model.ts +52 -0
- package/templates/src/oj-market/web/publish/pages/token-modal.tsx +82 -0
- package/templates/src/oj-market/web/publish/routes.ts +41 -0
- package/templates/src/oj-market/web/types.ts +30 -0
- package/templates/tests/lifecycle/compensate.spec.ts +207 -0
- package/templates/tests/lifecycle/deploy-smoke.spec.ts +38 -14
- package/templates/tests/lifecycle/installer.spec.ts +28 -9
- package/templates/tests/lifecycle/market-api.spec.ts +164 -12
- package/templates/tests/lifecycle/preflight.spec.ts +68 -0
- package/templates/tests/lifecycle/role-title-translate.spec.ts +2 -32
- package/templates/tests/lifecycle/text-edit.spec.ts +45 -27
- package/templates/tests/lifecycle/uninstall.spec.ts +36 -0
- package/templates/tests/publish-credential.spec.ts +354 -0
- package/templates/tests/publish-identity.spec.ts +44 -0
- package/templates/tests/wecom-client-timeout.spec.ts +33 -3
- package/templates/tests/wecom-landing.spec.ts +63 -1
- package/templates/tsconfig.paths.json +1 -0
- package/templates/web.config.ts +6 -4
- package/vendor/host-versions.json +3 -3
- package/templates/seed.mysql.sql +0 -6
- package/templates/seed.pg.sql +0 -6
- package/templates/src/app/api/_platform/migrations/0001__create_users.mysql.sql +0 -9
- package/templates/src/app/api/_platform/migrations/0001__create_users.pg.sql +0 -9
- package/templates/src/app/api/_platform/migrations/0001__create_users.sql +0 -7
- package/templates/src/app/api/_platform/migrations/0002__add_users_avatar_base64.mysql.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0002__add_users_avatar_base64.sql +0 -2
- package/templates/src/app/api/_platform/migrations/0003__users_source_display_name.mysql.sql +0 -5
- package/templates/src/app/api/_platform/migrations/0003__users_source_display_name.sql +0 -5
- package/templates/src/app/api/_platform/migrations/0004__sys_rbac.mysql.sql +0 -70
- package/templates/src/app/api/_platform/migrations/0004__sys_rbac.pg.sql +0 -67
- package/templates/src/app/api/_platform/migrations/0004__sys_rbac.sql +0 -67
- package/templates/src/app/api/_platform/migrations/0005__users_must_change.mysql.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0005__users_must_change.sql +0 -2
- package/templates/src/app/api/_platform/migrations/0006__users_is_forbidden.mysql.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0006__users_is_forbidden.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0007__users_email.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.mysql.sql +0 -10
- package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.pg.sql +0 -10
- package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.sql +0 -11
- package/templates/src/app/api/_platform/migrations/0009__sys_throttle.mysql.sql +0 -12
- package/templates/src/app/api/_platform/migrations/0009__sys_throttle.pg.sql +0 -12
- package/templates/src/app/api/_platform/migrations/0009__sys_throttle.sql +0 -13
- package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.mysql.sql +0 -17
- package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.pg.sql +0 -16
- package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.sql +0 -18
- package/templates/src/app/api/_platform/migrations/0011__sys_setting_group_label.mysql.sql +0 -2
- package/templates/src/app/api/_platform/migrations/0011__sys_setting_group_label.sql +0 -4
- package/templates/src/app/api/_platform/migrations/0012__market_history.mysql.sql +0 -13
- package/templates/src/app/api/_platform/migrations/0012__market_history.pg.sql +0 -12
- package/templates/src/app/api/_platform/migrations/0012__market_history.sql +0 -14
- package/templates/src/app/api/_platform/migrations/0013__users_profile.sql +0 -4
- package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.mysql.sql +0 -9
- package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.pg.sql +0 -7
- package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.sql +0 -8
- package/templates/src/app/api/_platform/migrations/0015__tenant.mysql.sql +0 -12
- package/templates/src/app/api/_platform/migrations/0015__tenant.pg.sql +0 -11
- package/templates/src/app/api/_platform/migrations/0015__tenant.sql +0 -44
- package/templates/src/app/api/_platform/migrations/0016__org.mysql.sql +0 -41
- package/templates/src/app/api/_platform/migrations/0016__org.pg.sql +0 -40
- package/templates/src/app/api/_platform/migrations/0016__org.sql +0 -62
- package/templates/src/app/api/_platform/migrations/0017__users_employee_no.mysql.sql +0 -7
- package/templates/src/app/api/_platform/migrations/0017__users_employee_no.pg.sql +0 -4
- package/templates/src/app/api/_platform/migrations/0017__users_employee_no.sql +0 -8
- package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.mysql.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.pg.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.sql +0 -8
- package/templates/src/app/web/system/pages/login-methods/index.tsx +0 -30
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// BDD:安装补偿清单(P0 §3.5)。锁后失败把站点恢复到「装前代」——旧代继续服务、任务 partial+可重试。
|
|
2
|
+
// 直接驱动 compensate()(不重跑整个状态机),配真实内存库夹具(installDb)+ 真实临时文件系统 +
|
|
3
|
+
// stub reload 面,覆盖:完整回滚、幂等、部分失败仍尽力回滚、匿名面不随失败放大(验收 4)。
|
|
4
|
+
//
|
|
5
|
+
// 资产回滚原语:removeAnonymousPaths(text-edit)/ revertLock(lock)/ removeModulesJsonEntry
|
|
6
|
+
// (deploy-web)/ revertRegistrations(uninstall,复用卸载事务)。各原语幂等(缺则 no-op),
|
|
7
|
+
// 故补偿可安全被重试端点反复调用。
|
|
8
|
+
|
|
9
|
+
import * as fsp from "node:fs/promises";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
13
|
+
import { atomicWriteText } from "../../src/app/api/_platform/_lifecycle/atomic";
|
|
14
|
+
import { compensate } from "../../src/app/api/_platform/_lifecycle/compensate";
|
|
15
|
+
import { parseLock, writeLock } from "../../src/app/api/_platform/_lifecycle/lock";
|
|
16
|
+
import { appendAnonymousPaths, removeAnonymousPaths } from "../../src/app/api/_platform/_lifecycle/text-edit";
|
|
17
|
+
import { installDb } from "../fixture";
|
|
18
|
+
|
|
19
|
+
const g = globalThis as any;
|
|
20
|
+
|
|
21
|
+
const BASE_CONFIG = "mounts: []\nserver:\n reload_api: true\nvars:\n other: 1\nauth:\n anonymous_paths:\n - /auth/login\n - /system/login-methods\n";
|
|
22
|
+
|
|
23
|
+
function nodeFsFace() {
|
|
24
|
+
return {
|
|
25
|
+
writeFile: (p: string, d: any) => fsp.writeFile(p, d),
|
|
26
|
+
readFile: (p: string) => fsp.readFile(p),
|
|
27
|
+
mkdir: (p: string, o: any) => fsp.mkdir(p, o),
|
|
28
|
+
remove: (p: string, o: any) => fsp.rm(p, o),
|
|
29
|
+
rename: (o: string, n: string) => fsp.rename(o, n),
|
|
30
|
+
copyFile: (s: string, d: string) => fsp.copyFile(s, d),
|
|
31
|
+
readdir: (p: string) => fsp.readdir(p),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let root: string;
|
|
36
|
+
let configPath: string;
|
|
37
|
+
let previewPath: string;
|
|
38
|
+
let sharedRoot: string;
|
|
39
|
+
let lockPath: string;
|
|
40
|
+
let modulesJsonPath: string;
|
|
41
|
+
let dbo: any;
|
|
42
|
+
let reloadCalls: number;
|
|
43
|
+
let reloadThrow: boolean;
|
|
44
|
+
|
|
45
|
+
const LOCK_BEFORE: Record<string, string> = { existing: "1.0.0" };
|
|
46
|
+
|
|
47
|
+
/** 把站点摆成「demo-solution 已锁后落位」状态,返回本次新增的匿名路径(= 补偿的输入 deferredAnonymous) */
|
|
48
|
+
async function seedInstalled(anonPaths: string[]): Promise<string[]> {
|
|
49
|
+
const added = appendAnonymousPaths(BASE_CONFIG, anonPaths);
|
|
50
|
+
await atomicWriteText(configPath, added.content);
|
|
51
|
+
await atomicWriteText(previewPath, added.content);
|
|
52
|
+
// 锁:装前快照 + 本次模块条目
|
|
53
|
+
await writeLock(lockPath, { ...LOCK_BEFORE, "demo-solution": "0.1.0" });
|
|
54
|
+
// modules.json:含本次模块条目
|
|
55
|
+
await atomicWriteText(modulesJsonPath, `${JSON.stringify([{ name: "demo-solution", entry: "modules/demo-solution/0.1.0/entry.js", enabled: true }], null, 2)}\n`);
|
|
56
|
+
// DB:路由/角色/权限各一行为 demo-solution(registering 段已落位)
|
|
57
|
+
await g.db.table("sys_solution_route").insert({ path: "/demo-solution", module: "demo-solution", category: "main" }).run();
|
|
58
|
+
await g.db.table("sys_role").insert({ name: "demo-solution.admin", module: "demo-solution" }).run();
|
|
59
|
+
await g.db.table("sys_permission").insert({ code: "demo-solution.view", module: "demo-solution" }).run();
|
|
60
|
+
return added.added;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function countAnon(cfg: string): number {
|
|
64
|
+
const lines = cfg.split("\n");
|
|
65
|
+
const idx = lines.findIndex(l => /^\s*anonymous_paths\s*:/.test(l));
|
|
66
|
+
if (idx === -1)
|
|
67
|
+
return 0;
|
|
68
|
+
let n = 0;
|
|
69
|
+
for (let i = idx + 1; i < lines.length; i++) {
|
|
70
|
+
const line = lines[i]!;
|
|
71
|
+
if (line.trim() === "" || /^\s*#/.test(line))
|
|
72
|
+
continue;
|
|
73
|
+
if (!line.replace(/#.*$/, "").trim().startsWith("- "))
|
|
74
|
+
break;
|
|
75
|
+
n++;
|
|
76
|
+
}
|
|
77
|
+
return n;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
beforeEach(async () => {
|
|
81
|
+
root = await fsp.mkdtemp(join(tmpdir(), "ojm-compensate-"));
|
|
82
|
+
configPath = join(root, "config.yaml");
|
|
83
|
+
previewPath = join(root, ".ojm-preview.yaml");
|
|
84
|
+
sharedRoot = join(root, "modules");
|
|
85
|
+
lockPath = join(sharedRoot, "manifests.yaml");
|
|
86
|
+
modulesJsonPath = join(root, "dist", "modules.json");
|
|
87
|
+
await fsp.mkdir(sharedRoot, { recursive: true });
|
|
88
|
+
await fsp.mkdir(join(root, "dist"), { recursive: true });
|
|
89
|
+
g.fs = nodeFsFace();
|
|
90
|
+
g.yaml = { parse: async (t: string) => JSON.parse(t) };
|
|
91
|
+
g.http = {};
|
|
92
|
+
dbo = installDb();
|
|
93
|
+
reloadCalls = 0;
|
|
94
|
+
reloadThrow = false;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
afterEach(async () => {
|
|
98
|
+
g.fs = undefined;
|
|
99
|
+
g.http = undefined;
|
|
100
|
+
g.db = undefined;
|
|
101
|
+
await fsp.rm(root, { recursive: true, force: true });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
function makeInput(deferredAnonymous: string[]): any {
|
|
105
|
+
return {
|
|
106
|
+
name: "demo-solution",
|
|
107
|
+
version: "0.1.0",
|
|
108
|
+
configPath,
|
|
109
|
+
deferredAnonymous,
|
|
110
|
+
sharedRoot,
|
|
111
|
+
modulesJsonPath,
|
|
112
|
+
lockBefore: LOCK_BEFORE,
|
|
113
|
+
lockExisted: true,
|
|
114
|
+
reload: {
|
|
115
|
+
reloadConfig: async () => {
|
|
116
|
+
reloadCalls++;
|
|
117
|
+
if (reloadThrow)
|
|
118
|
+
throw new Error("reload 模拟失败");
|
|
119
|
+
return { generation: 2, unchanged: false, applied: ["vars"], restart_pending: [], kept_listener: [] };
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
describe("compensate 完整回滚到装前代", () => {
|
|
126
|
+
it("锁/config匿名/modules.json/DB 全部回退,旧代仍服务(reload 已调用)", async () => {
|
|
127
|
+
const added = await seedInstalled(["demo-solution/ping", "demo-solution/health"]);
|
|
128
|
+
const r = await compensate(makeInput(added));
|
|
129
|
+
|
|
130
|
+
expect(r.allOk).toBe(true);
|
|
131
|
+
expect(r.steps.map(s => s.step)).toEqual(["config.anonymous", "lock", "modules.json", "db.registrations", "reload"]);
|
|
132
|
+
|
|
133
|
+
// 锁回到装前快照(无 demo-solution)
|
|
134
|
+
expect(parseLock(await fsp.readFile(lockPath, "utf8"))).toEqual(LOCK_BEFORE);
|
|
135
|
+
// config:新增匿名条目被精确移除,基线两条保留;seq 不被回滚(单调无害)
|
|
136
|
+
const cfg = await fsp.readFile(configPath, "utf8");
|
|
137
|
+
expect(cfg).toContain("- /auth/login");
|
|
138
|
+
expect(cfg).toContain("- /system/login-methods");
|
|
139
|
+
expect(cfg).not.toContain("demo-solution/ping");
|
|
140
|
+
expect(cfg).not.toContain("demo-solution/health");
|
|
141
|
+
expect(countAnon(cfg)).toBe(2);
|
|
142
|
+
// preview 派生同样被清理
|
|
143
|
+
const prev = await fsp.readFile(previewPath, "utf8");
|
|
144
|
+
expect(prev).not.toContain("demo-solution/ping");
|
|
145
|
+
// modules.json 无 demo-solution 条目
|
|
146
|
+
const list = JSON.parse(await fsp.readFile(modulesJsonPath, "utf8")) as unknown[];
|
|
147
|
+
expect(list.some((e: any) => e.name === "demo-solution")).toBe(false);
|
|
148
|
+
// DB 注册被级联清掉
|
|
149
|
+
expect(dbo.prepare("SELECT * FROM sys_solution_route WHERE module='demo-solution'").all()).toHaveLength(0);
|
|
150
|
+
expect(dbo.prepare("SELECT * FROM sys_role WHERE module='demo-solution'").all()).toHaveLength(0);
|
|
151
|
+
expect(dbo.prepare("SELECT * FROM sys_permission WHERE module='demo-solution'").all()).toHaveLength(0);
|
|
152
|
+
// reload 被调用一次(活动代回到装前态)
|
|
153
|
+
expect(reloadCalls).toBe(1);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("compensate 幂等(可重试)", () => {
|
|
158
|
+
it("连续两次补偿:第二次全 no-op,终态不变,reload 仍各调用一次", async () => {
|
|
159
|
+
const added = await seedInstalled(["demo-solution/ping"]);
|
|
160
|
+
const first = await compensate(makeInput(added));
|
|
161
|
+
const second = await compensate(makeInput(added));
|
|
162
|
+
expect(first.allOk).toBe(true);
|
|
163
|
+
expect(second.allOk).toBe(true);
|
|
164
|
+
expect(parseLock(await fsp.readFile(lockPath, "utf8"))).toEqual(LOCK_BEFORE);
|
|
165
|
+
expect(countAnon(await fsp.readFile(configPath, "utf8"))).toBe(2);
|
|
166
|
+
const list = JSON.parse(await fsp.readFile(modulesJsonPath, "utf8")) as unknown[];
|
|
167
|
+
expect(list.some((e: any) => e.name === "demo-solution")).toBe(false);
|
|
168
|
+
expect(dbo.prepare("SELECT * FROM sys_solution_route WHERE module='demo-solution'").all()).toHaveLength(0);
|
|
169
|
+
expect(reloadCalls).toBe(2); // 两次各触发一次 reload(幂等但不跳过 reload)
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("compensate 部分失败仍尽力回滚", () => {
|
|
174
|
+
it("reload 步抛错 → 该步标记失败、其余四步仍生效、allOk=false", async () => {
|
|
175
|
+
const added = await seedInstalled(["demo-solution/ping"]);
|
|
176
|
+
reloadThrow = true;
|
|
177
|
+
const r = await compensate(makeInput(added));
|
|
178
|
+
|
|
179
|
+
expect(r.allOk).toBe(false);
|
|
180
|
+
const reloadStep = r.steps.find(s => s.step === "reload")!;
|
|
181
|
+
expect(reloadStep.ok).toBe(false);
|
|
182
|
+
// 其余步成功
|
|
183
|
+
for (const s of r.steps.filter(s => s.step !== "reload")) {
|
|
184
|
+
expect(s.ok).toBe(true);
|
|
185
|
+
}
|
|
186
|
+
// 即便 reload 失败,已落位资产仍被回退
|
|
187
|
+
expect(parseLock(await fsp.readFile(lockPath, "utf8"))).toEqual(LOCK_BEFORE);
|
|
188
|
+
expect(countAnon(await fsp.readFile(configPath, "utf8"))).toBe(2);
|
|
189
|
+
expect(dbo.prepare("SELECT * FROM sys_solution_route WHERE module='demo-solution'").all()).toHaveLength(0);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("compensate 匿名面不随失败放大(验收 4)", () => {
|
|
194
|
+
it("同一方案连续两次失败安装:config 匿名条目数不增长", async () => {
|
|
195
|
+
// 第一次失败:装前基线 2 条,新增 1 条后补偿
|
|
196
|
+
const added = await seedInstalled(["demo-solution/ping"]);
|
|
197
|
+
await compensate(makeInput(added));
|
|
198
|
+
const afterFirst = await fsp.readFile(configPath, "utf8");
|
|
199
|
+
expect(countAnon(afterFirst)).toBe(2); // 回到基线
|
|
200
|
+
|
|
201
|
+
// 第二次失败:再次以同样 deferredAnonymous 补偿(模拟又失败一次)
|
|
202
|
+
await compensate(makeInput(added));
|
|
203
|
+
const afterSecond = await fsp.readFile(configPath, "utf8");
|
|
204
|
+
expect(countAnon(afterSecond)).toBe(2); // 不增长
|
|
205
|
+
expect(removeAnonymousPaths(afterSecond, added).removed).toEqual([]); // 已无残留可删
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// BDD:部署/热更/冒烟三步(P1-9,十阶段 stage 8~10)。
|
|
2
2
|
// deploy-web = staging/web 产物树真复制到站点 modules 目录 + modules.json 读-改-原子写 upsert
|
|
3
3
|
// (D13:读者永不见半截 JSON;R30:entry/css 路径 POSIX 分隔符序列化;同名整体替换、他条目原样保留);
|
|
4
|
-
// reload = 注入 reloadConfig 面(platform 特权 API),unchanged =
|
|
4
|
+
// reload = 注入 reloadConfig 面(platform 特权 API),unchanged = 安装幂等成功 / 卸载恒严格(R18 修订——加载靠锁 diff 触发,不再依赖 vars 哨兵),
|
|
5
5
|
// expected 段键(缺省 ["vars"],B-11 reload 触发面)须逐一 ∈ applied;
|
|
6
6
|
// smoke = 探活(health 2xx / 缺省首个非匿名路由连通即可)+ integrity(部署落位后重算 sha384 vs
|
|
7
7
|
// modules.json 条目,R10 包内容口径;缺失 fail-closed)+ 注册一致性(升级态「包含非相等」:
|
|
@@ -19,7 +19,7 @@ import { tmpdir } from "node:os";
|
|
|
19
19
|
import { join } from "node:path";
|
|
20
20
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
21
21
|
import { deployWeb, posixPath, upsertModulesJson } from "../../src/app/api/_platform/_lifecycle/steps/deploy-web";
|
|
22
|
-
import { reloadAndAssert } from "../../src/app/api/_platform/_lifecycle/steps/reload";
|
|
22
|
+
import { assertReloadApplied, reloadAndAssert } from "../../src/app/api/_platform/_lifecycle/steps/reload";
|
|
23
23
|
import { probeUrlOf, smoke } from "../../src/app/api/_platform/_lifecycle/steps/smoke";
|
|
24
24
|
|
|
25
25
|
const g = globalThis as any;
|
|
@@ -260,31 +260,42 @@ describe("deployWeb(stage=deploying:staging/web 树 → 站点 modules 目
|
|
|
260
260
|
});
|
|
261
261
|
});
|
|
262
262
|
|
|
263
|
-
describe("reloadAndAssert(stage=reloading:R18 unchanged=失败)", () => {
|
|
263
|
+
describe("reloadAndAssert(stage=reloading:R18 修订——安装 unchanged=幂等成功且默认不强制模块符号;卸载 unchanged/缺 -name@ver=严格失败;force+unchanged=失败)", () => {
|
|
264
264
|
function rlCtx(result?: ReloadResult, throws = false): ReloadContext {
|
|
265
265
|
const face: ReloadFace = {
|
|
266
266
|
reloadConfig: throws
|
|
267
267
|
? async () => {
|
|
268
268
|
throw new Error("server.reloadConfig: reload in progress (busy)");
|
|
269
269
|
}
|
|
270
|
-
: async () => result ?? { generation: 8, unchanged: false, applied: ["
|
|
270
|
+
: async () => result ?? { generation: 8, unchanged: false, applied: ["+demo-solution@0.1.0"], restart_pending: [], kept_listener: [] },
|
|
271
271
|
};
|
|
272
272
|
return { manifest: solManifest(), name: "demo-solution", version: "0.1.0", state: stateStore(), reload: face };
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
-
it("成功:applied 含
|
|
275
|
+
it("成功:applied 含 +name@ver(api 模块装配)→ ok=true,message 含 generation", async () => {
|
|
276
276
|
const r = await reloadAndAssert(rlCtx());
|
|
277
277
|
expect(r.stage).toBe("reloading");
|
|
278
278
|
expect(r.ok).toBe(true);
|
|
279
279
|
expect(r.message).toContain("8");
|
|
280
280
|
});
|
|
281
281
|
|
|
282
|
-
it("unchanged → ok=
|
|
282
|
+
it("unchanged → ok=true(R18 修订:安装幂等成功 = 已是最新装配,不再视为失败)", async () => {
|
|
283
283
|
const r = await reloadAndAssert(rlCtx({ generation: 7, unchanged: true, applied: [] }));
|
|
284
|
+
expect(r.ok).toBe(true);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("force + unchanged → ok=false(--force 要求强制重装配却无变更可重做)", async () => {
|
|
288
|
+
const r = await reloadAndAssert({ ...rlCtx({ generation: 7, unchanged: true, applied: [] }), force: true });
|
|
284
289
|
expect(r.ok).toBe(false);
|
|
285
290
|
expect(r.message).toContain("unchanged");
|
|
286
291
|
});
|
|
287
292
|
|
|
293
|
+
it("卸载 unchanged → ok=false(锁未摘除 = 卸载空转,恒严格)", async () => {
|
|
294
|
+
const r = assertReloadApplied({ generation: 7, unchanged: true, applied: [] }, { hasApi: true, kind: "uninstall", name: "demo-solution", version: "0.1.0" });
|
|
295
|
+
expect(r.ok).toBe(false);
|
|
296
|
+
expect(r.message).toContain("卸载空转");
|
|
297
|
+
});
|
|
298
|
+
|
|
288
299
|
it("face 抛错(并发 busy)→ ok=false retryable=true", async () => {
|
|
289
300
|
const r = await reloadAndAssert(rlCtx(undefined, true));
|
|
290
301
|
expect(r.ok).toBe(false);
|
|
@@ -292,10 +303,15 @@ describe("reloadAndAssert(stage=reloading:R18 unchanged=失败)", () => {
|
|
|
292
303
|
expect(r.message).toContain("busy");
|
|
293
304
|
});
|
|
294
305
|
|
|
295
|
-
it("
|
|
296
|
-
const r = await reloadAndAssert(
|
|
306
|
+
it("安装不再强制 +name@ver(R18 修订:applied 空属正常幂等态,挂载真通交由 smoke 段行为化校验)", async () => {
|
|
307
|
+
const r = await reloadAndAssert(rlCtx({ generation: 8, unchanged: false, applied: ["mounts"], restart_pending: [], kept_listener: [] }));
|
|
308
|
+
expect(r.ok).toBe(true);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("卸载缺 -name@ver → ok=false(卸载须显式拆装,锁未摘除即空转回归拦截点)", async () => {
|
|
312
|
+
const r = assertReloadApplied({ generation: 8, unchanged: false, applied: ["mounts"], restart_pending: [] }, { hasApi: true, kind: "uninstall", name: "demo-solution", version: "0.1.0" });
|
|
297
313
|
expect(r.ok).toBe(false);
|
|
298
|
-
expect(r.message).toContain("
|
|
314
|
+
expect(r.message).toContain("demo-solution@0.1.0");
|
|
299
315
|
});
|
|
300
316
|
|
|
301
317
|
it("返回形状违约(applied 非数组)→ ok=false retryable=false", async () => {
|
|
@@ -337,15 +353,13 @@ describe("smoke 探活(D14/Q-A:health 不强制)", () => {
|
|
|
337
353
|
expect(r.message).toContain("不计入失败");
|
|
338
354
|
});
|
|
339
355
|
|
|
340
|
-
it("health 401
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
// 真失败仍由非 401(如 500,见下条)兜底,不靠 anonymous 白名单。
|
|
356
|
+
it("health 401 与是否声明 anonymous 无关(统一 deferred,真失败靠非 401 兜底)", async () => {
|
|
357
|
+
// 设计明确「health 不强制、无凭证预期 401」,故 health 401 一律 deferred,
|
|
358
|
+
// 真失败仍由非 401(如 500,见下条)兜底。
|
|
344
359
|
await seedDeployed();
|
|
345
360
|
const r = await smoke(smCtx({
|
|
346
361
|
manifest: solManifest({ health: "/demo-solution/ping" }),
|
|
347
362
|
fetch: async () => ({ ok: false, status: 401 }),
|
|
348
|
-
deferredAnonymous: ["/demo-solution/other"],
|
|
349
363
|
}));
|
|
350
364
|
expect(r.ok).toBe(true);
|
|
351
365
|
expect(r.message).toContain("restart_pending");
|
|
@@ -394,6 +408,16 @@ describe("smoke 探活(D14/Q-A:health 不强制)", () => {
|
|
|
394
408
|
}));
|
|
395
409
|
expect(r.ok).toBe(true);
|
|
396
410
|
});
|
|
411
|
+
|
|
412
|
+
it("无 health:首个非匿名路由 404 → 探活失败(P0 §3.5:首个路由 404/连通失败 = 冒烟失败,避免假成功)", async () => {
|
|
413
|
+
await seedDeployed();
|
|
414
|
+
const r = await smoke(smCtx({
|
|
415
|
+
fetch: async () => ({ ok: false, status: 404 }),
|
|
416
|
+
}));
|
|
417
|
+
expect(r.ok).toBe(false);
|
|
418
|
+
expect(r.message).toContain("探活");
|
|
419
|
+
expect(r.message).toContain("404");
|
|
420
|
+
});
|
|
397
421
|
});
|
|
398
422
|
|
|
399
423
|
describe("smoke integrity(R10:部署落位后重算 sha384 vs modules.json)", () => {
|
|
@@ -156,7 +156,7 @@ function makeReq(overrides: Partial<InstallRequest> = {}): InstallRequest {
|
|
|
156
156
|
configPath: join(root, "config.yaml"),
|
|
157
157
|
fetch: async () => ({ ok: true, status: 200, arrayBuffer: async () => ARCHIVE.slice().buffer }),
|
|
158
158
|
exec: bundleExec(),
|
|
159
|
-
reload: { reloadConfig: async () => ({ generation: 1, unchanged: false, applied: [
|
|
159
|
+
reload: { reloadConfig: async () => ({ generation: 1, unchanged: false, applied: [`+demo-solution@${version}`], restart_pending: [], kept_listener: [] }) },
|
|
160
160
|
sha256Hex: d => createHash("sha256").update(d).digest("hex"),
|
|
161
161
|
verifyEd25519: nodeVerifyEd25519,
|
|
162
162
|
trustPath,
|
|
@@ -181,8 +181,8 @@ beforeEach(async () => {
|
|
|
181
181
|
modulesJsonPath = join(root, "dist", "modules.json");
|
|
182
182
|
dbPath = join(root, "db.sqlite");
|
|
183
183
|
await fsp.writeFile(dbPath, new TextEncoder().encode("sqlite-header\n"));
|
|
184
|
-
// 站点 config.yaml 夹具(reloading
|
|
185
|
-
await fsp.writeFile(join(root, "config.yaml"), "mounts: []\nserver:\n reload_api: true\nvars:\n
|
|
184
|
+
// 站点 config.yaml 夹具(reloading 段仅做 anonymous_paths 接线;模板 config.yaml 随发形态)
|
|
185
|
+
await fsp.writeFile(join(root, "config.yaml"), "mounts: []\nserver:\n reload_api: true\nvars:\n other: 1\nauth:\n anonymous_paths:\n - /auth/login\n");
|
|
186
186
|
g.fs = nodeFsFace();
|
|
187
187
|
g.yaml = { parse: async (t: string) => JSON.parse(t) };
|
|
188
188
|
g.http = {};
|
|
@@ -226,9 +226,8 @@ describe("runInstall 全绿路径(步骤 stub,register/smoke/audit 走真实
|
|
|
226
226
|
await fsp.access(join(versionDir, "routes.js"));
|
|
227
227
|
await fsp.access(join(versionDir, "migrations", "0001__init.sql"));
|
|
228
228
|
await expect(fsp.access(join(versionDir, "web"))).rejects.toThrow();
|
|
229
|
-
// reloading
|
|
229
|
+
// reloading 段仅做 anonymous_paths 接线(幂等追加;不再依赖 vars 哨兵触发 reload)
|
|
230
230
|
const cfg = new TextDecoder().decode(await fsp.readFile(join(root, "config.yaml")));
|
|
231
|
-
expect(cfg).toContain("ojm_install_seq: 1");
|
|
232
231
|
// C2-26 anonymous 运行时接线(P2-15③):方案匿名端点补进 auth.anonymous_paths(幂等追加)
|
|
233
232
|
expect(cfg).toContain("- /demo-solution/ping");
|
|
234
233
|
// web 产物落位 + modules.json 条目(R10 integrity 为包内容重算值)
|
|
@@ -260,10 +259,10 @@ describe("runInstall 全绿路径(步骤 stub,register/smoke/audit 走真实
|
|
|
260
259
|
expect(await getTask(taskDir, "nope")).toBeNull();
|
|
261
260
|
});
|
|
262
261
|
|
|
263
|
-
it("preview 形态:派生 .ojm-preview.yaml 存在时两份 config
|
|
262
|
+
it("preview 形态:派生 .ojm-preview.yaml 存在时两份 config 一起接线(否则 reload 恒 unchanged)", async () => {
|
|
264
263
|
// ojm preview 派生出来的那份才是服务端在读的 config(spa 挂载 + platform.root 改写)
|
|
265
264
|
const derived = join(root, ".ojm-preview.yaml");
|
|
266
|
-
await fsp.writeFile(derived, "mounts: []\nserver:\n reload_api: true\nvars:\n
|
|
265
|
+
await fsp.writeFile(derived, "mounts: []\nserver:\n reload_api: true\nvars:\n other: 1\nauth:\n anonymous_paths:\n - /auth/login\n");
|
|
267
266
|
|
|
268
267
|
const t = await runInstall(makeReq());
|
|
269
268
|
expect(t.status).toBe("done");
|
|
@@ -271,10 +270,8 @@ describe("runInstall 全绿路径(步骤 stub,register/smoke/audit 走真实
|
|
|
271
270
|
const source = new TextDecoder().decode(await fsp.readFile(join(root, "config.yaml")));
|
|
272
271
|
const preview = new TextDecoder().decode(await fsp.readFile(derived));
|
|
273
272
|
// 源 config 仍是真相(preview 重启后按它重建派生文件)
|
|
274
|
-
expect(source).toContain("ojm_install_seq: 1");
|
|
275
273
|
expect(source).toContain("- /demo-solution/ping");
|
|
276
274
|
// 派生文件也要变:reloadConfig 按它的文档 sha 判 unchanged
|
|
277
|
-
expect(preview).toContain("ojm_install_seq: 1");
|
|
278
275
|
expect(preview).toContain("- /demo-solution/ping");
|
|
279
276
|
});
|
|
280
277
|
|
|
@@ -1313,3 +1310,25 @@ describe("冒烟探活必须自报租户头(企微代开发多租户 v3 Phase
|
|
|
1313
1310
|
expect(t.stage).toBe("smoke");
|
|
1314
1311
|
});
|
|
1315
1312
|
});
|
|
1313
|
+
|
|
1314
|
+
describe("Phase 3:无守护 partial 判定(P0 §3.6 / 验收 15)", () => {
|
|
1315
|
+
it("热更返回待重启段非空 → 装完标 partial + restartRequired(不判 done,避免假成功)", async () => {
|
|
1316
|
+
// 无守护形态(P4 supervisor 未实施):匿名接线等 restart_pending 段需宿主重启才完全生效,
|
|
1317
|
+
// 不应报 done 误导。reload 返回 restart_pending 非空即标 partial + restartRequired。
|
|
1318
|
+
const t = await runInstall(makeReq({
|
|
1319
|
+
reload: { reloadConfig: async () => ({ generation: 1, unchanged: false, applied: ["+demo-solution@0.1.0"], restart_pending: ["auth"], kept_listener: [] }) },
|
|
1320
|
+
}));
|
|
1321
|
+
expect(t.status).toBe("partial");
|
|
1322
|
+
expect(t.restartRequired).toBe(true);
|
|
1323
|
+
expect(t.message).toContain("待重启段");
|
|
1324
|
+
expect(t.message).toContain("重启");
|
|
1325
|
+
// 安装产物已落位(非失败语义②补偿):锁/版本目录仍在,任务非 failed
|
|
1326
|
+
expect(t.stage).not.toBe("failed");
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
it("热更待重启段为空 → 正常 done(restartRequired 不置位)", async () => {
|
|
1330
|
+
const t = await runInstall(makeReq());
|
|
1331
|
+
expect(t.status).toBe("done");
|
|
1332
|
+
expect(t.restartRequired).toBeUndefined();
|
|
1333
|
+
});
|
|
1334
|
+
});
|
|
@@ -108,10 +108,12 @@ const CATALOG = JSON.stringify({
|
|
|
108
108
|
{ name: "wiki", shards: [1] },
|
|
109
109
|
],
|
|
110
110
|
});
|
|
111
|
+
/** 最新版详细描述(C2-05;喂给 index 聚合断言,验证卡片能读到方案「提供什么」) */
|
|
112
|
+
const LATEST_DESCRIPTION = "演示方案:提供示例 CRUD 与菜单路由,覆盖安装、升级、回滚与历史留痕全链路。";
|
|
111
113
|
/** 分片正文(纯内容无 meta:版本条目即 Form B manifest——原 manifests.yaml 聚合清单并入分片) */
|
|
112
114
|
const SHARD_1 = JSON.stringify({
|
|
113
115
|
solutions: [
|
|
114
|
-
{ name: "demo-solution", latest: "0.2.0", versions: [manifestFixture("0.2.0"), manifestFixture("0.1.0")] },
|
|
116
|
+
{ name: "demo-solution", latest: "0.2.0", versions: [{ ...manifestFixture("0.2.0"), description: LATEST_DESCRIPTION }, manifestFixture("0.1.0")] },
|
|
115
117
|
{ name: "wiki", latest: "1.3.0", versions: [{ name: "wiki", version: "1.3.0", archive: { file: "wiki-1.3.0.tgz", sha256: "x", size: 1 } }] },
|
|
116
118
|
],
|
|
117
119
|
});
|
|
@@ -205,9 +207,15 @@ function bundleExec(): ExecFace {
|
|
|
205
207
|
};
|
|
206
208
|
}
|
|
207
209
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
210
|
+
// reload 桩:按被安装版本产出 applied 模块符号(+name@ver,v0.1.122 双符号契约),
|
|
211
|
+
// 供 reloadAndAssert 终态校验(R18)。默认 0.1.0 覆盖绝大多数用例;跨片索引用例(REG-CS5)
|
|
212
|
+
// 安装 1.0.2499 / 1.0.0,须按实装版本注入对应桩,否则符号不匹配 → reloading 段判 partial。
|
|
213
|
+
function reloadStubFor(version: string): ReloadFace {
|
|
214
|
+
return {
|
|
215
|
+
reloadConfig: async () => ({ generation: 1, unchanged: false, applied: [`+demo-solution@${version}`], restart_pending: [], kept_listener: [] }),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
const reloadStub: ReloadFace = reloadStubFor("0.1.0");
|
|
211
219
|
|
|
212
220
|
function makeEnv(overrides: Partial<MarketEnv> = {}): MarketEnv {
|
|
213
221
|
return {
|
|
@@ -247,8 +255,8 @@ beforeEach(async () => {
|
|
|
247
255
|
modulesJsonPath = join(root, "dist", "modules.json");
|
|
248
256
|
dbPath = join(root, "db.sqlite");
|
|
249
257
|
await fsp.writeFile(dbPath, new TextEncoder().encode("sqlite-header\n"));
|
|
250
|
-
// 站点 config.yaml 夹具(reloading
|
|
251
|
-
await fsp.writeFile(join(root, "config.yaml"), "mounts: []\nserver:\n reload_api: true\nvars:\n
|
|
258
|
+
// 站点 config.yaml 夹具(reloading 段仅做 anonymous_paths 接线;模板 config.yaml 随发形态)
|
|
259
|
+
await fsp.writeFile(join(root, "config.yaml"), "mounts: []\nserver:\n reload_api: true\nvars:\n other: 1\nauth:\n anonymous_paths:\n - /auth/login\n");
|
|
252
260
|
g.fs = nodeFsFace();
|
|
253
261
|
dbo = installDb();
|
|
254
262
|
dbo.prepare("INSERT OR IGNORE INTO sys_user_role (user_id, role_name) VALUES (?, ?)").run(1, "superadmin");
|
|
@@ -819,7 +827,7 @@ describe("GET index(CDN catalog + shards 拉取 + 本地 state 对比)", ()
|
|
|
819
827
|
expect(String(last?.msg)).toContain("未配置市场地址");
|
|
820
828
|
});
|
|
821
829
|
|
|
822
|
-
it("聚合对比:CDN 最新版本 + 本地已装/enabled + upgradable + available/changelog/publisher/trust/key_id/operator(P2-11);本地独有方案也列出;拉取不带 no-cache", async () => {
|
|
830
|
+
it("聚合对比:CDN 最新版本 + 本地已装/enabled + upgradable + available/changelog/publisher/publisher_key/license/summary/description/trust/key_id/operator(P2-11);本地独有方案也列出;拉取不带 no-cache", async () => {
|
|
823
831
|
const env = makeEnv();
|
|
824
832
|
const state = await createStateStore(env.statePath);
|
|
825
833
|
await state.write({
|
|
@@ -835,9 +843,9 @@ describe("GET index(CDN catalog + shards 拉取 + 本地 state 对比)", ()
|
|
|
835
843
|
// REG-CS5④:读面不再发 cache-control: no-cache(服务端缓存头 + 条件请求承载新鲜度)
|
|
836
844
|
expect(fetchInits.every(i => (i as { headers?: Record<string, string> } | undefined)?.headers?.["cache-control"] === undefined)).toBe(true);
|
|
837
845
|
expect(last?.data).toEqual([
|
|
838
|
-
{ name: "demo-solution", latest: "0.2.0", installed: "0.1.0", enabled: true, upgradable: true, available: ["0.1.0", "0.2.0"], changelog: ["0.2.0 变更条目"], publisher: "oj-module", license: "MIT", trust_state: "trusted", key_id: "test.publisher", operator: null },
|
|
839
|
-
{ name: "wiki", latest: "1.3.0", installed: null, enabled: null, upgradable: false, available: ["1.3.0"], changelog: null, publisher: null, license: null, trust_state: null, key_id: null, operator: null },
|
|
840
|
-
{ name: "legacy", latest: null, installed: "9.9.9", enabled: false, upgradable: false, available: [], changelog: null, publisher: null, license: null, trust_state: null, key_id: null, operator: null },
|
|
846
|
+
{ name: "demo-solution", latest: "0.2.0", installed: "0.1.0", enabled: true, upgradable: true, available: ["0.1.0", "0.2.0"], changelog: ["0.2.0 变更条目"], publisher: "oj-module", publisher_key: "test.publisher", license: "MIT", summary: "演示方案", description: LATEST_DESCRIPTION, trust_state: "trusted", key_id: "test.publisher", operator: null },
|
|
847
|
+
{ name: "wiki", latest: "1.3.0", installed: null, enabled: null, upgradable: false, available: ["1.3.0"], changelog: null, publisher: null, publisher_key: null, license: null, summary: null, description: null, trust_state: null, key_id: null, operator: null },
|
|
848
|
+
{ name: "legacy", latest: null, installed: "9.9.9", enabled: false, upgradable: false, available: [], changelog: null, publisher: null, publisher_key: null, license: null, summary: null, description: null, trust_state: null, key_id: null, operator: null },
|
|
841
849
|
]);
|
|
842
850
|
});
|
|
843
851
|
|
|
@@ -1190,7 +1198,7 @@ describe("REG-CS5 三级索引(装箱分片 + 条件请求 + DIP 读取源 +
|
|
|
1190
1198
|
}
|
|
1191
1199
|
|
|
1192
1200
|
it("BDD① 装箱分片:2500 版本跨 3 片,latest 安装只拉最高片(≤ 1 catalog + 1 shard)→ done", async () => {
|
|
1193
|
-
const api = createMarketApi(makeEnv({ fetch: bigIndexFetch() }));
|
|
1201
|
+
const api = createMarketApi(makeEnv({ fetch: bigIndexFetch(), reload: reloadStubFor("1.0.2499") }));
|
|
1194
1202
|
fetchCalls.length = 0;
|
|
1195
1203
|
g.http = { user: { id: 1 }, body: { name: "demo-solution", version: null, source: "cdn" } };
|
|
1196
1204
|
await api.install.post();
|
|
@@ -1208,7 +1216,7 @@ describe("REG-CS5 三级索引(装箱分片 + 条件请求 + DIP 读取源 +
|
|
|
1208
1216
|
});
|
|
1209
1217
|
|
|
1210
1218
|
it("BDD① 回落低片:显式指定最低版本 → 先试最高片未中,逐片回落到 1 号片命中 → done", async () => {
|
|
1211
|
-
const api = createMarketApi(makeEnv({ fetch: bigIndexFetch() }));
|
|
1219
|
+
const api = createMarketApi(makeEnv({ fetch: bigIndexFetch(), reload: reloadStubFor("1.0.0") }));
|
|
1212
1220
|
fetchCalls.length = 0;
|
|
1213
1221
|
g.http = { user: { id: 1 }, body: { name: "demo-solution", version: "1.0.0", source: "cdn" } };
|
|
1214
1222
|
await api.install.post();
|
|
@@ -1554,3 +1562,147 @@ describe("P1-16 接线:marketEnv 生产口径 / exec opts 转发 / operator
|
|
|
1554
1562
|
}
|
|
1555
1563
|
});
|
|
1556
1564
|
});
|
|
1565
|
+
|
|
1566
|
+
// ── 发布历史(GET /market/releases)────────────────────────────────────────────
|
|
1567
|
+
// 数据源是签名索引本身:每次读先把索引里出现过的 product@version 幂等补进 market_release,
|
|
1568
|
+
// 再按发布时间倒序分页。索引读不到不阻断查询(返回已入库行 + syncError)。
|
|
1569
|
+
|
|
1570
|
+
interface ReleasePageData {
|
|
1571
|
+
list: Array<{ id: number, product: string, version: string, published_at: number, published_at_source: string, yanked: number }>
|
|
1572
|
+
total: number
|
|
1573
|
+
page: number
|
|
1574
|
+
size: number
|
|
1575
|
+
syncError: string | null
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
/** HEAD 可读 Last-Modified 的 CDN 面(其余请求复用 cdnFetch 的完整夹具) */
|
|
1579
|
+
function headAwareFetch(lastModified: string): FetchFace {
|
|
1580
|
+
return (async (url: string, init?: { method?: string }) => {
|
|
1581
|
+
if (init?.method === "HEAD") {
|
|
1582
|
+
return {
|
|
1583
|
+
ok: true,
|
|
1584
|
+
status: 200,
|
|
1585
|
+
arrayBuffer: async () => new ArrayBuffer(0),
|
|
1586
|
+
headers: { get: (name: string) => name.toLowerCase() === "last-modified" ? lastModified : null },
|
|
1587
|
+
};
|
|
1588
|
+
}
|
|
1589
|
+
return cdnFetch(url);
|
|
1590
|
+
}) as FetchFace;
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
/** 换一版分片正文(并按其字节重签,seq 与 catalog 同代),用于构造「索引说已下线」) */
|
|
1594
|
+
function shardWithYanked(): { body: string, sig: string } {
|
|
1595
|
+
const body = JSON.stringify({
|
|
1596
|
+
solutions: [{
|
|
1597
|
+
name: "demo-solution",
|
|
1598
|
+
latest: "0.2.0",
|
|
1599
|
+
versions: [
|
|
1600
|
+
{ ...manifestFixture("0.2.0"), description: LATEST_DESCRIPTION },
|
|
1601
|
+
{ ...manifestFixture("0.1.0"), yanked: true },
|
|
1602
|
+
],
|
|
1603
|
+
}],
|
|
1604
|
+
});
|
|
1605
|
+
return { body, sig: JSON.stringify(signIndex("shard", SEQ, body, TEST_KEY.privateKeyPem, "test.publisher")) };
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
describe("GET /market/releases(发布历史:索引对账落库 + 分页)", () => {
|
|
1609
|
+
it("首读把索引里全部 product@version 补进历史:产品/版本/发布时间齐全,无 HEAD 头时回落索引 issued_at(source=index)", async () => {
|
|
1610
|
+
const api = createMarketApi(makeEnv());
|
|
1611
|
+
g.http = { user: { id: 1 } };
|
|
1612
|
+
await api.releases.get();
|
|
1613
|
+
expect(last?.status).toBe(200);
|
|
1614
|
+
const page = last?.data as ReleasePageData;
|
|
1615
|
+
expect(page.syncError).toBeNull();
|
|
1616
|
+
expect(page.total).toBe(3);
|
|
1617
|
+
expect(page.list.map(r => `${r.product}@${r.version}`)).toEqual(["wiki@1.3.0", "demo-solution@0.1.0", "demo-solution@0.2.0"]);
|
|
1618
|
+
expect(page.list.every(r => r.published_at === ISSUED_AT && r.published_at_source === "index")).toBe(true);
|
|
1619
|
+
// 每行先试 HEAD(拿真正的落 CDN 时刻),失败才回落——不是完全不问源
|
|
1620
|
+
expect(fetchInits.some(i => (i as { method?: string } | undefined)?.method === "HEAD")).toBe(true);
|
|
1621
|
+
});
|
|
1622
|
+
|
|
1623
|
+
it("HEAD 拿得到 Last-Modified → 用文件时间并标 source=file(索引时间只作兜底,不冒充精确)", async () => {
|
|
1624
|
+
const api = createMarketApi(makeEnv({ fetch: headAwareFetch("Wed, 01 Oct 2025 00:00:00 GMT") }));
|
|
1625
|
+
g.http = { user: { id: 1 } };
|
|
1626
|
+
await api.releases.get();
|
|
1627
|
+
expect(last?.status).toBe(200);
|
|
1628
|
+
const page = last?.data as ReleasePageData;
|
|
1629
|
+
const expected = Math.floor(Date.parse("Wed, 01 Oct 2025 00:00:00 GMT") / 1000);
|
|
1630
|
+
expect(page.list.every(r => r.published_at === expected && r.published_at_source === "file")).toBe(true);
|
|
1631
|
+
});
|
|
1632
|
+
|
|
1633
|
+
it("幂等 + append-only:二次读不重复插入,已入库的 published_at 不被索引时间改写", async () => {
|
|
1634
|
+
// 预置一行「上次记录的历史」:发布时间是事实,哪怕与本次索引的时间不同也不该被刷新
|
|
1635
|
+
dbo.prepare("INSERT INTO market_release (product, version, published_at, published_at_source, yanked, first_seen_at) VALUES (?, ?, ?, ?, ?, ?)")
|
|
1636
|
+
.run("demo-solution", "0.2.0", 1_600_000_000, "file", 0, 1_600_000_000);
|
|
1637
|
+
const api = createMarketApi(makeEnv());
|
|
1638
|
+
g.http = { user: { id: 1 } };
|
|
1639
|
+
await api.releases.get();
|
|
1640
|
+
await api.releases.get();
|
|
1641
|
+
expect(last?.status).toBe(200);
|
|
1642
|
+
const page = last?.data as ReleasePageData;
|
|
1643
|
+
expect(page.total).toBe(3);
|
|
1644
|
+
const rows = dbo.prepare("SELECT product, version, published_at FROM market_release ORDER BY product, version").all() as Array<{ product: string, version: string, published_at: number }>;
|
|
1645
|
+
expect(rows).toHaveLength(3);
|
|
1646
|
+
expect(rows.find(r => r.product === "demo-solution" && r.version === "0.2.0")?.published_at).toBe(1_600_000_000);
|
|
1647
|
+
});
|
|
1648
|
+
|
|
1649
|
+
it("yanked 跟随索引(0→1 与 1→0 双向):下线是当前状态,不是历史事实的改写", async () => {
|
|
1650
|
+
// 1→0:库里记着已下线,索引又回到在架
|
|
1651
|
+
dbo.prepare("INSERT INTO market_release (product, version, published_at, published_at_source, yanked, first_seen_at) VALUES (?, ?, ?, ?, ?, ?)")
|
|
1652
|
+
.run("demo-solution", "0.2.0", ISSUED_AT, "index", 1, ISSUED_AT);
|
|
1653
|
+
const api = createMarketApi(makeEnv());
|
|
1654
|
+
g.http = { user: { id: 1 } };
|
|
1655
|
+
await api.releases.get();
|
|
1656
|
+
expect(last?.status).toBe(200);
|
|
1657
|
+
expect((dbo.prepare("SELECT yanked FROM market_release WHERE product = ? AND version = ?").get("demo-solution", "0.2.0") as { yanked: number }).yanked).toBe(0);
|
|
1658
|
+
|
|
1659
|
+
// 0→1:换一版已签名的分片,其中 0.1.0 标了 yanked
|
|
1660
|
+
const yanked = shardWithYanked();
|
|
1661
|
+
const fetchFn = (async (url: string) => {
|
|
1662
|
+
if (String(url).endsWith("shards/1.yaml.sig"))
|
|
1663
|
+
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(yanked.sig).buffer as ArrayBuffer };
|
|
1664
|
+
if (String(url).endsWith("shards/1.yaml"))
|
|
1665
|
+
return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode(yanked.body).buffer as ArrayBuffer };
|
|
1666
|
+
return cdnFetch(url);
|
|
1667
|
+
}) as FetchFace;
|
|
1668
|
+
const api2 = createMarketApi(makeEnv({ fetch: fetchFn }));
|
|
1669
|
+
g.http = { user: { id: 1 } };
|
|
1670
|
+
await api2.releases.get();
|
|
1671
|
+
expect(last?.status).toBe(200);
|
|
1672
|
+
expect((dbo.prepare("SELECT yanked FROM market_release WHERE product = ? AND version = ?").get("demo-solution", "0.1.0") as { yanked: number }).yanked).toBe(1);
|
|
1673
|
+
});
|
|
1674
|
+
|
|
1675
|
+
it("索引读不到(验签失败)→ 200 + 已入库历史 + syncError 人话(观测面不阻断页面)", async () => {
|
|
1676
|
+
const api = createMarketApi(makeEnv());
|
|
1677
|
+
g.http = { user: { id: 1 } };
|
|
1678
|
+
await api.releases.get();
|
|
1679
|
+
expect((last?.data as ReleasePageData).total).toBe(3);
|
|
1680
|
+
|
|
1681
|
+
const brokenFetch = (async (url: string) => {
|
|
1682
|
+
if (String(url).endsWith("shards/1.yaml.sig"))
|
|
1683
|
+
return { ok: false, status: 503, arrayBuffer: async () => new ArrayBuffer(0) };
|
|
1684
|
+
return cdnFetch(url);
|
|
1685
|
+
}) as FetchFace;
|
|
1686
|
+
const api2 = createMarketApi(makeEnv({ fetch: brokenFetch }));
|
|
1687
|
+
g.http = { user: { id: 1 } };
|
|
1688
|
+
await api2.releases.get();
|
|
1689
|
+
expect(last?.status).toBe(200);
|
|
1690
|
+
const page = last?.data as ReleasePageData;
|
|
1691
|
+
expect(page.total).toBe(3);
|
|
1692
|
+
expect(page.syncError).toContain("index_sig_missing");
|
|
1693
|
+
});
|
|
1694
|
+
|
|
1695
|
+
it("门与分页:匿名 401;page/limit 透传并按发布时间倒序取页", async () => {
|
|
1696
|
+
const api = createMarketApi(makeEnv());
|
|
1697
|
+
g.http = {};
|
|
1698
|
+
await api.releases.get();
|
|
1699
|
+
expect(last?.status).toBe(401);
|
|
1700
|
+
|
|
1701
|
+
g.http = { user: { id: 1 }, query: { page: "2", limit: "1" } };
|
|
1702
|
+
await api.releases.get();
|
|
1703
|
+
expect(last?.status).toBe(200);
|
|
1704
|
+
const page = last?.data as ReleasePageData;
|
|
1705
|
+
expect(page).toMatchObject({ page: 2, size: 1, total: 3 });
|
|
1706
|
+
expect(page.list).toHaveLength(1);
|
|
1707
|
+
});
|
|
1708
|
+
});
|