@kira905/dsh-ecosystem-panel 0.4.5 → 0.5.0

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/lib/warmup.js ADDED
@@ -0,0 +1,131 @@
1
+ // dsh-ecosystem-panel / lib/warmup.js
2
+ //
3
+ // 周期预热:让首屏别再"每次都从零算"。
4
+ //
5
+ // 三条纪律(缺一条都会变成"悄悄后台常驻"):
6
+ // ① **缺省关闭** —— 预热是要花资源的后台动作,默认不替使用者决定。
7
+ // ② 开了就有自证 —— 开启 / 周期 / 预热哪些源(三个环境变量口 + 配置文件同名键),
8
+ // 以及"跑过几次、上次多久、上次每个源什么状态"全部进 SLI,页脚跟状态接口都看得到。
9
+ // ③ 周期缺省 = 300 s,与 TTL 同源(文档仓《06-只读监控面板的设计纪律》§3.2:
10
+ // 复用体系里既有的五分钟节奏,**不新增定时作业**)—— 预热只是把同一个 TTL 提前填好,
11
+ // 频率比 TTL 更密是白烧 CPU,更疏则首屏照样吃冷启动。
12
+ //
13
+ // 预热**不联网做额外的事**:它只调用面板本来就会调的那几个源(lane.prime),所以"预热改了行为"
14
+ // 这种风险不存在;最坏情况是它把某个慢源多读了几遍 —— 而读幂等、只读、无副作用。
15
+
16
+ /** 缺省预热周期 = 300 s(与 TTL 同源,见文件头) */
17
+ export const DEFAULT_WARMUP_INTERVAL_MS = 300000;
18
+ /** 预热周期下限:再密就纯属烧 CPU(一屏数据 300 s 才旧一次) */
19
+ export const MIN_WARMUP_INTERVAL_MS = 5000;
20
+ /**
21
+ * 缺省预热哪些源:**只挑纯本地、零网络的轻片**。
22
+ * boot / bundles / inserts / patches / skills 都是"读几个文件 + 抓一次本机 HTML";
23
+ * decoupling / upgrades / registry 要跑外部脚本或查 npm,**不进缺省预热**(要就自己点名)。
24
+ */
25
+ export const DEFAULT_WARMUP_SOURCES = ['boot', 'bundles', 'inserts', 'patches', 'skills'];
26
+
27
+ export const WARMUP_DEFAULTS = Object.freeze({
28
+ enabled: false,
29
+ intervalMs: DEFAULT_WARMUP_INTERVAL_MS,
30
+ sources: DEFAULT_WARMUP_SOURCES
31
+ });
32
+
33
+ function clampInterval(ms) {
34
+ const n = Number(ms);
35
+ if (!Number.isFinite(n) || n <= 0) return DEFAULT_WARMUP_INTERVAL_MS;
36
+ return Math.max(MIN_WARMUP_INTERVAL_MS, Math.round(n));
37
+ }
38
+
39
+ /**
40
+ * 起一个预热器。
41
+ * @param {object} opts
42
+ * @param {object} opts.lane fetch-lane 实例
43
+ * @param {object} [opts.cfg] 配置层解析结果(读 cfg.warmup)
44
+ * @param {(m:string)=>void} [opts.log]
45
+ * @returns {{ enabled:boolean, config:object, status:()=>object, primeNow:()=>Promise<object>,
46
+ * whenReady:()=>Promise<object|undefined>, stop:()=>void }}
47
+ */
48
+ export function startWarmup(opts = {}) {
49
+ const lane = opts.lane;
50
+ const log = typeof opts.log === 'function' ? opts.log : () => {};
51
+ const conf = { ...WARMUP_DEFAULTS, ...(opts.cfg?.warmup ?? {}) };
52
+ const enabled = conf.enabled === true;
53
+ const intervalMs = clampInterval(conf.intervalMs);
54
+ const sources = Array.isArray(conf.sources) && conf.sources.length ? conf.sources.slice() : DEFAULT_WARMUP_SOURCES.slice();
55
+
56
+ const state = {
57
+ enabled,
58
+ intervalMs,
59
+ sources,
60
+ runs: 0,
61
+ lastRunAt: null,
62
+ lastElapsedMs: null,
63
+ lastSources: null,
64
+ errors: 0,
65
+ lastError: null,
66
+ nextRunAt: null
67
+ };
68
+
69
+ if (!lane || !enabled) {
70
+ return {
71
+ enabled: false,
72
+ config: { enabled, intervalMs, sources },
73
+ status: () => ({ ...state, reason: lane ? `未开启(缺省关闭;开法:环境变量 WARMUP=1,见 README)` : '未接车道' }),
74
+ primeNow: async () => ({ skipped: true, reason: '预热未开启' }),
75
+ whenReady: async () => undefined,
76
+ stop: () => {}
77
+ };
78
+ }
79
+
80
+ const knownIds = typeof lane.ids === 'function' ? new Set(lane.ids()) : null;
81
+ const asked = sources.includes('*') ? (knownIds ? [...knownIds] : []) : sources;
82
+ const targets = knownIds ? asked.filter((id) => knownIds.has(id)) : asked;
83
+ state.sources = targets;
84
+
85
+ let timer = null;
86
+ let running = null;
87
+
88
+ const runOnce = async () => {
89
+ const startedAt = Date.now();
90
+ try {
91
+ const summary = await lane.prime(targets);
92
+ state.runs += 1;
93
+ state.lastRunAt = new Date().toISOString();
94
+ state.lastElapsedMs = Date.now() - startedAt;
95
+ state.lastSources = summary.sources;
96
+ log(`warmup #${state.runs}: ${targets.length} 源 / ${state.lastElapsedMs} ms`);
97
+ return summary;
98
+ } catch (error) {
99
+ state.errors += 1;
100
+ state.lastError = String(error?.message ?? error);
101
+ log(`warmup 失败:${state.lastError}`);
102
+ return { error: state.lastError };
103
+ } finally {
104
+ state.nextRunAt = new Date(Date.now() + intervalMs).toISOString();
105
+ }
106
+ };
107
+
108
+ const primeNow = () => {
109
+ if (running) return running; // 上一次还没回来就别叠着跑
110
+ running = runOnce().finally(() => { running = null; });
111
+ return running;
112
+ };
113
+
114
+ // 先填一次("预热生效"的证据就在这里:首屏请求到达时缓存已经热了),然后按周期续。
115
+ const first = primeNow();
116
+ timer = setInterval(() => { primeNow(); }, intervalMs);
117
+ if (typeof timer.unref === 'function') timer.unref();
118
+
119
+ return {
120
+ enabled: true,
121
+ config: { enabled, intervalMs, sources: targets },
122
+ status: () => ({ ...state, inFlight: running !== null }),
123
+ primeNow,
124
+ whenReady: () => first,
125
+ stop: () => {
126
+ if (timer) clearInterval(timer);
127
+ timer = null;
128
+ state.enabled = false;
129
+ }
130
+ };
131
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kira905/dsh-ecosystem-panel",
3
3
  "description": "Read-only ecosystem overview panel for a DSH instance: plugin bundles, cordis.patch inserts, patch health, skills, upgrade health and decoupling health. One tab per category. Zero runtime npm dependencies.",
4
- "version": "0.4.5",
4
+ "version": "0.5.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -31,6 +31,12 @@
31
31
  "scripts": {
32
32
  "verify": "node scripts/verify-source.mjs .",
33
33
  "test": "node scripts/test-fixture.mjs",
34
+ "test:swr": "node scripts/test-swr.mjs",
35
+ "test:warmup": "node scripts/test-warmup-negctl.mjs",
36
+ "test:singleflight": "node scripts/test-singleflight.mjs",
37
+ "test:degrade": "node scripts/test-degrade.mjs",
38
+ "test:all": "node scripts/test-fixture.mjs && node scripts/test-swr.mjs && node scripts/test-warmup-negctl.mjs && node scripts/test-singleflight.mjs && node scripts/test-degrade.mjs",
39
+ "sim": "node scripts/sim-selfcheck.mjs",
34
40
  "state": "node scripts/verify-state.mjs"
35
41
  },
36
42
  "peerDependencies": {
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env node
2
+ // scripts/sim-selfcheck.mjs —— 新文件「行级同源度」自查(本仓 K8 的硬约束判据)
3
+ //
4
+ // 干什么:对本仓**新增**的 .js/.mjs 逐个算「行级同源度」——
5
+ // 分子 = 这个新文件里有多少条非空行,能在参照件里找到(多重集扣减,重复行按次数算)
6
+ // 分母 = 这个新文件自己的非空行数
7
+ // 参照件 = ① 生产源里**同名**的文件(有就算,这是最直接的"近源"判据)
8
+ // ② 本仓既有的**其它** .js/.mjs(本仓既有件本身就是生产件的近源副本 ⇒ 拿它当参照
9
+ // 是**更严**的口径:新文件连自家老件都不能像)
10
+ // 主结论 = 取所有参照件里最高的那个比值。**< 50% 才算通过**。
11
+ //
12
+ // 一键跑(示例):
13
+ // node scripts/sim-selfcheck.mjs --since d069a79 # 自某个 commit 起新增的文件
14
+ // node scripts/sim-selfcheck.mjs --files lib/fetch-lane.js,lib/warmup.js
15
+ // node scripts/sim-selfcheck.mjs --since d069a79 --json # 机器可读
16
+ //
17
+ // 为什么不用"逐字节相同":删几行注释就判"不同源",会低估同源度 ⇒ 主结论用行级。
18
+ // 退出码:0 = 全部 < 50%;1 = 有文件 ≥ 50%(= 这条硬约束没过);2 = 用法错误。
19
+
20
+ import fs from 'node:fs';
21
+ import os from 'node:os';
22
+ import path from 'node:path';
23
+ import { spawnSync } from 'node:child_process';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
27
+ const REPO = path.resolve(process.argv.includes('--repo')
28
+ ? process.argv[process.argv.indexOf('--repo') + 1]
29
+ : path.join(HERE, '..'));
30
+
31
+ const arg = (name, fallback = null) => {
32
+ const i = process.argv.indexOf(name);
33
+ return i >= 0 ? (process.argv[i + 1] ?? fallback) : fallback;
34
+ };
35
+ const asJson = process.argv.includes('--json');
36
+ const since = arg('--since');
37
+ const filesArg = arg('--files');
38
+ const prodRoot = path.resolve(arg('--prod-root') ?? process.env.DSH_ROOT ?? path.join(os.homedir(), 'DSH'));
39
+ const CODE_EXT = new Set(['.js', '.mjs', '.cjs', '.ts']);
40
+
41
+ if (!since && !filesArg) {
42
+ console.error('用法:node scripts/sim-selfcheck.mjs --since <commit> | --files <a.js,b.js> [--repo <dir>] [--prod-root <dir>] [--json]');
43
+ process.exit(2);
44
+ }
45
+
46
+ const kebab = (p) => p.replace(/\\/g, '/');
47
+
48
+ /** 取新增文件清单(相对仓根) */
49
+ function newFiles() {
50
+ if (filesArg) {
51
+ return filesArg.split(',').map((s) => s.trim()).filter(Boolean).map((p) => path.resolve(REPO, p));
52
+ }
53
+ const r = spawnSync('git', ['-C', REPO, 'diff', '--name-only', '--diff-filter=A', `${since}..HEAD`, '--', '*.js', '*.mjs', '*.cjs'], { encoding: 'utf8' });
54
+ if (r.status !== 0) {
55
+ console.error('git diff 失败:' + (r.stderr || '').trim());
56
+ process.exit(2);
57
+ }
58
+ return r.stdout.split(/\r?\n/).filter(Boolean).map((p) => path.join(REPO, p));
59
+ }
60
+
61
+ /** 非空行(去掉首尾空白;纯分隔符行不算内容 —— 否则 `}` / `);` 会把同源度凭空抬上去) */
62
+ function codeLines(file) {
63
+ const text = fs.readFileSync(file, 'utf8');
64
+ return text.split(/\r?\n/)
65
+ .map((l) => l.trim())
66
+ .filter((l) => l.length > 0 && !/^[{}()[\];,]+$/.test(l));
67
+ }
68
+
69
+ /** 多重集扣减:返回 ref 里有多少条能在 target 里找到 */
70
+ function matchedCount(refLines, targetCounts) {
71
+ const pool = new Map(targetCounts);
72
+ let hit = 0;
73
+ for (const line of refLines) {
74
+ const left = pool.get(line) ?? 0;
75
+ if (left > 0) { pool.set(line, left - 1); hit += 1; }
76
+ }
77
+ return hit;
78
+ }
79
+
80
+ function countsOf(lines) {
81
+ const m = new Map();
82
+ for (const l of lines) m.set(l, (m.get(l) ?? 0) + 1);
83
+ return m;
84
+ }
85
+
86
+ const SKIP_DIRS = new Set(['node_modules', '.git', '_archive', 'logs', 'backups', 'snapshots', 'generated', '_tmp', 'docs', 'dist']);
87
+ /** 受限遍历:生产源只找同名件,所以按 basename 索引即可(跳过重目录) */
88
+ function indexByBasename(root, wantedNames) {
89
+ const found = new Map();
90
+ const stack = [[root, 0]];
91
+ while (stack.length) {
92
+ const [dir, depth] = stack.pop();
93
+ if (depth > 7) continue;
94
+ let entries;
95
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
96
+ for (const e of entries) {
97
+ const full = path.join(dir, e.name);
98
+ if (e.isDirectory()) {
99
+ if (SKIP_DIRS.has(e.name) || e.name.startsWith('.')) continue;
100
+ stack.push([full, depth + 1]);
101
+ } else if (e.isFile() && wantedNames.has(e.name)) {
102
+ if (!found.has(e.name)) found.set(e.name, []);
103
+ found.get(e.name).push(full);
104
+ }
105
+ }
106
+ }
107
+ return found;
108
+ }
109
+
110
+ const targets = newFiles().filter((f) => fs.existsSync(f) && CODE_EXT.has(path.extname(f)));
111
+ if (targets.length === 0) {
112
+ console.error('没有找到新增的代码文件(--since 给的 commit 之后没有新增 .js/.mjs?)');
113
+ process.exit(2);
114
+ }
115
+
116
+ // 参照件:生产源同名件 + 本仓其它已有件
117
+ const wanted = new Set(targets.map((f) => path.basename(f)));
118
+ const prodIndex = fs.existsSync(prodRoot) ? indexByBasename(prodRoot, wanted) : new Map();
119
+ const repoRefs = [];
120
+ {
121
+ const stack = [[REPO, 0]];
122
+ while (stack.length) {
123
+ const [dir, depth] = stack.pop();
124
+ if (depth > 5) continue;
125
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
126
+ const full = path.join(dir, e.name);
127
+ if (e.isDirectory()) {
128
+ if (['node_modules', '.git', '_archive', '_tmp'].includes(e.name) || e.name.startsWith('.')) continue;
129
+ stack.push([full, depth + 1]);
130
+ } else if (e.isFile() && CODE_EXT.has(path.extname(e.name)) && !targets.includes(full)) {
131
+ repoRefs.push(full);
132
+ }
133
+ }
134
+ }
135
+ }
136
+
137
+ const refLinesCache = new Map();
138
+ const linesOf = (p) => {
139
+ if (!refLinesCache.has(p)) refLinesCache.set(p, codeLines(p));
140
+ return refLinesCache.get(p);
141
+ };
142
+
143
+ const results = targets.map((file) => {
144
+ const lines = codeLines(file);
145
+ const own = countsOf(lines);
146
+ const candidates = [];
147
+ for (const p of prodIndex.get(path.basename(file)) ?? []) {
148
+ candidates.push({ file: p, kind: 'prod-same-name', label: kebab(path.relative(prodRoot, p)) });
149
+ }
150
+ for (const p of repoRefs) {
151
+ candidates.push({ file: p, kind: 'repo-existing', label: kebab(path.relative(REPO, p)) });
152
+ }
153
+ let best = { file: null, kind: null, label: '-', ratio: 0, matched: 0 };
154
+ for (const c of candidates) {
155
+ const ref = linesOf(c.file);
156
+ const matched = matchedCount(ref, own);
157
+ const ratio = lines.length === 0 ? 0 : matched / lines.length;
158
+ if (ratio > best.ratio) best = { ...c, ratio, matched };
159
+ }
160
+ return {
161
+ file: kebab(path.relative(REPO, file)),
162
+ lines: lines.length,
163
+ matched: best.matched,
164
+ ratio: Math.round(best.ratio * 1000) / 1000,
165
+ percent: Math.round(best.ratio * 1000) / 10,
166
+ bestRef: best.label,
167
+ bestRefKind: best.kind,
168
+ hasProdSameName: (prodIndex.get(path.basename(file)) ?? []).length > 0,
169
+ pass: best.ratio < 0.5
170
+ };
171
+ });
172
+
173
+ if (asJson) {
174
+ console.log(JSON.stringify({ repo: REPO, prodRoot, caliber: '非空行多重集扣减 / 每文件取最高参照件', results }, null, 2));
175
+ } else {
176
+ console.log('同源度自查(行级):' + kebab(REPO));
177
+ console.log(' 口径:分子 = 新文件里能在参照件(生产同名件 ∪ 本仓既有件)找到的非空行数;分母 = 新文件非空行数');
178
+ console.log(' 判据:< 50% 通过(新文件不许拿本仓既有近源件当模板)\n');
179
+ const pad = (s, n) => String(s).padEnd(n);
180
+ console.log(' ' + pad('文件', 30) + pad('行数', 7) + pad('同源行', 8) + pad('同源度', 9) + pad('参照', 34) + '结论');
181
+ for (const r of results) {
182
+ console.log(' ' + pad(r.file, 30) + pad(r.lines, 7) + pad(r.matched, 8) + pad(r.percent + '%', 9)
183
+ + pad((r.bestRefKind === 'prod-same-name' ? '[生产同名] ' : '[仓内既有] ') + (r.bestRef ?? '-'), 34)
184
+ + (r.pass ? 'OK' : '🔴 超线'));
185
+ }
186
+ const bad = results.filter((r) => !r.pass);
187
+ console.log('\n 主结论:' + results.length + ' 个新文件,' + (results.length - bad.length) + ' 个 < 50%'
188
+ + (bad.length ? ',' + bad.length + ' 个 ≥ 50%(这条硬约束没过)' : '(全部通过)'));
189
+ }
190
+
191
+ process.exitCode = results.every((r) => r.pass) ? 0 : 1;
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ // scripts/test-degrade.mjs —— 夹具④:分片 + 超时预算 + 局部降级
3
+ //
4
+ // 一键跑:node scripts/test-degrade.mjs
5
+ // 退出码:0 = 全通过;1 = 有失败项。
6
+ //
7
+ // 三段:
8
+ // [A] 车道:快源 / 慢源(超时)/ 抛错源 / 未配置源 同时读 ⇒ 只有坏的降级,总耗时不被慢源拖爆
9
+ // [B] 总预算:整页预算比单源超时更小时 ⇒ 报「总预算耗尽」,而不是无限等
10
+ // [C] 面板整屏:坏源只坏那一格(那一组多一行「不可探测 + 原因」),其余组照常出、state.ok 仍为 true
11
+ //
12
+ // ⚠️ 夹具隔离三件套:USERPROFILE + HOME + DSH_HOME(另加本插件自己的 HOME 覆盖)。
13
+
14
+ import fs from 'node:fs';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+ import { fileURLToPath, pathToFileURL } from 'node:url';
18
+
19
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
+ let pass = 0;
21
+ let fail = 0;
22
+ const ok = (name, cond, detail = '') => {
23
+ if (cond) { pass += 1; console.log('[OK] ' + name + (detail ? ' :: ' + detail : '')); }
24
+ else { fail += 1; console.log('[FAIL] ' + name + (detail ? ' :: ' + detail : '')); }
25
+ };
26
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
27
+
28
+ // ---------------------------------------------------------------- 沙箱 + 三件套隔离
29
+ const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'panel-degrade-'));
30
+ const home = path.join(sandbox, 'home');
31
+ fs.mkdirSync(path.join(home, 'profiles', 'web'), { recursive: true });
32
+ for (const name of ['skill-a', 'skill-b']) {
33
+ fs.mkdirSync(path.join(home, 'skills', name), { recursive: true });
34
+ fs.writeFileSync(path.join(home, 'skills', name, 'SKILL.md'), `---\n{"name":"${name}","description":"夹具技能"}\n---\n`, 'utf8');
35
+ }
36
+ fs.writeFileSync(path.join(home, 'profiles', 'web', 'package.json'), JSON.stringify({ name: 'fixture-profile', dsh: { profile: { bundles: [] } } }), 'utf8');
37
+
38
+ process.env.USERPROFILE = home;
39
+ process.env.HOME = home;
40
+ process.env.DSH_HOME = home;
41
+ process.env.DSH_ECOSYSTEM_PANEL_HOME = home;
42
+
43
+ const { createLane, REASON } = await import(pathToFileURL(path.join(ROOT, 'lib', 'fetch-lane.js')).href);
44
+ const stateMod = await import(pathToFileURL(path.join(ROOT, 'lib', 'state.js')).href);
45
+
46
+ console.log('=== [A] 车道:一个快、一个慢、一个抛错、一个未配置 ===');
47
+ const lane = createLane({
48
+ totalBudgetMs: 600,
49
+ sources: [
50
+ { id: 'fast', label: '快源', run: async () => ({ ok: 1 }) },
51
+ { id: 'slow', label: '慢源', timeoutMs: 60, run: async () => { await sleep(400); return { ok: 'late' }; } },
52
+ { id: 'boom', label: '抛错源', run: async () => { throw new Error('上游 503'); } },
53
+ { id: 'gate', label: '未配置源', requires: () => '夹具:这个源没配置(示范第三种原因)', run: async () => ({ ok: 'nope' }) }
54
+ ]
55
+ });
56
+ const t0 = Date.now();
57
+ const readAll = await lane.readAll(['fast', 'slow', 'boom', 'gate'], {});
58
+ const wall = Date.now() - t0;
59
+ const r = readAll.byId;
60
+ ok('快源照常出', r.fast.status === 'cold' && r.fast.value?.ok === 1, `${r.fast.status}`);
61
+ ok('慢源超时 ⇒ 降级,原因写「超时」', r.slow.status === 'degraded' && r.slow.reason === REASON.TIMEOUT,
62
+ `${r.slow.status}/${r.slow.reason}(${r.slow.detail})`);
63
+ ok('抛错源 ⇒ 降级,原因写「报错」+ 上游原话', r.boom.status === 'degraded' && r.boom.reason === REASON.ERROR
64
+ && /上游 503/.test(String(r.boom.detail)), `${r.boom.reason}(${r.boom.detail})`);
65
+ ok('未配置源 ⇒ 降级,原因写「未配置」,且根本没发生取数',
66
+ r.gate.status === 'degraded' && r.gate.reason === REASON.NOT_CONFIGURED
67
+ && lane.sli().sources.gate.fetches === 0, `${r.gate.reason}(${r.gate.detail})`);
68
+ ok('整页总耗时没被慢源拖爆(慢源要 400 ms,实际 < 150 ms)', wall < 150, `${wall} ms`);
69
+ ok('降级计数按源分开记', lane.sli().sources.slow.degraded === 1 && lane.sli().sources.fast.degraded === 0,
70
+ JSON.stringify({ slow: lane.sli().sources.slow.degraded, fast: lane.sli().sources.fast.degraded }));
71
+ ok('降级不影响下一次读(快源仍是真值)', (await lane.read('fast')).status === 'fresh');
72
+
73
+ console.log('\n=== [B] 总预算:预算比单源超时还紧时,报「总预算耗尽」而不是无限等 ===');
74
+ const tight = createLane({
75
+ totalBudgetMs: 40,
76
+ sources: [{ id: 'slowpoke', label: '超慢源', timeoutMs: 5000, run: async () => { await sleep(300); return { ok: 1 }; } }]
77
+ });
78
+ const t1 = Date.now();
79
+ const tiny = await tight.readAll(['slowpoke'], {});
80
+ const tightWall = Date.now() - t1;
81
+ ok('预算耗尽 ⇒ 降级,原因写「总预算耗尽」', tiny.byId.slowpoke.status === 'degraded'
82
+ && tiny.byId.slowpoke.reason === REASON.BUDGET, `${tiny.byId.slowpoke.reason}`);
83
+ ok('确实按预算掐断(< 120 ms 就返回)', tightWall < 120, `${tightWall} ms`);
84
+
85
+ console.log('\n=== [C] 面板整屏:坏的只坏那一格,其余照出 ===');
86
+ // 现场注册两个坏源:一个抛错、一个超时(都落在「补丁健康」这一组里)
87
+ stateMod.registerSource({
88
+ id: 'probe-boom',
89
+ label: '夹具抛错源',
90
+ group: 'patches',
91
+ run: async () => { throw new Error('夹具:读不到上游'); },
92
+ describe: () => ({ rows: [] })
93
+ });
94
+ stateMod.registerSource({
95
+ id: 'probe-slow',
96
+ label: '夹具超时源',
97
+ group: 'patches',
98
+ timeoutMs: 60,
99
+ run: async () => { await sleep(500); return { ok: 1 }; },
100
+ describe: () => ({ rows: [] })
101
+ });
102
+ stateMod.resetLanes();
103
+ const st = await stateMod.buildState({ config: { home, profile: 'web', webUrl: 'http://127.0.0.1:1' } });
104
+ const patchRows = st.groups.patches;
105
+ const boomRow = patchRows.find((x) => x.name === '夹具抛错源');
106
+ const slowRow = patchRows.find((x) => x.name === '夹具超时源');
107
+ ok('整页仍然成功返回(state.ok=true)', st.ok === true);
108
+ ok('坏源只在自己的组里多一行「不可探测」', patchRows.length >= 3 && !!boomRow && !!slowRow,
109
+ `${patchRows.length} 行`);
110
+ ok('抛错那一格:不可探测 + 报错原因', /不可探测:报错/.test(boomRow.problems.join(' ')), boomRow.problems.join(' '));
111
+ ok('超时那一格:不可探测 + 超时原因', /不可探测:超时/.test(slowRow.problems.join(' ')), slowRow.problems.join(' '));
112
+ ok('其它组照常出(技能两条照出、跨表准入那格仍在)',
113
+ st.groups.skills.length === 2 && st.groups.registry.length === 1,
114
+ `skills=${st.groups.skills.length} registry=${st.groups.registry.length}`);
115
+ ok('正常源没被连坐(技能两条都是真值不是降级行)',
116
+ st.groups.skills.every((s) => s.kind === 'skill' && !/不可探测/.test((s.problems ?? []).join(' '))));
117
+ ok('页脚 SLI 里降级事件逐条可点(id + 原因)',
118
+ st.sli.degradeEvents.length === 2 && st.sli.degradeEvents.every((e) => e.id && e.reason),
119
+ JSON.stringify(st.sli.degradeEvents));
120
+ ok('坏源没有让整页多等:超时那格按 60 ms 掐断(不是等满 500 ms)',
121
+ (st.sli.request.sources.find((s) => s.id === 'probe-slow')?.elapsedMs ?? 9999) < 200,
122
+ `probe-slow elapsed=${st.sli.request.sources.find((s) => s.id === 'probe-slow')?.elapsedMs} ms`);
123
+ ok('每一步都留了单源耗时(降级那格也有)',
124
+ st.sli.request.sources.filter((s) => s.status === 'degraded').every((s) => typeof s.elapsedMs === 'number'));
125
+
126
+ stateMod.resetSources();
127
+ fs.rmSync(sandbox, { recursive: true, force: true });
128
+ console.log(`\n=== 结果:通过 ${pass} / 失败 ${fail} ===`);
129
+ console.log(fail === 0 ? 'DEGRADE FIXTURE PASSED' : 'DEGRADE FIXTURE FAILED');
130
+ process.exitCode = fail === 0 ? 0 : 1;
@@ -127,6 +127,10 @@ process.stdin.on('end', () => {
127
127
  // ---------------------------------------------------------------- 载入被测模块
128
128
  // ⚠️ 必须在 import 之前设好数据根:lib/state.js 的 YAML 依赖是在**模块加载期**经
129
129
  // createRequire(<数据根>/profiles/<profile>/package.json) 解析的。
130
+ // ⚠️ 夹具隔离三件套:USERPROFILE + HOME + DSH_HOME 一起指向沙箱(少一个就可能"写沙箱、发生产")。
131
+ process.env.USERPROFILE = sandbox;
132
+ process.env.HOME = sandbox;
133
+ process.env.DSH_HOME = sandbox;
130
134
  process.env.DSH_ECOSYSTEM_PANEL_HOME = home;
131
135
  const stateMod = await import(pathToFileURL(path.join(ROOT, 'lib', 'state.js')).href);
132
136
  ok('YAML 依赖在夹具数据根上解析成功(模块加载期)', stateMod.YAML_AVAILABLE === true, String(stateMod.YAML_ERROR ?? ''));
@@ -242,7 +246,28 @@ ok('停用组显示为「已按配置停用」提示项', s3.groups.patches.leng
242
246
  ok('停用组不计入 summary.total', s3.summary.total === s1.summary.total - 3, `${s3.summary.total} vs ${s1.summary.total}`);
243
247
  ok('停用组仍如实报出配置值', JSON.stringify(s3.meta.disabledGroups) === '["patches"]', JSON.stringify(s3.meta.disabledGroups));
244
248
 
245
- // ---------------------------------------------------------------- [4] 沙箱清理
249
+ // ---------------------------------------------------------------- [4] boot 清单 401 ⇒ 判"未知"
250
+ // 需要 token 的实例(`/?token=…`)会对服务端的无 cookie 抓取回 401,正文是 "authentication required"。
251
+ // 这类响应**必须**当成"抓不到"(ids=null = 未知),不许当成"清单里 0 条"——后者会让每一行插件
252
+ // 都报「boot 无 client 条目(疑似未加载/崩溃)」,纯属误报(隔离实例上实测踩到)。
253
+ console.log('\n=== [4] boot 清单 HTTP 401 ⇒ 判「未知」,不许当成「未加载」 ===');
254
+ const http = await import('node:http');
255
+ const stub = http.createServer((req, res) => {
256
+ res.writeHead(401, { 'content-type': 'text/plain; charset=utf-8' });
257
+ res.end('dsh web authentication required: reopen the URL printed by dsh web.');
258
+ });
259
+ await new Promise((resolve) => stub.listen(0, '127.0.0.1', resolve));
260
+ const stubUrl = `http://127.0.0.1:${stub.address().port}`;
261
+ const s4 = await stateMod.buildState({ config: { ...baseCfg, webUrl: stubUrl } });
262
+ await new Promise((resolve) => stub.close(resolve));
263
+ ok('401 时 bootEntries = null(未知,不是「0 条」)', s4.meta.bootEntries === null, String(s4.meta.bootEntries));
264
+ ok('401 被如实记进 bootError', /HTTP 401/.test(String(s4.meta.bootError)), String(s4.meta.bootError));
265
+ const exRow4 = s4.groups.bundles.find((b) => b.name === 'example-plugin');
266
+ ok('插件行退为「加载状态未知」(黄),不是「无 boot 条目」误报',
267
+ exRow4?.bootLoaded === null && /加载状态未知/.test((exRow4?.problems ?? []).join(' ')),
268
+ JSON.stringify(exRow4?.problems));
269
+
270
+ // ---------------------------------------------------------------- [5] 沙箱清理
246
271
  try { fs.rmSync(sandbox, { recursive: true, force: true }); } catch { /* ignore */ }
247
272
  ok('夹具沙箱已清理', !fs.existsSync(sandbox));
248
273
 
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ // scripts/test-singleflight.mjs —— 夹具③:单飞(同一源同一时刻只有一次在途取数)
3
+ //
4
+ // 一键跑:node scripts/test-singleflight.mjs
5
+ // 退出码:0 = 全通过;1 = 有失败项。
6
+ //
7
+ // 要证明的两件事:
8
+ // [A] 冷启动时 N 个并发请求打同一个源 ⇒ 源侧只被调用 **1 次**,N 个请求共享同一次结果
9
+ // [B] 过期(旧值在手)时 N 个并发请求 ⇒ 后台刷新也只发 **1 次**,不是每个请求各刷一遍
10
+ // 反例(如果不做单飞):10 个页面同时打开 = 源被打 10 次 —— 这正是"面板把源打爆"的现场。
11
+ //
12
+ // ⚠️ 夹具隔离三件套:USERPROFILE + HOME + DSH_HOME。
13
+
14
+ import fs from 'node:fs';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+ import { fileURLToPath, pathToFileURL } from 'node:url';
18
+
19
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
+ let pass = 0;
21
+ let fail = 0;
22
+ const ok = (name, cond, detail = '') => {
23
+ if (cond) { pass += 1; console.log('[OK] ' + name + (detail ? ' :: ' + detail : '')); }
24
+ else { fail += 1; console.log('[FAIL] ' + name + (detail ? ' :: ' + detail : '')); }
25
+ };
26
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
27
+
28
+ const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'panel-singleflight-'));
29
+ process.env.USERPROFILE = sandbox;
30
+ process.env.HOME = sandbox;
31
+ process.env.DSH_HOME = sandbox;
32
+ process.env.DSH_ECOSYSTEM_PANEL_HOME = sandbox;
33
+
34
+ const { createLane } = await import(pathToFileURL(path.join(ROOT, 'lib', 'fetch-lane.js')).href);
35
+
36
+ let clock = Date.now();
37
+ let calls = 0;
38
+ const RTT = 80;
39
+ const lane = createLane({
40
+ ttlMs: 500,
41
+ totalBudgetMs: 3000,
42
+ now: () => clock,
43
+ sources: [{
44
+ id: 'one-source',
45
+ label: '单飞探针',
46
+ async run() {
47
+ calls += 1;
48
+ const n = calls;
49
+ await sleep(RTT); // 模拟一次真实的慢取数
50
+ return { n };
51
+ }
52
+ }]
53
+ });
54
+
55
+ console.log('=== [A] 冷启动:10 个并发请求打同一个源 ===');
56
+ const t0 = Date.now();
57
+ const burst = await Promise.all(Array.from({ length: 10 }, () => lane.read('one-source')));
58
+ const wall = Date.now() - t0;
59
+ ok('10 个请求全部拿到值', burst.every((r) => r.value?.n === 1), JSON.stringify(burst.map((r) => r.value?.n)));
60
+ ok('源侧只被调用 1 次(单飞生效)', calls === 1, `calls=${calls}`);
61
+ ok('10 个请求共享同一次取数(耗时 ≈ 一次 RTT,不是 10 次)', wall >= RTT && wall < RTT * 3, `${wall} ms / RTT=${RTT} ms`);
62
+ ok('SLI 里也只有 1 次外部取数', lane.sli().sources['one-source'].fetches === 1,
63
+ String(lane.sli().sources['one-source'].fetches));
64
+ ok('计数口径如实:10 个冷启动请求各记一次 coldMiss,但外部只取 1 次(不拿"命中"骗读数)',
65
+ lane.sli().totals.coldMiss === 10 && lane.sli().sources['one-source'].fetches === 1,
66
+ JSON.stringify({ coldMiss: lane.sli().totals.coldMiss, fetches: lane.sli().sources['one-source'].fetches }));
67
+
68
+ console.log('\n=== [B] 过期:手持旧值时 8 个并发请求 ===');
69
+ clock += 520; // 让缓存过期(ttlMs=500)
70
+ const t1 = Date.now();
71
+ const staleBurst = await Promise.all(Array.from({ length: 8 }, () => lane.read('one-source')));
72
+ const staleWall = Date.now() - t1;
73
+ ok('8 个请求都先拿到旧值(stale,不阻塞)', staleBurst.every((r) => r.status === 'stale' && r.value?.n === 1),
74
+ staleBurst.map((r) => r.status).join(','));
75
+ ok('过期那一屏几乎不花时间(没等后台刷新)', staleWall < 40, `${staleWall} ms`);
76
+ await sleep(RTT + 60);
77
+ await lane.settle();
78
+ ok('后台刷新只发了 1 次(8 个请求共享同一次刷新)', calls === 2, `calls=${calls}`);
79
+ ok('SLI:过期读记成 8 次未命中,但外部取数只多 1 次',
80
+ lane.sli().totals.miss === 8 && lane.sli().sources['one-source'].fetches === 2,
81
+ JSON.stringify({ miss: lane.sli().totals.miss, fetches: lane.sli().sources['one-source'].fetches }));
82
+ const after = await lane.read('one-source');
83
+ ok('刷完后新值就位', after.status === 'fresh' && after.value?.n === 2, `${after.status} / n=${after.value?.n}`);
84
+
85
+ fs.rmSync(sandbox, { recursive: true, force: true });
86
+ console.log(`\n=== 结果:通过 ${pass} / 失败 ${fail} ===`);
87
+ console.log(fail === 0 ? 'SINGLE-FLIGHT FIXTURE PASSED' : 'SINGLE-FLIGHT FIXTURE FAILED');
88
+ process.exitCode = fail === 0 ? 0 : 1;