modelstage 0.5.24 → 0.5.28
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/README.md +7 -0
- package/bin/modelstage.mjs +48 -12
- package/config/models/live2d.azurlane.json +1 -1
- package/config/models/live2d.azurlane_spine.json +1 -1
- package/config/models/live2d.npc.json +1 -1
- package/config/models/spine.azurlane.json +1 -1
- package/config/models/spine.azurlane_spine.json +1 -1
- package/config/models/spine.npc.json +1 -1
- package/package.json +7 -2
- package/src/server/http/write.mjs +8 -1
- package/src/server/serve.mjs +62 -0
- package/src/shared/type-labels.mjs +31 -0
- package/src/web/components.css +59 -0
- package/src/web/index.html +2 -0
- package/src/web/roster/persist.js +40 -0
- package/src/web/roster/snapshot.js +2 -0
- package/src/web/roster/type-filter.js +97 -0
- package/src/web/roster/view.js +93 -2
- package/tool/lib/check-perms.mjs +0 -114
- package/tool/lib/contract-checks.mjs +0 -582
- package/tool/lib/cubism-core.mjs +0 -122
- package/tool/lib/export-bundle.mjs +0 -328
- package/tool/lib/spine-runtime.mjs +0 -131
- package/tool/lib/test-lock.mjs +0 -178
- package/tool/lib/test-report.mjs +0 -133
package/tool/lib/test-lock.mjs
DELETED
|
@@ -1,178 +0,0 @@
|
|
|
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
|
-
}
|
package/tool/lib/test-report.mjs
DELETED
|
@@ -1,133 +0,0 @@
|
|
|
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
|
-
}
|