modelstage 0.0.0-stage → 0.5.24
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/LICENSE +21 -0
- package/README.md +267 -2
- package/THIRD_PARTY_NOTICES.md +90 -0
- package/bin/modelstage.mjs +101 -0
- package/config/diag-upload.example.json +7 -0
- package/config/models/live2d.assetbundls.json +1 -0
- package/config/models/live2d.azurlane.json +1 -0
- package/config/models/live2d.azurlane_bundle.json +1 -0
- package/config/models/live2d.azurlane_spine.json +1 -0
- package/config/models/live2d.bd2.json +1 -0
- package/config/models/live2d.bluearchive.json +1 -0
- package/config/models/live2d.live2d.json +1 -0
- package/config/models/live2d.nikke.json +1 -0
- package/config/models/live2d.nikke_2.json +1 -0
- package/config/models/live2d.npc.json +1 -0
- package/config/models/spine.assetbundls.json +1 -0
- package/config/models/spine.azurlane.json +1 -0
- package/config/models/spine.azurlane_bundle.json +1 -0
- package/config/models/spine.azurlane_spine.json +1 -0
- package/config/models/spine.bd2.json +1 -0
- package/config/models/spine.bluearchive.json +1 -0
- package/config/models/spine.live2d.json +1 -0
- package/config/models/spine.nikke.json +1 -0
- package/config/models/spine.nikke_2.json +1 -0
- package/config/models/spine.npc.json +1 -0
- package/config/names/NIKKE.json +1058 -0
- package/config/names/README.md +47 -0
- package/config/names//345/244/251/345/221/275/344/271/213/345/255/220.json +42897 -0
- package/config/names//346/243/225/350/211/262/345/260/230/345/237/2032.json +4016 -0
- package/config/names//347/242/247/350/223/235/350/210/252/347/272/277.json +38342 -0
- package/config/plugins.json +13 -0
- package/config/resources.example.json +40 -0
- package/package.json +58 -4
- package/plugins/unity-live2d-bundle/README.md +98 -0
- package/plugins/unity-live2d-bundle/index.js +891 -0
- package/plugins/unity-live2d-bundle/lib/anim-to-motion.js +402 -0
- package/plugins/unity-live2d-bundle/lib/bundle-probe.js +244 -0
- package/plugins/unity-live2d-bundle/lib/bytes.js +189 -0
- package/plugins/unity-live2d-bundle/lib/common-strings.js +46 -0
- package/plugins/unity-live2d-bundle/lib/dxt.js +197 -0
- package/plugins/unity-live2d-bundle/lib/errors.js +71 -0
- package/plugins/unity-live2d-bundle/lib/etc2.js +371 -0
- package/plugins/unity-live2d-bundle/lib/extract.js +242 -0
- package/plugins/unity-live2d-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
- package/plugins/unity-live2d-bundle/lib/lzma-d-min.js +34 -0
- package/plugins/unity-live2d-bundle/lib/model3.js +137 -0
- package/plugins/unity-live2d-bundle/lib/png.js +424 -0
- package/plugins/unity-live2d-bundle/lib/serialized.js +233 -0
- package/plugins/unity-live2d-bundle/lib/texdecode.js +190 -0
- package/plugins/unity-live2d-bundle/lib/typetree.js +215 -0
- package/plugins/unity-live2d-bundle/lib/unityfs.js +396 -0
- package/plugins/unity-live2d-bundle/plugin.json +81 -0
- package/plugins/unity-live2d-bundle/profiles/unityfs-live2d.json +26 -0
- package/plugins/unity-spine-bundle/index.js +535 -0
- package/plugins/unity-spine-bundle/lib/bytes.js +189 -0
- package/plugins/unity-spine-bundle/lib/common-strings.js +46 -0
- package/plugins/unity-spine-bundle/lib/dxt.js +197 -0
- package/plugins/unity-spine-bundle/lib/errors.js +79 -0
- package/plugins/unity-spine-bundle/lib/etc2.js +371 -0
- package/plugins/unity-spine-bundle/lib/extract.js +242 -0
- package/plugins/unity-spine-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
- package/plugins/unity-spine-bundle/lib/lzma-d-min.js +34 -0
- package/plugins/unity-spine-bundle/lib/png.js +424 -0
- package/plugins/unity-spine-bundle/lib/serialized.js +233 -0
- package/plugins/unity-spine-bundle/lib/texdecode.js +190 -0
- package/plugins/unity-spine-bundle/lib/typetree.js +215 -0
- package/plugins/unity-spine-bundle/lib/unityfs.js +396 -0
- package/plugins/unity-spine-bundle/plugin.json +75 -0
- package/src/server/README.md +69 -0
- package/src/server/assets/browse.mjs +56 -0
- package/src/server/assets/bundle-probe.mjs +94 -0
- package/src/server/assets/probe.mjs +184 -0
- package/src/server/core/config.mjs +173 -0
- package/src/server/core/env-num.mjs +60 -0
- package/src/server/core/env.mjs +49 -0
- package/src/server/core/fs-portable.mjs +37 -0
- package/src/server/core/log.mjs +107 -0
- package/src/server/core/paths.mjs +44 -0
- package/src/server/data/catalog.mjs +1091 -0
- package/src/server/data/entry-states.mjs +499 -0
- package/src/server/data/live2d.mjs +272 -0
- package/src/server/data/manifest-cache.mjs +58 -0
- package/src/server/data/manifest-stamp.mjs +135 -0
- package/src/server/data/model-meta.mjs +114 -0
- package/src/server/data/names.mjs +106 -0
- package/src/server/data/shape-probe-worker.mjs +33 -0
- package/src/server/data/shape-probe.mjs +626 -0
- package/src/server/data/spine.mjs +336 -0
- package/src/server/data/tree.mjs +126 -0
- package/src/server/http/atomic-json.mjs +145 -0
- package/src/server/http/background-solids.mjs +270 -0
- package/src/server/http/backgrounds.mjs +388 -0
- package/src/server/http/diag-upload.mjs +264 -0
- package/src/server/http/diag.mjs +173 -0
- package/src/server/http/ready.mjs +167 -0
- package/src/server/http/static.mjs +312 -0
- package/src/server/http/write.mjs +647 -0
- package/src/server/index.mjs +114 -0
- package/src/server/plugins/builtin-providers.json +30 -0
- package/src/server/plugins/registry.mjs +650 -0
- package/src/server/plugins/slot-catalog.mjs +286 -0
- package/src/server/routes/background-solids.mjs +56 -0
- package/src/server/routes/backgrounds.mjs +77 -0
- package/src/server/routes/config.mjs +38 -0
- package/src/server/routes/data.mjs +313 -0
- package/src/server/routes/diag.mjs +37 -0
- package/src/server/routes/entries.mjs +170 -0
- package/src/server/routes/ready.mjs +122 -0
- package/src/server/serve.mjs +334 -0
- package/src/server/unpack/capacity.mjs +217 -0
- package/src/server/unpack/mem-probe.mjs +27 -0
- package/src/server/unpack/plugin-runner.mjs +529 -0
- package/src/server/unpack/plugin-store.mjs +736 -0
- package/src/server/unpack/probe-store.mjs +73 -0
- package/src/server/unpack/routes.mjs +359 -0
- package/src/server/unpack/runtime/exec.mjs +558 -0
- package/src/server/unpack/runtime/index.mjs +141 -0
- package/src/server/unpack/runtime/node-vm.mjs +111 -0
- package/src/server/unpack/unpack-worker.mjs +105 -0
- package/src/server/unpack/worker-client.mjs +184 -0
- package/src/server/unpack/write-result.mjs +84 -0
- package/src/shared/bundle-shape.mjs +83 -0
- package/src/shared/entry-reasons.mjs +119 -0
- package/src/shared/names.mjs +45 -0
- package/src/web/README.md +193 -0
- package/src/web/app/anim-list.js +222 -0
- package/src/web/app/background-solids.js +150 -0
- package/src/web/app/backgrounds.js +152 -0
- package/src/web/app/camera-log.js +28 -0
- package/src/web/app/context.js +72 -0
- package/src/web/app/dir-picker.js +242 -0
- package/src/web/app/entry-report.js +71 -0
- package/src/web/app/memory-bars.js +91 -0
- package/src/web/app/memory-budget.js +228 -0
- package/src/web/app/memory-guard.js +204 -0
- package/src/web/app/model-actions.js +682 -0
- package/src/web/app/renderer-setting.js +70 -0
- package/src/web/app/root-manager.js +254 -0
- package/src/web/app/status.js +138 -0
- package/src/web/app/topbar-actions.js +357 -0
- package/src/web/app/transport-actions.js +297 -0
- package/src/web/app/ui-state.js +559 -0
- package/src/web/app/url-params.js +171 -0
- package/src/web/app/view-settings.js +186 -0
- package/src/web/components.css +465 -0
- package/src/web/debug/diag-hud.js +95 -0
- package/src/web/debug/drag.js +369 -0
- package/src/web/debug/log.js +320 -0
- package/src/web/debug/model-cmp.js +292 -0
- package/src/web/debug/panel.css +474 -0
- package/src/web/debug/panel.js +712 -0
- package/src/web/debug/race-probe.js +82 -0
- package/src/web/debug/upload.js +130 -0
- package/src/web/engine/adapters/atlas.js +47 -0
- package/src/web/engine/adapters/index.js +22 -0
- package/src/web/engine/adapters/renderer.js +223 -0
- package/src/web/engine/adapters/skeleton.js +105 -0
- package/src/web/engine/adapters/texture.js +64 -0
- package/src/web/engine/animation.js +181 -0
- package/src/web/engine/appearance.js +136 -0
- package/src/web/engine/camera-adapter.js +212 -0
- package/src/web/engine/effect-chain.js +138 -0
- package/src/web/engine/engine-state.js +120 -0
- package/src/web/engine/filters.js +224 -0
- package/src/web/engine/fit.js +197 -0
- package/src/web/engine/frame-gate.js +33 -0
- package/src/web/engine/gl-driver.js +56 -0
- package/src/web/engine/gl.js +77 -0
- package/src/web/engine/index.js +20 -0
- package/src/web/engine/loader.js +55 -0
- package/src/web/engine/model-info.js +26 -0
- package/src/web/engine/pipeline.js +200 -0
- package/src/web/engine/player.js +427 -0
- package/src/web/engine/pma/blend.js +76 -0
- package/src/web/engine/pma/detect.js +118 -0
- package/src/web/engine/pma/index.js +8 -0
- package/src/web/engine/pma/resolve.js +112 -0
- package/src/web/engine/pma/upload.js +127 -0
- package/src/web/engine/pma.js +5 -0
- package/src/web/engine/render-frame.js +93 -0
- package/src/web/engine/runtime-capabilities.js +209 -0
- package/src/web/engine/runtime.js +37 -0
- package/src/web/engine/skins.js +165 -0
- package/src/web/engine/textures.js +174 -0
- package/src/web/index.html +115 -0
- package/src/web/infra/api.js +531 -0
- package/src/web/infra/diag.js +509 -0
- package/src/web/infra/document.js +269 -0
- package/src/web/infra/dom.js +31 -0
- package/src/web/infra/events.mjs +27 -0
- package/src/web/infra/log.js +153 -0
- package/src/web/infra/mem-stats.js +112 -0
- package/src/web/infra/plugin-host-registration.mjs +285 -0
- package/src/web/infra/plugin-host-report.mjs +32 -0
- package/src/web/infra/plugin-host-unload.mjs +79 -0
- package/src/web/infra/plugin-host-unpack.mjs +899 -0
- package/src/web/infra/plugin-host-warmup.mjs +180 -0
- package/src/web/infra/plugin-host.js +628 -0
- package/src/web/infra/ready.js +461 -0
- package/src/web/infra/schedule.js +51 -0
- package/src/web/infra/shortcuts.js +129 -0
- package/src/web/infra/slot-registry.js +402 -0
- package/src/web/infra/vfs.js +247 -0
- package/src/web/live2d/bridge.js +400 -0
- package/src/web/live2d/model.js +321 -0
- package/src/web/live2d/overlay.js +142 -0
- package/src/web/live2d/pixi-app.js +111 -0
- package/src/web/live2d/playback-probe.js +92 -0
- package/src/web/live2d/probe-geometry.js +329 -0
- package/src/web/live2d/probe.js +119 -0
- package/src/web/live2d/release.js +66 -0
- package/src/web/live2d/runtime-adapter.js +289 -0
- package/src/web/live2d/stage-ctx.js +152 -0
- package/src/web/live2d/stage.js +204 -0
- package/src/web/live2d/vfs-settings.js +174 -0
- package/src/web/live2d/view.js +89 -0
- package/src/web/main.js +629 -0
- package/src/web/panel/background-list.js +373 -0
- package/src/web/panel/bg-source.js +291 -0
- package/src/web/panel/color-picker.css +199 -0
- package/src/web/panel/color-picker.js +278 -0
- package/src/web/panel/dialog.css +116 -0
- package/src/web/panel/dialog.js +173 -0
- package/src/web/panel/dom.js +35 -0
- package/src/web/panel/icons.js +58 -0
- package/src/web/panel/memory-bar.js +195 -0
- package/src/web/panel/panel-settings.js +1263 -0
- package/src/web/panel/panel.css +1304 -0
- package/src/web/panel/render-choice.js +104 -0
- package/src/web/panel/schema.js +784 -0
- package/src/web/panel/settings-io.js +291 -0
- package/src/web/panel/shell-controls.js +274 -0
- package/src/web/panel/slider.js +210 -0
- package/src/web/panel/toolbar-schema.js +111 -0
- package/src/web/panel/toolbar.js +195 -0
- package/src/web/panel/transport-picker.js +375 -0
- package/src/web/panel/transport.js +321 -0
- package/src/web/panel/view.js +553 -0
- package/src/web/plugin/unpack-worker.js +185 -0
- package/src/web/render/camera/constants.js +60 -0
- package/src/web/render/camera/index.js +123 -0
- package/src/web/render/camera/spine.js +124 -0
- package/src/web/render/capture.js +30 -0
- package/src/web/render/interaction.js +527 -0
- package/src/web/render/live2d.js +627 -0
- package/src/web/render/playback.js +47 -0
- package/src/web/render/reset-view.js +29 -0
- package/src/web/render/spine.js +386 -0
- package/src/web/render/surface-switch.js +176 -0
- package/src/web/render/surface.js +571 -0
- package/src/web/render/surfaces.js +70 -0
- package/src/web/roster/filter.js +31 -0
- package/src/web/roster/list.js +276 -0
- package/src/web/roster/persist.js +52 -0
- package/src/web/roster/reveal.js +77 -0
- package/src/web/roster/rows.js +248 -0
- package/src/web/roster/snapshot.js +37 -0
- package/src/web/roster/view.js +670 -0
- package/src/web/spine/canvas-api-groups.js +267 -0
- package/src/web/spine/canvas-api.js +97 -0
- package/src/web/spine/canvas-document.js +80 -0
- package/src/web/spine/canvas-engine.js +193 -0
- package/src/web/spine/canvas-entry.js +28 -0
- package/src/web/spine/canvas-pixels.js +36 -0
- package/src/web/spine/canvas-runtime.js +127 -0
- package/src/web/spine/canvas-view.js +108 -0
- package/src/web/spine/canvas.js +7 -0
- package/src/web/spine/runtime-hint.js +73 -0
- package/src/web/spine/runtimes.js +212 -0
- package/src/web/style.css +493 -0
- package/tool/lib/bundle-probe-node.mjs +121 -0
- package/tool/lib/check-perms.mjs +114 -0
- package/tool/lib/contract-checks.mjs +582 -0
- package/tool/lib/cubism-core.mjs +122 -0
- package/tool/lib/export-bundle.mjs +328 -0
- package/tool/lib/paths.mjs +20 -0
- package/tool/lib/runtime-guides.mjs +65 -0
- package/tool/lib/runtime-package.mjs +403 -0
- package/tool/lib/spine-meta.mjs +227 -0
- package/tool/lib/spine-motions.mjs +104 -0
- package/tool/lib/spine-runtime.mjs +131 -0
- package/tool/lib/test-lock.mjs +178 -0
- package/tool/lib/test-report.mjs +133 -0
- package/tool/parse-jsonc.mjs +86 -0
- package/tool/resolve-live2d-runtimes.mjs +276 -0
- package/tool/spine-motion-rules.json +101 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// tool/lib/spine-motions.mjs —— Spine 动作名的「分组 / 循环 / 置信度」判定(纯函数)
|
|
3
|
+
//
|
|
4
|
+
// 为什么独立成纯函数 + 数据文件:
|
|
5
|
+
// Spine 的动作命名规则因游戏而异(bd2 与碧蓝航线画风/命名都不同),写死在代码里
|
|
6
|
+
// 既难维护、也会绑定到某个前端。能力层应当只提供
|
|
7
|
+
// 「规则数据 + 判定函数」,由调用方(新前端/导出工具)自行决定怎么用。
|
|
8
|
+
//
|
|
9
|
+
// 判定优先级:overrides(精确名) → families(正则族,按声明顺序首个命中) → defaults
|
|
10
|
+
// 置信度:high = 规则显式匹配且有实测样本;low = 未命中任何规则(交给调用方保守处理)
|
|
11
|
+
// ============================================================================
|
|
12
|
+
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
|
|
15
|
+
/** 读取规则文件(调用方可用自己的路径) */
|
|
16
|
+
export function loadRules(file) {
|
|
17
|
+
const text = fs.readFileSync(file, 'utf8').replace(/^\s*\/\/.*$/gm, ''); // 允许 // 注释
|
|
18
|
+
const rules = JSON.parse(text);
|
|
19
|
+
validateRules(rules);
|
|
20
|
+
return rules;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 校验规则结构(失败抛可读错误,避免"规则写错了却静默不生效")。
|
|
25
|
+
* 不做业务判断,只保证字段存在、类型正确、正则可编译。
|
|
26
|
+
*/
|
|
27
|
+
export function validateRules(rules) {
|
|
28
|
+
if (!rules || typeof rules !== 'object') throw new Error('规则文件不是对象');
|
|
29
|
+
if (!rules.defaults || typeof rules.defaults.loop !== 'boolean') throw new Error('规则缺少 defaults.loop(布尔)');
|
|
30
|
+
if (!rules.defaults.confidence) throw new Error('规则缺少 defaults.confidence');
|
|
31
|
+
const fams = rules.families;
|
|
32
|
+
if (!Array.isArray(fams)) throw new Error('规则缺少 families 数组');
|
|
33
|
+
for (const [i, f] of fams.entries()) {
|
|
34
|
+
const at = `families[${i}]`;
|
|
35
|
+
if (!f || typeof f.group !== 'string' || !f.group) throw new Error(`${at}.group 必须是非空字符串`);
|
|
36
|
+
if (!Array.isArray(f.patterns) || !f.patterns.length) throw new Error(`${at}.patterns 必须是非空数组`);
|
|
37
|
+
for (const p of f.patterns) {
|
|
38
|
+
try { new RegExp(p); } catch (e) { throw new Error(`${at} 的正则非法 ${JSON.stringify(p)}:${e.message}`); }
|
|
39
|
+
}
|
|
40
|
+
if (typeof f.loop !== 'boolean') throw new Error(`${at}.loop 必须是布尔`);
|
|
41
|
+
if (typeof f.hits !== 'number') throw new Error(`${at}.hits 必须是数字(实测命中数)`);
|
|
42
|
+
if (typeof f.note !== 'string' || !f.note.trim()) throw new Error(`${at}.note 必须写明依据`);
|
|
43
|
+
}
|
|
44
|
+
if (rules.overrides !== undefined && !Array.isArray(rules.overrides)) throw new Error('overrides 必须是数组');
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 判定单个动作名。
|
|
50
|
+
* @param {string} name 动作名
|
|
51
|
+
* @param {object} rules loadRules 的结果
|
|
52
|
+
* @returns {{name:string, group:string, loop:boolean, confidence:'high'|'low', rule:string|null}}
|
|
53
|
+
*/
|
|
54
|
+
export function classify(name, rules) {
|
|
55
|
+
const n = String(name == null ? '' : name);
|
|
56
|
+
const ruleName = (r) => (r && r.id ? String(r.id) : null);
|
|
57
|
+
|
|
58
|
+
// ① 精确名覆盖(少量特例:名字一样但语义不同,比如 mission 与 mission_complete)
|
|
59
|
+
for (const o of (rules && rules.overrides) || []) {
|
|
60
|
+
if (o && o.name === n) {
|
|
61
|
+
return {
|
|
62
|
+
name: n,
|
|
63
|
+
group: o.group || n,
|
|
64
|
+
loop: typeof o.loop === 'boolean' ? o.loop : rules.defaults.loop,
|
|
65
|
+
confidence: o.confidence || 'high',
|
|
66
|
+
rule: ruleName(o) || `override:${n}`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// ② 正则族(按声明顺序,首个命中即用)
|
|
71
|
+
for (const f of (rules && rules.families) || []) {
|
|
72
|
+
for (const p of f.patterns) {
|
|
73
|
+
let hit = false;
|
|
74
|
+
try { hit = new RegExp(p).test(n); } catch { hit = false; }
|
|
75
|
+
if (hit) {
|
|
76
|
+
return { name: n, group: f.group, loop: !!f.loop, confidence: f.confidence || 'high', rule: ruleName(f) || `family:${f.group}` };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// ③ 兜底:分组=动作名本身,不循环,低置信(宁可一次性动作也不误判成循环)
|
|
81
|
+
return { name: n, group: n, loop: !!rules.defaults.loop, confidence: rules.defaults.confidence || 'low', rule: null };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 批量判定(保持输入顺序) */
|
|
85
|
+
export function classifyAll(names, rules) {
|
|
86
|
+
return (names || []).map((n) => classify(n, rules));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 统计规则命中情况(自检/报告用:证明每条规则都有实测样本) */
|
|
90
|
+
export function countHits(names, rules) {
|
|
91
|
+
const out = [];
|
|
92
|
+
for (const f of (rules && rules.families) || []) {
|
|
93
|
+
let n = 0;
|
|
94
|
+
for (const name of names || []) {
|
|
95
|
+
for (const p of f.patterns) {
|
|
96
|
+
let hit = false;
|
|
97
|
+
try { hit = new RegExp(p).test(String(name)); } catch { hit = false; }
|
|
98
|
+
if (hit) { n++; break; }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
out.push({ group: f.group, declaredHits: f.hits, actualHits: n, ok: f.hits === n });
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// tool/lib/spine-runtime.mjs —— Spine 运行时源码定位 + 在 Node 里受控加载
|
|
3
|
+
//
|
|
4
|
+
// 为什么要单独一层:Spine 的 .skel/.json 只能由**对应版本**的运行时解析,
|
|
5
|
+
// 而运行时代码是浏览器风格的打包体(会摸 document/window)。这里在 Node 的 vm 沙箱里
|
|
6
|
+
// 垫一个最小 DOM 垫片把它跑起来,只用来做**数据层解析**(不渲染、不碰 WebGL)。
|
|
7
|
+
//
|
|
8
|
+
// 源码来源(按优先级,逐条探测,命中即用):
|
|
9
|
+
// ① src/spine-runtimes/spine-webgl-<ver>.js —— 项目内(该目录不入库)
|
|
10
|
+
// ② ../spine-runtimes/spine-webgl-<ver>.js —— 工作区共享
|
|
11
|
+
// ③ 环境变量 SPINE_RUNTIME_DIR 指定的目录
|
|
12
|
+
//
|
|
13
|
+
// 没有内联兜底:运行时源码必须由外部提供,找不到时显式报错并给修复指引。
|
|
14
|
+
//
|
|
15
|
+
// 懒加载:首次真正要解析某版本时才读源码 + 建沙箱,之后按版本缓存;不在服务启动时加载。
|
|
16
|
+
// ============================================================================
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import vm from 'node:vm';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
|
|
23
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
25
|
+
const WORKSPACE = path.resolve(ROOT, '..');
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 数据根:与 web.sh / serve.mjs 同一口径(MODELSTAGE_DATA_ROOT 覆盖,缺省项目根)。
|
|
29
|
+
* 下载物落在 <dataRoot>/runtimes/spine/<ver>/spine-webgl-<ver>.js。
|
|
30
|
+
* @returns {string}
|
|
31
|
+
*/
|
|
32
|
+
function dataRootDir() {
|
|
33
|
+
const env = (process.env.MODELSTAGE_DATA_ROOT || '').trim();
|
|
34
|
+
return env ? path.resolve(env) : ROOT;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 运行时源码候选目录(按优先级) */
|
|
38
|
+
const RUNTIME_DIRS = [
|
|
39
|
+
path.join(ROOT, 'src', 'spine-runtimes'),
|
|
40
|
+
path.join(WORKSPACE, 'spine-runtimes'),
|
|
41
|
+
// **末尾追加**(上面两条的既有优先次序不动):tool/fetch-runtimes.mjs 的下载落点。
|
|
42
|
+
// 必须放最后 —— "项目自带优先"不能因为引入下载功能而静默失效。
|
|
43
|
+
path.join(dataRootDir(), 'runtimes', 'spine'),
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
const sourceCache = new Map(); // version → { code, origin }
|
|
49
|
+
/**
|
|
50
|
+
* 取某版本的运行时源码。
|
|
51
|
+
* @param {string} version 如 '3.8' / '4.2'
|
|
52
|
+
* @returns {{code:string, origin:string}|null}
|
|
53
|
+
*/
|
|
54
|
+
export function getRuntimeSource(version) {
|
|
55
|
+
if (sourceCache.has(version)) return sourceCache.get(version);
|
|
56
|
+
let hit = null;
|
|
57
|
+
for (const dir of RUNTIME_DIRS) {
|
|
58
|
+
// 两种布局都要认:
|
|
59
|
+
// 平铺 <dir>/spine-webgl-<ver>.js —— 项目自带 / 环境变量目录的既有布局
|
|
60
|
+
// 分版本 <dir>/<ver>/spine-webgl-<ver>.js —— tool/fetch-runtimes.mjs 的下载布局
|
|
61
|
+
// (分版本目录是为了让每个版本各自带 LICENSE 与 SOURCE.json)
|
|
62
|
+
for (const p of [path.join(dir, `spine-webgl-${version}.js`), path.join(dir, version, `spine-webgl-${version}.js`)]) {
|
|
63
|
+
if (fs.existsSync(p)) { hit = { code: fs.readFileSync(p, 'utf8'), origin: p }; break; }
|
|
64
|
+
}
|
|
65
|
+
if (hit) break;
|
|
66
|
+
}
|
|
67
|
+
if (!hit && process.env.SPINE_RUNTIME_DIR) {
|
|
68
|
+
const p = path.join(process.env.SPINE_RUNTIME_DIR, `spine-webgl-${version}.js`);
|
|
69
|
+
if (fs.existsSync(p)) hit = { code: fs.readFileSync(p, 'utf8'), origin: p };
|
|
70
|
+
}
|
|
71
|
+
sourceCache.set(version, hit);
|
|
72
|
+
return hit;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 可用版本列表(扫描候选目录;平铺与分版本两种布局都算) */
|
|
76
|
+
export function listRuntimeVersions() {
|
|
77
|
+
const out = new Set();
|
|
78
|
+
const addFrom = (dir) => {
|
|
79
|
+
for (const f of fs.readdirSync(dir)) {
|
|
80
|
+
const m = f.match(/^spine-webgl-(.+)\.js$/);
|
|
81
|
+
if (m) out.add(m[1]);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
for (const dir of RUNTIME_DIRS) {
|
|
85
|
+
if (!fs.existsSync(dir)) continue;
|
|
86
|
+
addFrom(dir);
|
|
87
|
+
// 分版本子目录(下载布局)也要列出来
|
|
88
|
+
let subs = [];
|
|
89
|
+
try { subs = fs.readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name); } catch { /* 跳过 */ }
|
|
90
|
+
for (const sub of subs) {
|
|
91
|
+
try { addFrom(path.join(dir, sub)); } catch { /* 跳过读不了的子目录 */ }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return [...out].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const sandboxCache = new Map(); // version → { spine, origin }
|
|
98
|
+
/**
|
|
99
|
+
* 在 vm 沙箱里加载某版本运行时,返回其导出的 spine 对象(懒加载 + 缓存)。
|
|
100
|
+
* 沙箱只垫数据层需要的最小 DOM 能力:createElement 返回带 getContext()=null 的假 canvas,
|
|
101
|
+
* 足以让运行时完成模块初始化,但任何真正的渲染调用都会失败 —— 这正是我们要的边界。
|
|
102
|
+
*/
|
|
103
|
+
export function loadRuntime(version) {
|
|
104
|
+
if (sandboxCache.has(version)) return sandboxCache.get(version);
|
|
105
|
+
const src = getRuntimeSource(version);
|
|
106
|
+
if (!src) throw new Error(`没有可用的 Spine ${version} 运行时源码。请任选一种方式提供:SPINE_RUNTIME_DIR 环境变量 / src/spine-runtimes/ / 工作区共享 ../spine-runtimes/`);
|
|
107
|
+
const sandbox = {
|
|
108
|
+
console: { log() {}, info() {}, warn() {}, error() {}, debug() {} },
|
|
109
|
+
performance: { now: () => Date.now() },
|
|
110
|
+
navigator: { userAgent: 'node-spine-meta' },
|
|
111
|
+
setTimeout, clearTimeout, setInterval, clearInterval,
|
|
112
|
+
TextDecoder, TextEncoder,
|
|
113
|
+
URL, Blob,
|
|
114
|
+
document: {
|
|
115
|
+
createElement: () => ({ getContext: () => null, style: {}, addEventListener() {}, width: 0, height: 0 }),
|
|
116
|
+
addEventListener() {}, removeEventListener() {},
|
|
117
|
+
},
|
|
118
|
+
addEventListener() {}, removeEventListener() {},
|
|
119
|
+
};
|
|
120
|
+
sandbox.window = sandbox;
|
|
121
|
+
sandbox.self = sandbox;
|
|
122
|
+
sandbox.globalThis = sandbox;
|
|
123
|
+
vm.createContext(sandbox);
|
|
124
|
+
vm.runInContext(src.code, sandbox, { filename: `spine-${version}.js` });
|
|
125
|
+
if (!sandbox.spine) throw new Error(`Spine ${version} 运行时加载后没有导出 spine 对象(源码来源:${src.origin})`);
|
|
126
|
+
const hit = { spine: sandbox.spine, origin: src.origin };
|
|
127
|
+
sandboxCache.set(version, hit);
|
|
128
|
+
return hit;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export const PATHS = { ROOT, WORKSPACE, RUNTIME_DIRS };
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// tool/lib/test-lock.mjs —— 测试运行的「单实例锁」(同一项目同时只允许一个测试实例)
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要:两个人(或两个 AI 会话)同时跑 e2e 会互相踩——端口/共享浏览器/DPR/像素采样都会串味,
|
|
4
|
+
// 结果是"我这边红你那边绿"扯不清。互斥之后,第二个实例**明确跳过并说清谁在跑**,
|
|
5
|
+
// 而不是继续跑出假红(假红比不跑更贵:会误导修错地方)。
|
|
6
|
+
//
|
|
7
|
+
// 约定:
|
|
8
|
+
// · 锁文件 <repoRoot>/runtime/test.lock,内容 JSON:{ pid, startedAt, startedAtMs, host, label, tool }
|
|
9
|
+
// · 第二次获取失败 → 退出码 75(EX_TEMPFAIL,区别于 0 通过 / 1 失败),并打印持有者与已运行时长
|
|
10
|
+
// · 陈旧锁自动接管:持有者 PID 已不存在 ⇒ 直接接管(崩溃/被 kill 不该永久锁死项目)
|
|
11
|
+
// · 子进程继承:调用方设置 DSH_TEST_LOCK_HELD=1,子工具(如 check-console)不再重复加锁
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
|
|
16
|
+
/** "跳过"专用退出码(BSD EX_TEMPFAIL):与 0 通过 / 1 失败明确区分 */
|
|
17
|
+
export const EXIT_SKIPPED = 75;
|
|
18
|
+
|
|
19
|
+
/** 子进程继承用的环境变量名 */
|
|
20
|
+
export const HELD_ENV = 'DSH_TEST_LOCK_HELD';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* PID 是否还活着。
|
|
24
|
+
* @param {number} pid
|
|
25
|
+
* @returns {boolean}
|
|
26
|
+
*/
|
|
27
|
+
export function isAlive(pid) {
|
|
28
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
29
|
+
try {
|
|
30
|
+
process.kill(pid, 0); // 只探测,不发信号
|
|
31
|
+
return true;
|
|
32
|
+
} catch (e) {
|
|
33
|
+
return e && e.code === 'EPERM'; // 存在但无权限 ⇒ 视为活着
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 进程的真实启动时刻(Linux /proc)。
|
|
39
|
+
* 用来识别 **PID 复用**:操作系统回收 PID 后会把它分配给新进程,此时 `kill(pid,0)` 依然成功,
|
|
40
|
+
* 光看 isAlive 会以为锁还被持有 ⇒ 项目被永久锁死。判据:进程启动时刻**晚于**锁创建时刻 ⇒
|
|
41
|
+
* 它不可能是锁的持有者。
|
|
42
|
+
* @returns {number|null} epoch 毫秒;非 Linux 或读取失败返回 null(退回旧行为)
|
|
43
|
+
*/
|
|
44
|
+
export function procStartMs(pid) {
|
|
45
|
+
try {
|
|
46
|
+
const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
47
|
+
// comm 字段可能含空格与括号(形如 (chrome-headless),也可能含右括号)⇒ 取最后一个 ')' 之后
|
|
48
|
+
const rest = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
|
|
49
|
+
const startTicks = Number(rest[19]); // 第 22 个字段 starttime(pid/comm/state 之后从 0 数)
|
|
50
|
+
const btime = Number((fs.readFileSync('/proc/stat', 'utf8').match(/^btime (\d+)$/m) || [])[1]);
|
|
51
|
+
if (!Number.isFinite(startTicks) || !Number.isFinite(btime)) return null;
|
|
52
|
+
const CLK_TCK = 100; // 常见值;仅用于量级比较,不做精确计时
|
|
53
|
+
return btime * 1000 + (startTicks * 1000) / CLK_TCK;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 把锁内容渲染成一句人话(谁在跑 / PID / 开始时间 / 已运行多久)。
|
|
61
|
+
* @param {object} holder
|
|
62
|
+
* @returns {string}
|
|
63
|
+
*/
|
|
64
|
+
export function describeHolder(holder) {
|
|
65
|
+
if (!holder) return '(未知持有者)';
|
|
66
|
+
const startedMs = Number(holder.startedAtMs) || 0;
|
|
67
|
+
const elapsed = startedMs ? Math.max(0, Math.round((Date.now() - startedMs) / 1000)) : null;
|
|
68
|
+
const dur = elapsed === null ? '时长未知'
|
|
69
|
+
: (elapsed < 60 ? `${elapsed}s` : `${Math.floor(elapsed / 60)}m${String(elapsed % 60).padStart(2, '0')}s`);
|
|
70
|
+
return `PID ${holder.pid} · 开始 ${holder.startedAt || '?'} · 已运行 ${dur} · ${holder.label || '?'} · ${holder.tool || '?'}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 读锁文件;不存在或损坏返回 null */
|
|
74
|
+
function readLock(file) {
|
|
75
|
+
try {
|
|
76
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
77
|
+
const j = JSON.parse(raw);
|
|
78
|
+
return j && typeof j === 'object' ? j : null;
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 原子创建:openSync('wx') 成功才算拿到锁(避免两个进程同时判"没有锁"再互相覆盖) */
|
|
85
|
+
function createExclusive(file, body) {
|
|
86
|
+
try {
|
|
87
|
+
const fd = fs.openSync(file, 'wx', 0o644);
|
|
88
|
+
fs.writeSync(fd, JSON.stringify(body, null, 2));
|
|
89
|
+
fs.closeSync(fd);
|
|
90
|
+
return true;
|
|
91
|
+
} catch (e) {
|
|
92
|
+
if (e && e.code === 'EEXIST') return false;
|
|
93
|
+
throw e;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 获取测试单实例锁。
|
|
99
|
+
* @param {{root: string, label?: string, tool?: string}} opts
|
|
100
|
+
* @returns {{acquired: boolean, holder?: object, file: string, tookOver?: boolean, release: Function}}
|
|
101
|
+
*/
|
|
102
|
+
export function acquireTestLock(opts) {
|
|
103
|
+
const root = opts.root;
|
|
104
|
+
const dir = path.join(root, 'runtime');
|
|
105
|
+
const file = path.join(dir, 'test.lock');
|
|
106
|
+
const body = {
|
|
107
|
+
pid: process.pid,
|
|
108
|
+
startedAt: new Date().toISOString(),
|
|
109
|
+
startedAtMs: Date.now(),
|
|
110
|
+
host: os.hostname(),
|
|
111
|
+
label: opts.label || 'test',
|
|
112
|
+
tool: opts.tool || process.argv.slice(1).join(' '),
|
|
113
|
+
};
|
|
114
|
+
const noop = { acquired: true, file, release: () => {} };
|
|
115
|
+
|
|
116
|
+
// 子进程:父进程已持锁 ⇒ 不再加锁(否则 test-all → check-console 会自己把自己锁死)
|
|
117
|
+
if (process.env[HELD_ENV] === '1') return { ...noop, inherited: true };
|
|
118
|
+
|
|
119
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
120
|
+
|
|
121
|
+
if (createExclusive(file, body)) {
|
|
122
|
+
return { acquired: true, file, release: makeRelease(file) };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 已被占用:判断持有者是否还活着
|
|
126
|
+
const holder = readLock(file);
|
|
127
|
+
let alive = holder && isAlive(Number(holder.pid));
|
|
128
|
+
let pidReused = false;
|
|
129
|
+
if (alive) {
|
|
130
|
+
// PID 复用检测:进程启动晚于锁创建 ⇒ 抢锁的进程早已退出,这个 PID 是新进程
|
|
131
|
+
const startMs = procStartMs(Number(holder.pid));
|
|
132
|
+
const lockMs = Number(holder.startedAtMs) || 0;
|
|
133
|
+
if (startMs && lockMs && startMs > lockMs + 2000) {
|
|
134
|
+
alive = false;
|
|
135
|
+
pidReused = true;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (alive) return { acquired: false, holder, file, release: () => {} };
|
|
139
|
+
|
|
140
|
+
// 陈旧锁(进程已死/文件损坏)⇒ 接管;崩溃不该把项目永久锁死
|
|
141
|
+
try {
|
|
142
|
+
fs.writeFileSync(file, JSON.stringify(body, null, 2), 'utf8');
|
|
143
|
+
return { acquired: true, file, tookOver: true, pidReused, holder, release: makeRelease(file) };
|
|
144
|
+
} catch (e) {
|
|
145
|
+
return { acquired: false, holder: holder || { pid: 0, label: `锁文件不可写:${e.message}` }, file, release: () => {} };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 释放:只删自己的锁(别人的锁不能被误删) */
|
|
150
|
+
function makeRelease(file) {
|
|
151
|
+
return () => {
|
|
152
|
+
const cur = readLock(file);
|
|
153
|
+
if (cur && Number(cur.pid) !== process.pid) return; // 已不是我的锁,不动
|
|
154
|
+
try {
|
|
155
|
+
fs.unlinkSync(file);
|
|
156
|
+
} catch {
|
|
157
|
+
/* 已删除 */
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** 注册进程退出/中断时的自动释放(含 SIGINT/SIGTERM:Ctrl-C 也要解锁) */
|
|
163
|
+
export function releaseOnExit(lock) {
|
|
164
|
+
if (!lock || !lock.acquired) return;
|
|
165
|
+
let done = false;
|
|
166
|
+
const bye = () => {
|
|
167
|
+
if (done) return;
|
|
168
|
+
done = true;
|
|
169
|
+
lock.release();
|
|
170
|
+
};
|
|
171
|
+
process.on('exit', bye);
|
|
172
|
+
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
173
|
+
process.on(sig, () => {
|
|
174
|
+
bye();
|
|
175
|
+
process.exit(EXIT_SKIPPED);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// tool/lib/test-report.mjs —— 回归结果落盘(每套一份 JSON + 本轮汇总 + latest 指针)
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要:以前跑完只看终端汇总,跑挂/超时后想回溯"哪套、什么错、当时内存多少"就得整轮重跑。
|
|
4
|
+
// 落盘后 AI 只读 `runtime/test-results/latest.json` 一个入口就能拿到全部结果。
|
|
5
|
+
//
|
|
6
|
+
// 一个关键能力:**把"真失败"和"超时被杀"分开**。
|
|
7
|
+
// 套件被 SIGKILL 时 failed/total 都是 null,以前会被报成"红"(假红),
|
|
8
|
+
// 实际情况是"跑太久被砍"。这类必须单独标出来,否则会让人去查根本不该查的业务代码。
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
/** 读系统可用内存(MB);拿不到返回 null(非 Linux 或 /proc 不可读) */
|
|
14
|
+
export function memAvailableMB() {
|
|
15
|
+
try {
|
|
16
|
+
// dsh-skip-quality 只读 /proc/meminfo(约 1~2KB 的固定格式伪文件),memory-bomb 不适用
|
|
17
|
+
const txt = fs.readFileSync('/proc/meminfo', 'utf8');
|
|
18
|
+
const m = /^MemAvailable:\s+(\d+)\s+kB$/m.exec(txt);
|
|
19
|
+
return m ? Math.round(Number(m[1]) / 1024) : null;
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 时间戳目录名:yyyyMMdd-HHmmss(本地时区,便于人对号) */
|
|
26
|
+
export function runStamp(date = new Date()) {
|
|
27
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
28
|
+
return `${date.getFullYear()}${p(date.getMonth() + 1)}${p(date.getDate())}-${
|
|
29
|
+
p(date.getHours())}${p(date.getMinutes())}${p(date.getSeconds())}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 取文本尾巴(行数上限),用于 stdoutTail/stderrTail 诊断 */
|
|
33
|
+
export function tailLines(text, maxLines = 200) {
|
|
34
|
+
const lines = String(text || '').split('\n');
|
|
35
|
+
return lines.slice(Math.max(0, lines.length - maxLines)).join('\n');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 判定一套的结局。**这是"超时 vs 真失败"的唯一判据**。
|
|
40
|
+
*
|
|
41
|
+
* @param {{result: object|null, status: number|null, signal: string|null, ms: number, limitMs: number}} p
|
|
42
|
+
* @returns {{status: string, hint: string}}
|
|
43
|
+
* ok 有结构化结果且 failed=0
|
|
44
|
+
* failed 有结构化结果且 failed>0(真断言失败)
|
|
45
|
+
* killed-by-timeout 没结果且被信号杀掉/退出码异常,且耗时≥上限(超时,不是断言失败)
|
|
46
|
+
* no-result 没结果但不是超时(脚本崩、chrome 起不来、输出不是 JSON…)
|
|
47
|
+
*/
|
|
48
|
+
export function classifyOutcome({ result, status, signal, ms, limitMs }) {
|
|
49
|
+
if (result && Number.isFinite(result.failed)) {
|
|
50
|
+
return result.failed > 0
|
|
51
|
+
? { status: 'failed', hint: `${result.failed} 项断言未通过` }
|
|
52
|
+
: { status: 'ok', hint: '' };
|
|
53
|
+
}
|
|
54
|
+
const nearLimit = Number.isFinite(limitMs) && ms >= limitMs * 0.9;
|
|
55
|
+
if (signal === 'SIGKILL' || status === 2 || (nearLimit && !result)) {
|
|
56
|
+
const how = signal ? `被 ${signal} 杀掉` : `退出码 ${status}`;
|
|
57
|
+
return {
|
|
58
|
+
status: 'killed-by-timeout',
|
|
59
|
+
hint: `套件跑了 ${ms}ms,接近/超过上限 ${limitMs}ms,${how} —— 提高超时或查它为什么慢(不是断言失败)`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return { status: 'no-result', hint: `没产出结构化结果(exitCode=${status}),看 stdoutTail/stderrTail` };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 递归建目录并写 JSON(原子写:先写 .tmp 再 rename,避免读到半个文件) */
|
|
66
|
+
function writeJson(file, value) {
|
|
67
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
68
|
+
const tmp = `${file}.tmp`;
|
|
69
|
+
fs.writeFileSync(tmp, `${JSON.stringify(value, null, 1)}\n`, 'utf8');
|
|
70
|
+
fs.renameSync(tmp, file);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 一套的落盘文件名(套件名已限定为 kebab-case,直接当文件名安全) */
|
|
74
|
+
function suiteFile(dir, suiteName) {
|
|
75
|
+
return path.join(dir, `${suiteName}.json`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 开一轮:建目录、写初始汇总(running=true)。
|
|
80
|
+
* @param {{outRoot: string, base: string, suites: string[], startedAt: string}} p
|
|
81
|
+
* @returns {{dir: string, summaryPath: string}}
|
|
82
|
+
*/
|
|
83
|
+
export function openRun({ outRoot, base, suites, startedAt }) {
|
|
84
|
+
const dir = path.join(outRoot, runStamp());
|
|
85
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
86
|
+
const summaryPath = path.join(dir, 'run-summary.json');
|
|
87
|
+
writeJson(summaryPath, {
|
|
88
|
+
startedAt,
|
|
89
|
+
finishedAt: null,
|
|
90
|
+
running: true, // 跑完置 false —— 外部可据此判断"还在跑吗"
|
|
91
|
+
exitCode: null,
|
|
92
|
+
base,
|
|
93
|
+
plannedSuites: suites,
|
|
94
|
+
suites: [],
|
|
95
|
+
env: { memAvailableMBAtStart: memAvailableMB(), node: process.version, pid: process.pid },
|
|
96
|
+
});
|
|
97
|
+
return { dir, summaryPath };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 单套写盘(每套跑完立刻写,后台跑中途被杀也留得下已跑部分) */
|
|
101
|
+
export function writeSuiteResult(dir, entry) {
|
|
102
|
+
writeJson(suiteFile(dir, entry.suite), entry);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 更新汇总(每套跑完调一次,保证 running=true 期间也能看到进度) */
|
|
106
|
+
export function updateSummary(summaryPath, patch) {
|
|
107
|
+
let prev = {};
|
|
108
|
+
try { prev = JSON.parse(fs.readFileSync(summaryPath, 'utf8')); } catch { prev = {}; }
|
|
109
|
+
writeJson(summaryPath, { ...prev, ...patch });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 收尾:写 finishedAt/exitCode/running=false,并把 `latest.json` 指过来。
|
|
114
|
+
* latest 里只放"去哪读",避免同一份数据写两处后漂移。
|
|
115
|
+
*/
|
|
116
|
+
export function finishRun({ outRoot, dir, summaryPath, exitCode }) {
|
|
117
|
+
updateSummary(summaryPath, {
|
|
118
|
+
finishedAt: new Date().toISOString(),
|
|
119
|
+
running: false,
|
|
120
|
+
exitCode,
|
|
121
|
+
env: { ...(readJson(summaryPath, {}).env || {}), memAvailableMBAtEnd: memAvailableMB() },
|
|
122
|
+
});
|
|
123
|
+
writeJson(path.join(outRoot, 'latest.json'), {
|
|
124
|
+
run: path.basename(dir),
|
|
125
|
+
summary: path.relative(outRoot, summaryPath),
|
|
126
|
+
finishedAt: new Date().toISOString(),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 读 JSON(失败返回默认值) */
|
|
131
|
+
export function readJson(file, fallback) {
|
|
132
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
|
|
133
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// parse-jsonc.mjs — 带注释的 JSON 解析(JSONC),零依赖
|
|
3
|
+
//
|
|
4
|
+
// 为什么需要:标准 JSON 不支持注释,但配置文件是人要反复手改的,没注释很难维护。
|
|
5
|
+
// 用 YAML 能原生支持注释,但要引入依赖(本项目刻意保持零依赖,见 zero-dep 规范)。
|
|
6
|
+
// 折中方案:JSON + 轻量注释剥离,既零依赖,又能写 // 与 /* */ 注释。
|
|
7
|
+
//
|
|
8
|
+
// 关键正确性要求(易踩坑):
|
|
9
|
+
// 剥离注释时必须**跳过字符串内部**,否则会误伤路径/URL 里的 // 或 /*,
|
|
10
|
+
// 例如 "path": "https://example.com/a//b" 里的 // 不是注释。
|
|
11
|
+
// 本实现逐字符扫描并跟踪字符串状态(含转义 \" 处理),只在字符串外剥离。
|
|
12
|
+
//
|
|
13
|
+
// 用法:
|
|
14
|
+
// const { parseJsonc } = await import('./parse-jsonc.mjs');
|
|
15
|
+
// const cfg = parseJsonc(fs.readFileSync(file, 'utf8'));
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 剥离 JSONC 文本中的注释(保留字符串原样)。
|
|
19
|
+
* @param {string} text 原始文本
|
|
20
|
+
* @returns {string} 可被 JSON.parse 解析的文本
|
|
21
|
+
*/
|
|
22
|
+
export function stripComments(text) {
|
|
23
|
+
let out = '';
|
|
24
|
+
let inStr = false;
|
|
25
|
+
let esc = false;
|
|
26
|
+
let i = 0;
|
|
27
|
+
const n = text.length;
|
|
28
|
+
|
|
29
|
+
while (i < n) {
|
|
30
|
+
const ch = text[i];
|
|
31
|
+
const next = text[i + 1];
|
|
32
|
+
|
|
33
|
+
if (inStr) {
|
|
34
|
+
out += ch;
|
|
35
|
+
if (esc) { esc = false; }
|
|
36
|
+
else if (ch === '\\') { esc = true; }
|
|
37
|
+
else if (ch === '"') { inStr = false; }
|
|
38
|
+
i++;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 字符串开始
|
|
43
|
+
if (ch === '"') { inStr = true; out += ch; i++; continue; }
|
|
44
|
+
|
|
45
|
+
// 行注释 //
|
|
46
|
+
if (ch === '/' && next === '/') {
|
|
47
|
+
while (i < n && text[i] !== '\n') i++;
|
|
48
|
+
continue; // 换行由下一轮原样输出,保持行号对齐
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 块注释 /* */
|
|
52
|
+
if (ch === '/' && next === '*') {
|
|
53
|
+
i += 2;
|
|
54
|
+
while (i < n && !(text[i] === '*' && text[i + 1] === '/')) i++;
|
|
55
|
+
i += 2; // 跳过结束符 */
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
out += ch;
|
|
60
|
+
i++;
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 解析 JSONC(先剥离注释再 JSON.parse)。
|
|
67
|
+
* @param {string} text 原始文本
|
|
68
|
+
* @param {string} [label] 报错时的文件名提示
|
|
69
|
+
* @returns {any} 解析结果
|
|
70
|
+
*/
|
|
71
|
+
export function parseJsonc(text, label = '配置') {
|
|
72
|
+
const cleaned = stripComments(text);
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(cleaned);
|
|
75
|
+
} catch (e) {
|
|
76
|
+
throw new Error(`${label}解析失败: ${e.message}(已剥离注释后再解析;若报错位置与原文不符,注意注释会改变行号)`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 直接运行:读文件并打印解析结果,便于排查配置
|
|
81
|
+
// 仅 CLI 自跑时执行(被 import 时此分支不会进入),故不走 logger
|
|
82
|
+
if (process.argv[1] && process.argv[1].endsWith('parse-jsonc.mjs') && process.argv[2]) {
|
|
83
|
+
const fs = await import('node:fs');
|
|
84
|
+
const cfg = parseJsonc(fs.readFileSync(process.argv[2], 'utf8'), process.argv[2]);
|
|
85
|
+
console.log(JSON.stringify(cfg, null, 2));
|
|
86
|
+
}
|