@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,575 @@
|
|
|
1
|
+
// BDD:迁移步骤(P1-7,十阶段 stage 5 migrating,D11 核心裁决:迁移成功后才允许后续落锁)。
|
|
2
|
+
// 前置备份(R14):sqlite 库 copy 为 <db>.bak-<ts>(fsFace.copyFile),无备份不迁移(fail-closed);
|
|
3
|
+
// 完成后清理仅保留最近 3 份 .bak-*(按时间戳后缀数值比较)。
|
|
4
|
+
// S1-6 实测契约(oj 0.1.76):exec.run(["oj","migrate","--format","json","-d",dir,module]),
|
|
5
|
+
// stdout 恰一行 JSON {"results":[{module,ok,applied:[{version,name}],error?}]}(装配期全局失败
|
|
6
|
+
// 顶层 error + results 空);成败真源 = exit code(exit 0 = 全部成功),逐模块 continue-and-collect。
|
|
7
|
+
// rollbackGuard 纯函数:目标产物 max seq < 账本 head = 降版部署,禁止(OJ-9/M003 同源红线);
|
|
8
|
+
// seq 为数值非版本号,直接数值比较(semver.ts 不适用)。
|
|
9
|
+
// exec/fs 面注入 stub(同 steps-fs 范式),不真跑 oj/sqlite。
|
|
10
|
+
import type { FsFace } from "../../src/_platform/_lifecycle/atomic";
|
|
11
|
+
import type { StateStore } from "../../src/_platform/_lifecycle/state";
|
|
12
|
+
import type { ExecFace } from "../../src/_platform/_lifecycle/steps/extract";
|
|
13
|
+
import type { MigrateContext } from "../../src/_platform/_lifecycle/steps/migrate";
|
|
14
|
+
import * as fsp from "node:fs/promises";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
18
|
+
import { parseLock } from "../../src/_platform/_lifecycle/lock";
|
|
19
|
+
import { buildMigrateCmd, maxAppliedSeq, migrate, migrateSvcDirOf, parseMigrateOutput, rollbackGuard } from "../../src/_platform/_lifecycle/steps/migrate";
|
|
20
|
+
|
|
21
|
+
const g = globalThis as any;
|
|
22
|
+
const DB_BYTES = new TextEncoder().encode("sqlite-header-bytes\n");
|
|
23
|
+
|
|
24
|
+
/** S1-6 实测成功样例(逐字形态):demo 应用 seq 3 一条迁移 */
|
|
25
|
+
const OK_LINE = JSON.stringify({ results: [{ module: "demo-solution", ok: true, applied: [{ version: 3, name: "create_todos" }] }] });
|
|
26
|
+
|
|
27
|
+
let root: string;
|
|
28
|
+
let dbPath: string;
|
|
29
|
+
let staging: string;
|
|
30
|
+
|
|
31
|
+
beforeEach(async () => {
|
|
32
|
+
root = await fsp.mkdtemp(join(tmpdir(), "ojm-migrate-"));
|
|
33
|
+
dbPath = join(root, "db.sqlite");
|
|
34
|
+
await fsp.writeFile(dbPath, DB_BYTES);
|
|
35
|
+
// §2.9 解包后的扁平 staging:api/<name>-<version>/ 为 oj build 产物模块目录(原生清单 + 迁移 SQL)
|
|
36
|
+
staging = join(root, "modules", ".staging");
|
|
37
|
+
const modDir = join(staging, "api", "demo-solution-0.1.0");
|
|
38
|
+
await fsp.mkdir(join(modDir, "migrations"), { recursive: true });
|
|
39
|
+
await fsp.writeFile(join(modDir, "manifest.yaml"), "name: demo-solution\ndesc: 演示方案\nversion: 0.1.0\n");
|
|
40
|
+
await fsp.writeFile(join(modDir, "routes.js"), "export {};\n");
|
|
41
|
+
await fsp.writeFile(join(modDir, "migrations", "0001__init.sql"), "CREATE TABLE note (id INTEGER PRIMARY KEY);\n");
|
|
42
|
+
g.fs = nodeFsFace();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(async () => {
|
|
46
|
+
g.fs = undefined;
|
|
47
|
+
await fsp.rm(root, { recursive: true, force: true });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
function nodeFsFace(): FsFace {
|
|
51
|
+
return {
|
|
52
|
+
writeFile: (path, data) => fsp.writeFile(path, data),
|
|
53
|
+
readFile: path => fsp.readFile(path),
|
|
54
|
+
mkdir: (path, opts) => fsp.mkdir(path, opts),
|
|
55
|
+
remove: (path, opts) => fsp.rm(path, opts),
|
|
56
|
+
rename: (oldPath, newPath) => fsp.rename(oldPath, newPath),
|
|
57
|
+
copyFile: (src, dest) => fsp.copyFile(src, dest),
|
|
58
|
+
readdir: path => fsp.readdir(path),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 记录 argv 的 exec stub(同 steps-fs 范式;handler 可模拟失败/违约输出) */
|
|
63
|
+
function recordingExec(handler?: (argv: string[]) => Promise<{ code: number, stdout: string, stderr: string }>): ExecFace & { calls: string[][], opts: (undefined | { timeoutMs?: number })[] } {
|
|
64
|
+
const calls: string[][] = [];
|
|
65
|
+
const opts: (undefined | { timeoutMs?: number })[] = [];
|
|
66
|
+
return {
|
|
67
|
+
calls,
|
|
68
|
+
opts,
|
|
69
|
+
run: async (argv, o) => {
|
|
70
|
+
calls.push([...argv]);
|
|
71
|
+
opts.push(o);
|
|
72
|
+
return handler
|
|
73
|
+
? handler(argv)
|
|
74
|
+
: { code: 0, stdout: "", stderr: "" };
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 恒成功且返回 S1-6 合同 JSON 的 exec stub(stdout 带尾随换行,oj 真实形态) */
|
|
80
|
+
function okExec(): ExecFace & { calls: string[][] } {
|
|
81
|
+
return recordingExec(async () => ({ code: 0, stdout: `${OK_LINE}\n`, stderr: "" }));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 记录写入次数的 state stub(迁移步绝不允许触碰 state——锁前语义 D11) */
|
|
85
|
+
function recordingState(): StateStore & { writes: number } {
|
|
86
|
+
const s = { writes: 0 } as StateStore & { writes: number };
|
|
87
|
+
s.read = () => ({ solutions: {} });
|
|
88
|
+
s.write = async () => {
|
|
89
|
+
s.writes++;
|
|
90
|
+
};
|
|
91
|
+
return s;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function mgCtx(overrides: Partial<MigrateContext> = {}): MigrateContext {
|
|
95
|
+
return {
|
|
96
|
+
manifest: { name: "demo-solution", version: "0.1.0" },
|
|
97
|
+
name: "demo-solution",
|
|
98
|
+
version: "0.1.0",
|
|
99
|
+
state: recordingState(),
|
|
100
|
+
staging,
|
|
101
|
+
dbPath,
|
|
102
|
+
exec: okExec(),
|
|
103
|
+
...overrides,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
describe("命令构造纯函数(exec 白名单逐字对齐,S1-2/S1-6)", () => {
|
|
108
|
+
it("buildMigrateCmd 逐字:[oj, migrate, --format, json, -d, dir, module]", () => {
|
|
109
|
+
expect(buildMigrateCmd("/site/dist", "demo-solution")).toEqual([
|
|
110
|
+
"oj",
|
|
111
|
+
"migrate",
|
|
112
|
+
"--format",
|
|
113
|
+
"json",
|
|
114
|
+
"-d",
|
|
115
|
+
"/site/dist",
|
|
116
|
+
"demo-solution",
|
|
117
|
+
]);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("rollbackGuard(纯函数:true=放行,OJ-9 禁降版)", () => {
|
|
122
|
+
it("首装放行:ledgerHead 为 null/undefined/0 一律放行", () => {
|
|
123
|
+
expect(rollbackGuard(3, null)).toBe(true);
|
|
124
|
+
expect(rollbackGuard(3, undefined)).toBe(true);
|
|
125
|
+
expect(rollbackGuard(3, 0)).toBe(true);
|
|
126
|
+
expect(rollbackGuard(3n, 0n)).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("正常升级放行:目标 max seq ≥ head(含等值与 number/bigint 混比)", () => {
|
|
130
|
+
expect(rollbackGuard(7, 5)).toBe(true);
|
|
131
|
+
expect(rollbackGuard(5, 5)).toBe(true);
|
|
132
|
+
expect(rollbackGuard(10n, 7)).toBe(true);
|
|
133
|
+
expect(rollbackGuard(7, 7n)).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("降版禁止:目标 max seq < head(OJ-9 红线)", () => {
|
|
137
|
+
expect(rollbackGuard(5, 7)).toBe(false);
|
|
138
|
+
expect(rollbackGuard(3n, 5)).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("parseMigrateOutput(纯函数,S1-6 实测契约)", () => {
|
|
143
|
+
it("实测样例逐字解析:results 三条、applied 取 {version:1, name:create_todos}、失败模块带 error", () => {
|
|
144
|
+
const line = JSON.stringify({
|
|
145
|
+
results: [
|
|
146
|
+
{ module: "auth", ok: true, applied: [] },
|
|
147
|
+
{ module: "demo", ok: true, applied: [{ version: 1, name: "create_todos" }] },
|
|
148
|
+
{ module: "home", ok: false, applied: [], error: "at seq 2: `error applying migration V2__broken`" },
|
|
149
|
+
],
|
|
150
|
+
});
|
|
151
|
+
const r = parseMigrateOutput(`${line}\n`);
|
|
152
|
+
expect(r.ok).toBe(true);
|
|
153
|
+
if (r.ok) {
|
|
154
|
+
expect(r.output.results).toHaveLength(3);
|
|
155
|
+
expect(r.output.results[1]).toEqual({ module: "demo", ok: true, applied: [{ version: 1, name: "create_todos" }] });
|
|
156
|
+
expect(r.output.results[2]!.ok).toBe(false);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("尾随换行/空白先 trim 再解析(stdout 恒带换行)", () => {
|
|
161
|
+
const r = parseMigrateOutput(` ${OK_LINE}\n\n`);
|
|
162
|
+
expect(r.ok).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("装配期全局失败形态:顶层 error + results 空数组,仍算合法输出(成败以 exit code 为真源)", () => {
|
|
166
|
+
const r = parseMigrateOutput(JSON.stringify({ error: "refinery: DSN 已脱敏", results: [] }));
|
|
167
|
+
expect(r.ok).toBe(true);
|
|
168
|
+
if (r.ok) {
|
|
169
|
+
expect(r.output.results).toHaveLength(0);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("非 JSON 输出拒(ok=false 带原因)", () => {
|
|
174
|
+
const r = parseMigrateOutput("not-json at all");
|
|
175
|
+
expect(r.ok).toBe(false);
|
|
176
|
+
expect(!r.ok && r.message).toContain("JSON");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("顶层非键值对象(数组/标量/null)拒", () => {
|
|
180
|
+
expect(parseMigrateOutput("[]").ok).toBe(false);
|
|
181
|
+
expect(parseMigrateOutput("42").ok).toBe(false);
|
|
182
|
+
expect(parseMigrateOutput("null").ok).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("results 缺失或非数组拒", () => {
|
|
186
|
+
expect(parseMigrateOutput("{}").ok).toBe(false);
|
|
187
|
+
expect(parseMigrateOutput(JSON.stringify({ results: {} })).ok).toBe(false);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("条目形状损坏拒:module 非字符串 / ok 非布尔 / applied 非数组 / applied 条目 version 非数字", () => {
|
|
191
|
+
const bad = [
|
|
192
|
+
{ results: [{ module: 1, ok: true, applied: [] }] },
|
|
193
|
+
{ results: [{ module: "demo", ok: "yes", applied: [] }] },
|
|
194
|
+
{ results: [{ module: "demo", ok: true, applied: {} }] },
|
|
195
|
+
{ results: [{ module: "demo", ok: true, applied: [{ version: "1", name: "x" }] }] },
|
|
196
|
+
{ results: ["demo"] },
|
|
197
|
+
];
|
|
198
|
+
for (const shape of bad) {
|
|
199
|
+
expect(parseMigrateOutput(JSON.stringify(shape)).ok).toBe(false);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe("maxAppliedSeq(目标产物 max seq,rollbackGuard 的 seq 来源)", () => {
|
|
205
|
+
it("跨模块取最大 version;无 applied 返回 null", () => {
|
|
206
|
+
const r = parseMigrateOutput(JSON.stringify({
|
|
207
|
+
results: [
|
|
208
|
+
{ module: "a", ok: true, applied: [{ version: 2, name: "x" }] },
|
|
209
|
+
{ module: "b", ok: true, applied: [{ version: 7, name: "y" }, { version: 4, name: "z" }] },
|
|
210
|
+
],
|
|
211
|
+
}));
|
|
212
|
+
expect(r.ok && maxAppliedSeq(r.output)).toBe(7);
|
|
213
|
+
const empty = parseMigrateOutput(JSON.stringify({ results: [{ module: "a", ok: true, applied: [] }] }));
|
|
214
|
+
expect(empty.ok && maxAppliedSeq(empty.output)).toBeNull();
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("p2-13 exec.spawn 流式路径(G2①:feature-detect 双路径;onStdout 回调内自累积跨块拼接)", () => {
|
|
219
|
+
/** spawn stub:stdout 按 byteLength 切成多块(默认切在 UTF-8 双字节中文字符中间)异步吐出 */
|
|
220
|
+
function spawnExec(output: string, opts: { code?: number, sliceAt?: number[] } = {}): ExecFace & { spawnCalls: number, opts: (undefined | { timeoutMs?: number })[] } {
|
|
221
|
+
const bytes = new TextEncoder().encode(output);
|
|
222
|
+
const cuts = opts.sliceAt ?? [5, 12];
|
|
223
|
+
const chunks: Uint8Array[] = [];
|
|
224
|
+
let prev = 0;
|
|
225
|
+
for (const c of [...cuts, bytes.byteLength]) {
|
|
226
|
+
if (c > prev && c <= bytes.byteLength) {
|
|
227
|
+
chunks.push(bytes.slice(prev, c));
|
|
228
|
+
prev = c;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (prev < bytes.byteLength) {
|
|
232
|
+
chunks.push(bytes.slice(prev));
|
|
233
|
+
}
|
|
234
|
+
const self = {
|
|
235
|
+
spawnCalls: 0,
|
|
236
|
+
opts: [] as (undefined | { timeoutMs?: number })[],
|
|
237
|
+
run: async (): Promise<{ code: number, stdout: string, stderr: string }> => {
|
|
238
|
+
throw new Error("spawn 在场时不得回落 exec.run");
|
|
239
|
+
},
|
|
240
|
+
spawn: async (_argv: string[], o?: { timeoutMs?: number }) => {
|
|
241
|
+
self.spawnCalls++;
|
|
242
|
+
self.opts.push(o);
|
|
243
|
+
async function* gen(list: Uint8Array[]) {
|
|
244
|
+
for (const c of list)
|
|
245
|
+
yield c;
|
|
246
|
+
}
|
|
247
|
+
return {
|
|
248
|
+
stdout: gen(chunks),
|
|
249
|
+
stderr: gen([new TextEncoder().encode("warn-line\n")]),
|
|
250
|
+
exit: async () => ({ code: opts.code ?? 0 }),
|
|
251
|
+
kill: () => {},
|
|
252
|
+
};
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
return self as unknown as ExecFace & { spawnCalls: number, opts: (undefined | { timeoutMs?: number })[] };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
it("跨块拼接:S1-6 JSON 恰一行被切 3 块(含中文多字节边界)→ 解析成功、onMigrateOutput 捕获 applied max seq", async () => {
|
|
259
|
+
const seen: { frac: number }[] = [];
|
|
260
|
+
const outs: unknown[] = [];
|
|
261
|
+
const exec = spawnExec(`${OK_LINE}\n`);
|
|
262
|
+
const r = await migrate(mgCtx({
|
|
263
|
+
exec,
|
|
264
|
+
onProgress: frac => seen.push({ frac }),
|
|
265
|
+
onMigrateOutput: o => outs.push(o),
|
|
266
|
+
}));
|
|
267
|
+
expect(r.ok).toBe(true);
|
|
268
|
+
// 解析成功且 applied 捕获(跨 chunk 拼接后 parseMigrateOutput 拿到完整 JSON)
|
|
269
|
+
expect(outs).toHaveLength(1);
|
|
270
|
+
const out = outs[0] as { results: Array<{ applied: Array<{ version: number }> }> };
|
|
271
|
+
const max = Math.max(...out.results.flatMap(x => x.applied.map(a => a.version)));
|
|
272
|
+
expect(max).toBeGreaterThan(0);
|
|
273
|
+
// 进度逐块上报且单调
|
|
274
|
+
expect(seen.length).toBeGreaterThanOrEqual(3);
|
|
275
|
+
for (let i = 1; i < seen.length; i++)
|
|
276
|
+
expect(seen[i]!.frac).toBeGreaterThan(seen[i - 1]!.frac);
|
|
277
|
+
expect(seen[seen.length - 1]!.frac).toBeLessThan(1);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("spawn 在场:argv/timeoutMs 逐字转发;stdout 与 stderr 双流消费(背压语义不悬挂)", async () => {
|
|
281
|
+
const exec = spawnExec(`${OK_LINE}\n`);
|
|
282
|
+
const r = await migrate(mgCtx({ exec }));
|
|
283
|
+
expect(r.ok).toBe(true);
|
|
284
|
+
expect(exec.spawnCalls).toBe(1);
|
|
285
|
+
expect(exec.opts[0]).toEqual({ timeoutMs: 600_000 });
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("spawn 超时/超限形态(code=137 不抛错,v0.1.79 订正)→ 迁移失败 retryable,stderr 摘要在场", async () => {
|
|
289
|
+
const exec = spawnExec("", { code: 137 });
|
|
290
|
+
const r = await migrate(mgCtx({ exec }));
|
|
291
|
+
expect(r.ok).toBe(false);
|
|
292
|
+
expect(r.retryable).toBe(true);
|
|
293
|
+
expect(r.message).toContain("137");
|
|
294
|
+
expect(r.message).toContain("warn-line");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("无 spawn(回落 stage 粒度):exec.run 缓冲路径照常(既有用例同路径,此处显式钉回落不炸)", async () => {
|
|
298
|
+
const exec = okExec();
|
|
299
|
+
const r = await migrate(mgCtx({ exec, onProgress: () => {
|
|
300
|
+
throw new Error("缓冲路径不得触发进度回调");
|
|
301
|
+
} }));
|
|
302
|
+
expect(r.ok).toBe(true);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
describe("p3-8 取消安装(U5):cancel token → h.kill 提前终止 + appliedSeq 稳定", () => {
|
|
307
|
+
/**
|
|
308
|
+
* 取消用 spawn stub:stdout 切多块吐出;spawn 一旦启动即记录,kill 记录调用;exit 返回 0(常态,
|
|
309
|
+
* 取消由 killed 标记而非退出码判定)。cancel token 仅在 spawn 启动且收过 ≥1 块后才为真——避免
|
|
310
|
+
* 误触 top-of-exec 检测(真实 cancel 是布尔闭包,此处用 spawnStarted 门控贴近语义)
|
|
311
|
+
*/
|
|
312
|
+
function cancelSpawnExec(): ExecFace & { killCalled: () => boolean, cancel: () => boolean } {
|
|
313
|
+
let killed = false;
|
|
314
|
+
let spawnStarted = false;
|
|
315
|
+
let chunksInSpawn = 0;
|
|
316
|
+
return {
|
|
317
|
+
run: async () => { throw new Error("spawn 在场不得回落 exec.run"); },
|
|
318
|
+
spawn: async () => {
|
|
319
|
+
spawnStarted = true;
|
|
320
|
+
const bytes = new TextEncoder().encode(`${OK_LINE}\n`);
|
|
321
|
+
const cuts = [5, 12, bytes.byteLength];
|
|
322
|
+
const chunks: Uint8Array[] = [];
|
|
323
|
+
let prev = 0;
|
|
324
|
+
for (const c of cuts) {
|
|
325
|
+
if (c > prev) {
|
|
326
|
+
chunks.push(bytes.slice(prev, c));
|
|
327
|
+
prev = c;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
async function* gen() {
|
|
331
|
+
for (const c of chunks) {
|
|
332
|
+
chunksInSpawn++;
|
|
333
|
+
yield c;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return {
|
|
337
|
+
stdout: gen(),
|
|
338
|
+
stderr: gen(),
|
|
339
|
+
exit: async () => ({ code: 0 }),
|
|
340
|
+
kill: () => {
|
|
341
|
+
killed = true;
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
},
|
|
345
|
+
killCalled: () => killed,
|
|
346
|
+
cancel: () => spawnStarted && chunksInSpawn >= 1,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
it("spawn 流式路径首块后取消 → h.kill() 触发、提前终止返回 cancelled、appliedSeq 未被部分输出污染", async () => {
|
|
351
|
+
const exec = cancelSpawnExec();
|
|
352
|
+
const outs: unknown[] = [];
|
|
353
|
+
const r = await migrate(mgCtx({ exec, cancel: exec.cancel, onMigrateOutput: o => outs.push(o), onProgress: () => {} }));
|
|
354
|
+
expect(r.ok).toBe(false);
|
|
355
|
+
expect((r as { cancelled?: boolean }).cancelled).toBe(true);
|
|
356
|
+
expect(exec.killCalled()).toBe(true);
|
|
357
|
+
// 取消发生在解析前 → appliedSeq 捕获回调不得被部分输出触发(稳定:不污染对账 floor)
|
|
358
|
+
expect(outs).toHaveLength(0);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("run 缓冲路径取消:返回 cancelled(缓冲路径无法中途 kill,但返回前探测命中)", async () => {
|
|
362
|
+
const exec = recordingExec(async () => ({ code: 0, stdout: `${OK_LINE}\n`, stderr: "" }));
|
|
363
|
+
const r = await migrate(mgCtx({ exec, cancel: () => true }));
|
|
364
|
+
expect(r.ok).toBe(false);
|
|
365
|
+
expect((r as { cancelled?: boolean }).cancelled).toBe(true);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("未取消:cancel token 恒 false → 正常完成、appliedSeq 捕获、kill 不调用", async () => {
|
|
369
|
+
const exec = cancelSpawnExec();
|
|
370
|
+
const seen: { frac: number }[] = [];
|
|
371
|
+
const outs: unknown[] = [];
|
|
372
|
+
const r = await migrate(mgCtx({ exec, cancel: () => false, onProgress: f => seen.push({ frac: f }), onMigrateOutput: o => outs.push(o) }));
|
|
373
|
+
expect(r.ok).toBe(true);
|
|
374
|
+
expect(outs).toHaveLength(1);
|
|
375
|
+
expect(exec.killCalled()).toBe(false);
|
|
376
|
+
expect(seen.length).toBeGreaterThanOrEqual(3);
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
describe("migrate(stage=migrating:R14 备份 → exec → 解析 → 禁降版守卫;绝触碰锁与 state)", () => {
|
|
381
|
+
it("成功:备份文件存在且字节一致,exec argv 逐字对齐 buildMigrateCmd,state 零写入", async () => {
|
|
382
|
+
const state = recordingState();
|
|
383
|
+
const exec = okExec();
|
|
384
|
+
const r = await migrate(mgCtx({ state, exec }));
|
|
385
|
+
expect(r.stage).toBe("migrating");
|
|
386
|
+
expect(r.ok).toBe(true);
|
|
387
|
+
const baks = (await fsp.readdir(root)).filter(n => n.startsWith("db.sqlite.bak-"));
|
|
388
|
+
expect(baks).toHaveLength(1);
|
|
389
|
+
expect(new Uint8Array(await fsp.readFile(join(root, baks[0]!)))).toEqual(DB_BYTES);
|
|
390
|
+
expect(exec.calls[0]).toEqual(buildMigrateCmd(migrateSvcDirOf(staging), "demo-solution"));
|
|
391
|
+
// 超时显式取平台硬上限:oj exec.run timeoutMs 缺省 30s 会杀生产级长迁移(0.1.78 文档化口径)
|
|
392
|
+
expect(exec.opts[0]).toEqual({ timeoutMs: 600_000 });
|
|
393
|
+
expect(state.writes).toBe(0);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("备份保留 3 份:完成后仅留最近 3 个 .bak-*(R14 清理策略)", async () => {
|
|
397
|
+
await fsp.writeFile(`${dbPath}.bak-1000`, DB_BYTES);
|
|
398
|
+
await fsp.writeFile(`${dbPath}.bak-2000`, DB_BYTES);
|
|
399
|
+
await fsp.writeFile(`${dbPath}.bak-3000`, DB_BYTES);
|
|
400
|
+
const r = await migrate(mgCtx());
|
|
401
|
+
expect(r.ok).toBe(true);
|
|
402
|
+
const baks = (await fsp.readdir(root)).filter(n => n.startsWith("db.sqlite.bak-"));
|
|
403
|
+
expect(baks).toHaveLength(3);
|
|
404
|
+
expect(baks.some(n => n.endsWith(".bak-1000"))).toBe(false);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it("备份清理 readdir 失败:迁移照常 ok,清理失败 note 并入 message(禁 console——release isolate console 抛 BadResource,Finding A)", async () => {
|
|
408
|
+
const base = nodeFsFace();
|
|
409
|
+
g.fs = {
|
|
410
|
+
...base,
|
|
411
|
+
readdir: (path: string) => (path === root ? Promise.reject(new Error("readdir 炸了")) : fsp.readdir(path)),
|
|
412
|
+
};
|
|
413
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
414
|
+
try {
|
|
415
|
+
const r = await migrate(mgCtx());
|
|
416
|
+
expect(r.ok).toBe(true);
|
|
417
|
+
expect(r.message).toContain("备份清理失败");
|
|
418
|
+
expect(r.message).toContain("readdir 炸了");
|
|
419
|
+
expect(warn).not.toHaveBeenCalled();
|
|
420
|
+
}
|
|
421
|
+
finally {
|
|
422
|
+
warn.mockRestore();
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("oj migrate 失败(exit≠0):ok=false retryable=true,stderr 摘要入 message,state 未变", async () => {
|
|
427
|
+
const state = recordingState();
|
|
428
|
+
const exec = recordingExec(async () => ({
|
|
429
|
+
code: 1,
|
|
430
|
+
stdout: JSON.stringify({ results: [{ module: "demo-solution", ok: false, applied: [], error: "at seq 2: migration V2__broken" }] }),
|
|
431
|
+
stderr: "",
|
|
432
|
+
}));
|
|
433
|
+
const r = await migrate(mgCtx({ state, exec }));
|
|
434
|
+
expect(r.ok).toBe(false);
|
|
435
|
+
expect(r.retryable).toBe(true);
|
|
436
|
+
expect(r.message).toContain("exit 1");
|
|
437
|
+
expect(r.message).toContain("at seq 2");
|
|
438
|
+
expect(r.message).toContain(".bak-");
|
|
439
|
+
expect(state.writes).toBe(0);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("exit 0 但 stdout 非 JSON:ok=false retryable=false(输出契约破坏,重试不自愈)", async () => {
|
|
443
|
+
const exec = recordingExec(async () => ({ code: 0, stdout: "segmentation fault", stderr: "" }));
|
|
444
|
+
const r = await migrate(mgCtx({ exec }));
|
|
445
|
+
expect(r.ok).toBe(false);
|
|
446
|
+
expect(r.retryable).toBe(false);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("exit 0 但 results 含 ok=false 模块:按失败处理,message 含模块名与 error 摘要", async () => {
|
|
450
|
+
const line = JSON.stringify({ results: [{ module: "demo-solution", ok: false, applied: [], error: "incomplete input" }] });
|
|
451
|
+
const exec = recordingExec(async () => ({ code: 0, stdout: line, stderr: "" }));
|
|
452
|
+
const r = await migrate(mgCtx({ exec }));
|
|
453
|
+
expect(r.ok).toBe(false);
|
|
454
|
+
expect(r.retryable).toBe(true);
|
|
455
|
+
expect(r.message).toContain("demo-solution");
|
|
456
|
+
expect(r.message).toContain("incomplete input");
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("禁降版守卫:ledgerHead=5、applied max seq=3 → ok=false retryable=false 引降版红线;ledgerHead=3 放行", async () => {
|
|
460
|
+
const state = recordingState();
|
|
461
|
+
const blocked = await migrate(mgCtx({ state, ledgerHead: 5 }));
|
|
462
|
+
expect(blocked.ok).toBe(false);
|
|
463
|
+
expect(blocked.retryable).toBe(false);
|
|
464
|
+
expect(blocked.message).toContain("降版");
|
|
465
|
+
expect(state.writes).toBe(0);
|
|
466
|
+
|
|
467
|
+
const allowed = await migrate(mgCtx({ ledgerHead: 3 }));
|
|
468
|
+
expect(allowed.ok).toBe(true);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it("no-op(applied 空数组,同版重装)不触发禁降版判定:ledgerHead=5 仍 ok=true", async () => {
|
|
472
|
+
const line = JSON.stringify({ results: [{ module: "demo-solution", ok: true, applied: [] }] });
|
|
473
|
+
const exec = recordingExec(async () => ({ code: 0, stdout: line, stderr: "" }));
|
|
474
|
+
const r = await migrate(mgCtx({ exec, ledgerHead: 5 }));
|
|
475
|
+
expect(r.ok).toBe(true);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("备份不可用(copyFile 抛错):ok=false 且 exec 零调用(无备份不迁移,fail-closed)", async () => {
|
|
479
|
+
const exec = okExec();
|
|
480
|
+
const brokenFs = { ...nodeFsFace(), copyFile: async () => {
|
|
481
|
+
throw new Error("EACCES");
|
|
482
|
+
} };
|
|
483
|
+
g.fs = brokenFs;
|
|
484
|
+
const r = await migrate(mgCtx({ exec }));
|
|
485
|
+
expect(r.ok).toBe(false);
|
|
486
|
+
expect(r.message).toContain("备份");
|
|
487
|
+
expect(exec.calls.length).toBe(0);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("exec 抛异常:ok=false retryable=true", async () => {
|
|
491
|
+
const exec = recordingExec(async () => {
|
|
492
|
+
throw new Error("NotCapable: exec requires module ownership");
|
|
493
|
+
});
|
|
494
|
+
const r = await migrate(mgCtx({ exec }));
|
|
495
|
+
expect(r.ok).toBe(false);
|
|
496
|
+
expect(r.retryable).toBe(true);
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
// B-11:`oj migrate -d` 必须指向 oj release discover 可解析的目录——D11 迁移先于落锁,模块
|
|
501
|
+
// 尚不在站点锁内,站点 sharedRoot 不可作 -d;安装器物化「迁移服务目录」(staging 兄弟目录,
|
|
502
|
+
// release 形态 = 扁平锁 manifests.yaml + <name>-<version>/ 模块目录拷贝),oj is_release 判定
|
|
503
|
+
// (目录含 manifests.yaml 即 release)使 dev 站点上同样走 release discover(oj-bin serve_cmd.rs
|
|
504
|
+
// is_release + manifest.rs discover 真源)。运行前后svc 目录都不得残留。
|
|
505
|
+
describe("迁移服务目录(B-11:-d 指向 release 形态 svc 目录,oj discover 可解析)", () => {
|
|
506
|
+
it("migrateSvcDirOf 布局推导:staging 兄弟目录 <staging>-migrate(相对/绝对路径同构)", () => {
|
|
507
|
+
expect(migrateSvcDirOf("modules/.staging")).toBe("modules/.staging-migrate");
|
|
508
|
+
expect(migrateSvcDirOf("/site/modules/.staging")).toBe("/site/modules/.staging-migrate");
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it("物化 + 清场:svc 目录含扁平锁(恰一条目,D12)与模块目录全量拷贝(原生清单/迁移 SQL 随行);exec -d 逐字指向;运行后清场", async () => {
|
|
512
|
+
// svc 目录在 migrate 返回前已被 finally 清场——物化内容须在 exec(oj migrate 视角)运行时点验证
|
|
513
|
+
let capturedDir: string | null = null;
|
|
514
|
+
let lockSeen: Record<string, string> | null = null;
|
|
515
|
+
let modFilesSeen: string[] | null = null;
|
|
516
|
+
const exec = recordingExec(async (argv) => {
|
|
517
|
+
capturedDir = argv[5] ?? null;
|
|
518
|
+
lockSeen = parseLock(await fsp.readFile(join(capturedDir!, "manifests.yaml"), "utf8"));
|
|
519
|
+
modFilesSeen = (await fsp.readdir(join(capturedDir!, "demo-solution-0.1.0"))).sort();
|
|
520
|
+
await fsp.access(join(capturedDir!, "demo-solution-0.1.0", "migrations", "0001__init.sql"));
|
|
521
|
+
return { code: 0, stdout: `${OK_LINE}\n`, stderr: "" };
|
|
522
|
+
});
|
|
523
|
+
const r = await migrate(mgCtx({ exec }));
|
|
524
|
+
expect(r.ok).toBe(true);
|
|
525
|
+
expect(capturedDir).toBe(migrateSvcDirOf(staging));
|
|
526
|
+
expect(lockSeen).toEqual({ "demo-solution": "0.1.0" });
|
|
527
|
+
expect(modFilesSeen).toEqual(["manifest.yaml", "migrations", "routes.js"]);
|
|
528
|
+
// 运行后清场:svc 目录不得残留(staging 本体原样保留,lock_commit 的 rename 源)
|
|
529
|
+
await expect(fsp.access(migrateSvcDirOf(staging))).rejects.toThrow();
|
|
530
|
+
await fsp.access(join(staging, "api", "demo-solution-0.1.0", "manifest.yaml"));
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it("失败路径(exit≠0)svc 目录同样清场(finally 语义)", async () => {
|
|
534
|
+
const exec = recordingExec(async () => ({ code: 1, stdout: "", stderr: "boom" }));
|
|
535
|
+
const r = await migrate(mgCtx({ exec }));
|
|
536
|
+
expect(r.ok).toBe(false);
|
|
537
|
+
await expect(fsp.access(migrateSvcDirOf(staging))).rejects.toThrow();
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it("包内无 api/<name>-<version>/ 段(web-only 方案):跳过迁移 ok=true,exec 与 R14 备份零触发", async () => {
|
|
541
|
+
await fsp.rm(join(staging, "api"), { recursive: true });
|
|
542
|
+
const exec = okExec();
|
|
543
|
+
const r = await migrate(mgCtx({ exec }));
|
|
544
|
+
expect(r.ok).toBe(true);
|
|
545
|
+
expect(r.message).toContain("web-only");
|
|
546
|
+
expect(exec.calls.length).toBe(0);
|
|
547
|
+
const baks = (await fsp.readdir(root)).filter(n => n.startsWith("db.sqlite.bak-"));
|
|
548
|
+
expect(baks).toHaveLength(0);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it("残留 svc 目录(上次崩溃遗留):先清后建,不污染本次迁移输入", async () => {
|
|
552
|
+
const svc = migrateSvcDirOf(staging);
|
|
553
|
+
await fsp.mkdir(join(svc, "junk"), { recursive: true });
|
|
554
|
+
await fsp.writeFile(join(svc, "manifests.yaml"), "evil: 9.9.9\n");
|
|
555
|
+
const r = await migrate(mgCtx());
|
|
556
|
+
expect(r.ok).toBe(true);
|
|
557
|
+
await expect(fsp.access(join(svc, "junk"))).rejects.toThrow();
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
// oj 真机实测(0.1.78):remove 不存在的路径即使 force:true 也抛 TypeError: invalid_argument
|
|
561
|
+
// ——首装(无残留)不得被残留清除的报错挡死(真机冒烟 P1-13b 首轮失败根因)
|
|
562
|
+
it("首装无残留:oj 语义 remove(missing) 抛 invalid_argument 不阻断物化", async () => {
|
|
563
|
+
const ojLikeFs: FsFace = {
|
|
564
|
+
...nodeFsFace(),
|
|
565
|
+
remove: async (path, opts) => {
|
|
566
|
+
await fsp.access(path);
|
|
567
|
+
return fsp.rm(path, opts);
|
|
568
|
+
},
|
|
569
|
+
};
|
|
570
|
+
g.fs = ojLikeFs;
|
|
571
|
+
const r = await migrate(mgCtx());
|
|
572
|
+
expect(r.ok).toBe(true);
|
|
573
|
+
expect(r.message).toContain("迁移完成");
|
|
574
|
+
});
|
|
575
|
+
});
|