@kira905/dsh-ecosystem-panel 0.4.6 → 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.
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env node
2
+ // scripts/test-swr.mjs —— 夹具①:SWR(过期先给旧值 + 后台刷新;冷启动阻塞取真值并记 coldMiss)
3
+ //
4
+ // 一键跑:node scripts/test-swr.mjs
5
+ // 退出码:0 = 全通过;1 = 有失败项。
6
+ //
7
+ // 分两段:
8
+ // [A] 车道本身(假时钟 + 可控延迟的探针源):冷启 / 命中 / 过期先出旧值 / 后台换新 / 计数分开
9
+ // [B] 面板整屏(真 buildState + 假时钟车道):/state 的 freshness 与 sli 真的把"旧值 / 旧了多久"
10
+ // 吐出来,而不是只在单测里成立
11
+ //
12
+ // ⚠️ 夹具隔离三件套:USERPROFILE + HOME + DSH_HOME 全部指向沙箱(另加本插件自己的 HOME 覆盖),
13
+ // 少一个就可能"写沙箱、发生产"。
14
+
15
+ import fs from 'node:fs';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+ import { fileURLToPath, pathToFileURL } from 'node:url';
19
+
20
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
21
+ let pass = 0;
22
+ let fail = 0;
23
+ const ok = (name, cond, detail = '') => {
24
+ if (cond) { pass += 1; console.log('[OK] ' + name + (detail ? ' :: ' + detail : '')); }
25
+ else { fail += 1; console.log('[FAIL] ' + name + (detail ? ' :: ' + detail : '')); }
26
+ };
27
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
28
+
29
+ // ---------------------------------------------------------------- 沙箱 + 三件套隔离
30
+ const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'panel-swr-'));
31
+ const home = path.join(sandbox, 'home');
32
+ fs.mkdirSync(path.join(home, 'profiles', 'web'), { recursive: true });
33
+ fs.mkdirSync(path.join(home, 'skills', 'skill-a'), { recursive: true });
34
+ fs.writeFileSync(path.join(home, 'skills', 'skill-a', 'SKILL.md'), '---\n{"name":"skill-a","description":"夹具技能"}\n---\n', 'utf8');
35
+ fs.writeFileSync(path.join(home, 'profiles', 'web', 'package.json'), JSON.stringify({ name: 'fixture-profile', dsh: { profile: { bundles: [] } } }), 'utf8');
36
+
37
+ process.env.USERPROFILE = home;
38
+ process.env.HOME = home;
39
+ process.env.DSH_HOME = home;
40
+ process.env.DSH_ECOSYSTEM_PANEL_HOME = home;
41
+
42
+ const laneMod = await import(pathToFileURL(path.join(ROOT, 'lib', 'fetch-lane.js')).href);
43
+ const stateMod = await import(pathToFileURL(path.join(ROOT, 'lib', 'state.js')).href);
44
+ const { createLane, DEFAULT_TTL_MS } = laneMod;
45
+
46
+ console.log('=== [A] 车道:冷启动 / 命中 / 过期先给旧值 / 后台换新 ===');
47
+ console.log(`缺省 TTL = ${DEFAULT_TTL_MS} ms(300 s,与文档仓 06 篇 §3.2 的"五分钟节奏"同源)`);
48
+ ok('缺省 TTL 就是 300 s(没另定一个数)', DEFAULT_TTL_MS === 300000, String(DEFAULT_TTL_MS));
49
+
50
+ let clock = Date.now();
51
+ let calls = 0;
52
+ const lane = createLane({
53
+ ttlMs: 300,
54
+ totalBudgetMs: 2000,
55
+ now: () => clock,
56
+ sources: [{
57
+ id: 'probe',
58
+ label: '探针',
59
+ async run() {
60
+ calls += 1;
61
+ const n = calls;
62
+ await sleep(120); // 故意的慢源:用来证明"过期那一下没在等它"
63
+ return { n, at: clock };
64
+ }
65
+ }]
66
+ });
67
+
68
+ const t0 = Date.now();
69
+ const cold = await lane.read('probe');
70
+ const coldWall = Date.now() - t0;
71
+ ok('冷启动不是空壳:阻塞取到真值', cold.status === 'cold' && cold.value?.n === 1, `${cold.status} / n=${cold.value?.n}`);
72
+ ok('冷启动耗时 = 真取数(不是 0,也没超时)', coldWall >= 110, `${coldWall} ms`);
73
+ ok('数据块自带 fetchedAt', typeof cold.fetchedAt === 'number' && cold.fetchedAt === clock, String(cold.fetchedAt));
74
+ ok('冷启动单独记成 coldMiss(不算命中)', lane.sli().totals.coldMiss === 1 && lane.sli().totals.hit === 0,
75
+ JSON.stringify(lane.sli().totals));
76
+
77
+ const warm = await lane.read('probe');
78
+ ok('TTL 内再读 = 命中,不重复取数', warm.status === 'fresh' && calls === 1 && lane.sli().totals.hit === 1,
79
+ `${warm.status} / calls=${calls}`);
80
+
81
+ // 过期:旧值必须**立刻**出(不等 120 ms 的慢源),同时后台换新
82
+ clock += 340; // 超过 ttlMs=300
83
+ const t1 = Date.now();
84
+ const stale = await lane.read('probe');
85
+ const staleWall = Date.now() - t1;
86
+ ok('过期后先给旧值(stale),旧值内容没变', stale.status === 'stale' && stale.value?.n === 1,
87
+ `${stale.status} / n=${stale.value?.n}`);
88
+ ok('旧值带"旧了多久"(ageMs ≥ TTL)', stale.ageMs >= 300, `${stale.ageMs} ms`);
89
+ ok('过期那一下没有阻塞等慢源(< 60 ms 返回)', staleWall < 60, `${staleWall} ms`);
90
+ ok('过期被记为未命中(miss),不是命中', lane.sli().totals.miss === 1 && lane.sli().totals.hit === 1,
91
+ JSON.stringify(lane.sli().totals));
92
+
93
+ await lane.settle(); // 等后台那次刷新落定
94
+ const after = await lane.read('probe');
95
+ ok('后台刷回来就换新值', after.status === 'fresh' && after.value?.n === 2, `${after.status} / n=${after.value?.n}`);
96
+ ok('换新只多取了一次(单飞,不叠加)', calls === 2, String(calls));
97
+ const sliA = lane.sli();
98
+ ok('SLI 三桶分开:命中 / 未命中 / 冷启动', sliA.totals.hit === 2 && sliA.totals.miss === 1 && sliA.totals.coldMiss === 1,
99
+ JSON.stringify(sliA.totals));
100
+ ok('SLI 有每源耗时', sliA.sources.probe.fetches === 2 && sliA.sources.probe.avgMs >= 100,
101
+ `fetches=${sliA.sources.probe.fetches} avg=${sliA.sources.probe.avgMs} ms`);
102
+
103
+ console.log('\n=== [B] 面板整屏:freshness / sli 真的把"旧值 + 旧了多久"吐出来 ===');
104
+ stateMod.resetLanes();
105
+ let panelClock = Date.now();
106
+ const panelLane = createLane({
107
+ sources: stateMod.listSources(),
108
+ ttlMs: 120,
109
+ totalBudgetMs: 4000,
110
+ now: () => panelClock
111
+ });
112
+ const cfgBase = { home, profile: 'web', webUrl: 'http://127.0.0.1:1' };
113
+ const s1 = await stateMod.buildState({ config: cfgBase, lane: panelLane });
114
+ ok('第一次请求:有冷启动计数', s1.sli.totals.coldMiss >= 1, JSON.stringify(s1.sli.totals));
115
+ ok('第一次请求:新鲜度里没有任何过期源', s1.freshness.stale === false, JSON.stringify(s1.freshness.sources).slice(0, 120));
116
+ const s2 = await stateMod.buildState({ config: cfgBase, lane: panelLane });
117
+ ok('第二次请求:全部命中、取数几乎不花时间', s2.sli.request.totalMs <= 5 && s2.sli.totals.hit > s1.sli.totals.hit,
118
+ `totalMs=${s2.sli.request.totalMs} hit=${s2.sli.totals.hit}`);
119
+
120
+ panelClock += 200; // 全过期
121
+ const s3 = await stateMod.buildState({ config: cfgBase, lane: panelLane });
122
+ const staleIds = Object.entries(s3.freshness.sources).filter(([, v]) => v.stale).map(([k]) => k);
123
+ ok('过期后:整屏标 stale 且列清哪些源是旧值', s3.freshness.stale === true && staleIds.length > 0, staleIds.join(','));
124
+ ok('过期后:给出"最旧的那个"旧了多久', s3.freshness.oldestAgeMs >= 120, `${s3.freshness.oldestAgeMs} ms`);
125
+ ok('过期后:旧值仍然照常渲染(不是空壳)', s3.groups.skills.length === 1, String(s3.groups.skills.length));
126
+ ok('过期后:这次记成未命中', s3.sli.totals.miss >= 1, String(s3.sli.totals.miss));
127
+ ok('页脚三项数据齐(每源耗时 / 命中率三桶 / 降级次数)',
128
+ Array.isArray(s3.sli.request.sources) && s3.sli.request.sources.length === stateMod.listSources().length
129
+ && typeof s3.sli.totals.degraded === 'number', `sources=${s3.sli.request.sources.length}`);
130
+
131
+ fs.rmSync(sandbox, { recursive: true, force: true });
132
+ console.log(`\n=== 结果:通过 ${pass} / 失败 ${fail} ===`);
133
+ console.log(fail === 0 ? 'SWR FIXTURE PASSED' : 'SWR FIXTURE FAILED');
134
+ process.exitCode = fail === 0 ? 0 : 1;
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env node
2
+ // scripts/test-warmup-negctl.mjs —— 夹具②:周期预热的**阴性对照**(不预热必须看得见冷窗)
3
+ //
4
+ // 一键跑:node scripts/test-warmup-negctl.mjs
5
+ // 退出码:0 = 全通过;1 = 有失败项(阴性对照没变红 = 本夹具失败)。
6
+ //
7
+ // 为什么这个夹具最重要的是"红":
8
+ // "预热生效"很容易自证 —— 随便跑跑都能说它快了。真正能证明它在工作的,是**把它关掉之后
9
+ // 冷窗回来了**(首屏 coldMiss 上升 + 有可测的取数延迟)。看不到冷窗 ⇒ 要么预热没关掉、
10
+ // 要么缓存键串了、要么两次跑的是同一条车道 —— 那这套"预热"就是装饰品。
11
+ //
12
+ // 两条场景共用同一个沙箱与同一份源清单,唯一差别是 warmup.enabled:
13
+ // [开] 预热先填一遍 ⇒ 首屏零冷启动、耗时趋零
14
+ // [关] 首屏自己冷取 ⇒ 冷启动计数 = 源数、耗时含慢源
15
+ //
16
+ // ⚠️ 夹具隔离三件套:USERPROFILE + HOME + DSH_HOME(另加本插件自己的 HOME 覆盖)。
17
+
18
+ import fs from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+ import { fileURLToPath, pathToFileURL } from 'node:url';
22
+
23
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
24
+ let pass = 0;
25
+ let fail = 0;
26
+ const ok = (name, cond, detail = '') => {
27
+ if (cond) { pass += 1; console.log('[OK] ' + name + (detail ? ' :: ' + detail : '')); }
28
+ else { fail += 1; console.log('[FAIL] ' + name + (detail ? ' :: ' + detail : '')); }
29
+ };
30
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
31
+
32
+ // ---------------------------------------------------------------- 沙箱 + 三件套隔离
33
+ const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'panel-warmup-'));
34
+ const home = path.join(sandbox, 'home');
35
+ fs.mkdirSync(path.join(home, 'profiles', 'web'), { recursive: true });
36
+ for (const name of ['skill-a', 'skill-b']) {
37
+ fs.mkdirSync(path.join(home, 'skills', name), { recursive: true });
38
+ fs.writeFileSync(path.join(home, 'skills', name, 'SKILL.md'), `---\n{"name":"${name}","description":"夹具技能"}\n---\n`, 'utf8');
39
+ }
40
+ fs.writeFileSync(path.join(home, 'profiles', 'web', 'package.json'), JSON.stringify({ name: 'fixture-profile', dsh: { profile: { bundles: [] } } }), 'utf8');
41
+
42
+ process.env.USERPROFILE = home;
43
+ process.env.HOME = home;
44
+ process.env.DSH_HOME = home;
45
+ process.env.DSH_ECOSYSTEM_PANEL_HOME = home;
46
+
47
+ const cfgMod = await import(pathToFileURL(path.join(ROOT, 'lib', 'config.js')).href);
48
+ const stateMod = await import(pathToFileURL(path.join(ROOT, 'lib', 'state.js')).href);
49
+ const warmMod = await import(pathToFileURL(path.join(ROOT, 'lib', 'warmup.js')).href);
50
+ const { getConfig, resetConfig } = cfgMod;
51
+ const { buildState, getLane, registerSource, resetLanes, resetSources, setWarmupReporter } = stateMod;
52
+ const { startWarmup, DEFAULT_WARMUP_INTERVAL_MS, MIN_WARMUP_INTERVAL_MS } = warmMod;
53
+
54
+ // 一个"慢源":让冷窗有可测的延迟(真实面板里这类源 = 外部 CLI / 网络查询)
55
+ const SLOW_MS = 150;
56
+ let slowCalls = 0;
57
+ registerSource({
58
+ id: 'probe-slow',
59
+ label: '慢探针(夹具)',
60
+ group: 'probe',
61
+ timeoutMs: 2000,
62
+ async run() {
63
+ slowCalls += 1;
64
+ await sleep(SLOW_MS);
65
+ return { calls: slowCalls };
66
+ },
67
+ describe: (v) => ({ rows: [], meta: { probeSlowCalls: v?.calls ?? null } })
68
+ });
69
+
70
+ const baseConfig = (extra = {}) => ({ home, profile: 'web', webUrl: 'http://127.0.0.1:1', ...extra });
71
+ // 预热"全部源"(*):这样开/关的唯一差别就是"首屏要不要自己冷取",读数最干净
72
+ const WARM_SOURCES = ['*'];
73
+
74
+ /** 跑一条"冷进程"场景:新配置 + 新车道 + 可选预热 */
75
+ async function scenario(enabled) {
76
+ resetConfig();
77
+ resetLanes();
78
+ setWarmupReporter(null);
79
+ const warmup = { enabled, intervalMs: 300000, sources: WARM_SOURCES };
80
+ const cfg = getConfig(baseConfig({ warmup }));
81
+ const lane = getLane(cfg);
82
+ let warm = null;
83
+ if (enabled) {
84
+ warm = startWarmup({ lane, cfg });
85
+ await warm.whenReady();
86
+ setWarmupReporter(() => warm.status());
87
+ }
88
+ const before = lane.sli().totals;
89
+ const t0 = Date.now();
90
+ const state = await buildState({ config: baseConfig(), lane });
91
+ const ms = Date.now() - t0;
92
+ const after = lane.sli().totals;
93
+ const result = {
94
+ enabled,
95
+ ms,
96
+ // 计数是车道累计的 ⇒ 预热自己那次取数当然也算冷启动。这里取**增量** = 首屏这次花的
97
+ coldMissDelta: after.coldMiss - before.coldMiss,
98
+ coldMiss: after.coldMiss,
99
+ srcStatuses: state.sli.request.sources.map((s) => s.status),
100
+ totalMs: state.sli.request.totalMs,
101
+ warmupSli: state.sli.warmup,
102
+ skills: state.groups.skills.length,
103
+ warmStatus: warm ? warm.status() : null
104
+ };
105
+ if (warm) {
106
+ warm.stop();
107
+ setWarmupReporter(null);
108
+ }
109
+ return result;
110
+ }
111
+
112
+ console.log('=== 预热缺省值(README 与配置层必须一致)===');
113
+ ok('预热缺省关闭(不许悄悄后台常驻)', warmMod.WARMUP_DEFAULTS.enabled === false);
114
+ ok('缺省周期 = 300 s(与 TTL 同源,不另定)', DEFAULT_WARMUP_INTERVAL_MS === 300000, String(DEFAULT_WARMUP_INTERVAL_MS));
115
+ ok('周期下限 5 s(再密就是白烧 CPU)', MIN_WARMUP_INTERVAL_MS === 5000, String(MIN_WARMUP_INTERVAL_MS));
116
+ const cfgDefault = getConfig(baseConfig());
117
+ ok('配置层缺省也是"关 + 300 s"', cfgDefault.warmup.enabled === false && cfgDefault.warmup.intervalMs === 300000,
118
+ JSON.stringify(cfgDefault.warmup));
119
+ ok('三个环境变量口都在(开关 / 周期 / 预热哪些源)',
120
+ ['warmupEnabled', 'warmupIntervalMs', 'warmupSources'].every((k) => k in cfgDefault.sources),
121
+ JSON.stringify(Object.keys(cfgDefault.sources).filter((k) => k.startsWith('warmup'))));
122
+
123
+ console.log('\n=== 对拍照:预热开 vs 预热关(同一沙箱、同一源清单)===');
124
+ const ON = await scenario(true);
125
+ const OFF = await scenario(false);
126
+
127
+ const pad = (s, n) => String(s).padEnd(n);
128
+ console.log('\n 场景 首屏耗时 首屏冷启动 首屏未命中 首屏命中 源数 预热状态');
129
+ console.log(' ' + pad('预热开', 12) + pad(ON.totalMs + ' ms', 13) + pad(ON.coldMissDelta, 12) + pad(ON.srcStatuses.filter((x) => x === 'stale').length, 12) + pad(ON.srcStatuses.filter((x) => x === 'fresh').length, 10) + pad(ON.srcStatuses.length, 6) + '已跑 ' + (ON.warmStatus?.runs ?? 0) + ' 次');
130
+ console.log(' ' + pad('预热关', 12) + pad(OFF.totalMs + ' ms', 13) + pad(OFF.coldMissDelta, 12) + pad(OFF.srcStatuses.filter((x) => x === 'stale').length, 12) + pad(OFF.srcStatuses.filter((x) => x === 'fresh').length, 10) + pad(OFF.srcStatuses.length, 6) + '未开启');
131
+ console.log('');
132
+
133
+ ok('预热开:首屏零冷启动(全部命中缓存)', ON.coldMissDelta === 0 && ON.srcStatuses.every((x) => x === 'fresh'),
134
+ `delta=${ON.coldMissDelta} / ${ON.srcStatuses.join(',')}`);
135
+ ok('预热开:预热器真的跑过一次,且把源都填好了',
136
+ ON.warmStatus?.runs === 1 && Object.values(ON.warmStatus?.lastSources ?? {}).every((v) => v.status === 'cold' || v.status === 'fresh'),
137
+ `runs=${ON.warmStatus?.runs} sources=${Object.keys(ON.warmStatus?.lastSources ?? {}).length}`);
138
+ ok('预热开:首屏耗时不含慢源(命中即回,几毫秒级)', ON.totalMs < 50, `${ON.totalMs} ms`);
139
+ ok('预热开:整屏仍照常出(不是靠少取数换来的快)', ON.skills === 2 && ON.srcStatuses.length === 10,
140
+ `skills=${ON.skills} sources=${ON.srcStatuses.length}`);
141
+
142
+ ok('🔴 预热关:冷窗确实出现(首屏 coldMiss = 源数)', OFF.coldMissDelta === OFF.srcStatuses.length,
143
+ `delta=${OFF.coldMissDelta} / sources=${OFF.srcStatuses.length}`);
144
+ ok('🔴 预热关:首屏有可测的取数延迟(慢源那 ' + SLOW_MS + ' ms 跑不掉)', OFF.totalMs >= SLOW_MS,
145
+ `${OFF.totalMs} ms`);
146
+ ok('🔴 阴性对照成立:开 vs 关的差距一眼可见',
147
+ OFF.coldMissDelta > ON.coldMissDelta && OFF.totalMs >= ON.totalMs + 80,
148
+ `首屏冷启动 ${ON.coldMissDelta} → ${OFF.coldMissDelta};首屏 ${ON.totalMs} ms → ${OFF.totalMs} ms`);
149
+
150
+ console.log('\n 阴性对照读数:首屏冷启动 ' + ON.coldMissDelta + ' → ' + OFF.coldMissDelta
151
+ + ';首屏耗时 ' + ON.totalMs + ' ms → ' + OFF.totalMs + ' ms(关掉预热就看得见冷窗 = 预热确实在工作)');
152
+
153
+ // 误开也挡住:周期被写成负数/0 时必须回到缺省,而不是变成"每毫秒一次"
154
+ const badCfg = getConfig(baseConfig({ warmup: { enabled: true, intervalMs: 0, sources: [] } }));
155
+ ok('周期给 0/负数时回落到缺省 300 s(不许变成空转)', badCfg.warmup.intervalMs === 300000, String(badCfg.warmup.intervalMs));
156
+
157
+ resetSources();
158
+ fs.rmSync(sandbox, { recursive: true, force: true });
159
+ console.log(`\n=== 结果:通过 ${pass} / 失败 ${fail} ===`);
160
+ console.log(fail === 0 ? 'WARMUP NEGATIVE-CONTROL PASSED' : 'WARMUP NEGATIVE-CONTROL FAILED');
161
+ process.exitCode = fail === 0 ? 0 : 1;