dsh-job-progress 0.1.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,121 @@
1
+ // test/preflight-client.mjs — 不装插件也能验客户端半边(2026-09-20)
2
+ //
3
+ // 为什么要有它:客户端半边只在浏览器里跑,它的错误只出现在**渲染层 console**,
4
+ // 而那份 console 不落盘(本机日志目录里只有 host 与 main 两份)。后果是
5
+ // 「插件把界面搞坏了」只能等重启后靠肉眼发现 —— 2026-09-20 就是这么栽的:
6
+ // factory 里引用了 exports/module 却没声明(真·bug),宿主半边却挂载正常、
7
+ // 日志里一条异常都没有,于是整场排查只能对着空白猜。
8
+ //
9
+ // 这个脚本用桩把模块加载器的契约在 Node 里复现一遍,任何一条不成立就非零退出:
10
+ // ① 顶层必须只调用 window.__ModuleLoader__.load(...)
11
+ // ② id 必须等于包名;factory 必须能被调用并返回 exports
12
+ // ③ exports.apply / exports.inject 必须存在
13
+ // ④ apply(ctx) 必须不抛,且确实注册到预期槽位
14
+ // ⑤ 组件函数必须能跑(有任务时不返回 null,无任务时返回 null)
15
+ //
16
+ // 用法:node test/preflight-client.mjs
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { fileURLToPath, pathToFileURL } from 'node:url';
20
+
21
+ const here = path.dirname(fileURLToPath(import.meta.url));
22
+ const clientPath = path.join(here, '..', 'lib', 'client.js');
23
+ const pkg = JSON.parse(fs.readFileSync(path.join(here, '..', 'package.json'), 'utf8'));
24
+
25
+ const passed = [];
26
+ const failed = [];
27
+ const check = (name, fn) => {
28
+ try {
29
+ fn();
30
+ passed.push(name);
31
+ } catch (error) {
32
+ failed.push(`${name} — ${error?.message ?? error}`);
33
+ }
34
+ };
35
+
36
+ // ── 复现模块加载器:它只做一件事,把模块描述对象交给执行器 ──────────────
37
+ let loaded = null;
38
+ globalThis.window = { __ModuleLoader__: { load: (mod) => { loaded = mod; } } };
39
+
40
+ let importError = null;
41
+ try {
42
+ await import(pathToFileURL(clientPath).href);
43
+ } catch (error) {
44
+ importError = error;
45
+ }
46
+ check('模块可被导入', () => { if (importError) throw importError; });
47
+ check('顶层调用了 __ModuleLoader__.load', () => { if (loaded === null) throw new Error('load 从未被调用'); });
48
+ check('id 与包名一致', () => {
49
+ if (loaded?.id !== pkg.name) throw new Error(`id=${JSON.stringify(loaded?.id)},包名=${pkg.name}`);
50
+ });
51
+ check('声明了 web 平台与 client 注入', () => {
52
+ const client = pkg.dsh?.client;
53
+ if (client?.platform !== 'web') throw new Error('dsh.client.platform 必须是 web');
54
+ if (!Array.isArray(client?.inject) || client.inject.length === 0) throw new Error('dsh.client.inject 不能为空');
55
+ });
56
+ check('package.json 暴露了 ./client 入口', () => {
57
+ if (pkg.exports?.['./client'] === undefined) throw new Error('exports["./client"] 缺失');
58
+ });
59
+
60
+ // ── 最小 React 桩:够让模块体与组件体各跑一遍 ────────────────────────────
61
+ const reactShim = {
62
+ createElement: (type, props, ...children) => ({ type, props, children }),
63
+ useState: (init) => [typeof init === 'function' ? init() : init, () => {}],
64
+ useEffect: () => {},
65
+ useRef: () => ({ current: null }),
66
+ useMemo: (fn) => fn(),
67
+ useCallback: (fn) => fn,
68
+ };
69
+ const requireStub = (name) => {
70
+ if (name === 'react') return reactShim;
71
+ throw new Error(`客户端半边不该 require ${JSON.stringify(name)}(除 react 外都应收敛掉)`);
72
+ };
73
+
74
+ let mod = null;
75
+ check('factory 可执行并返回 exports', () => {
76
+ mod = loaded.factory(requireStub);
77
+ if (!mod) throw new Error('factory 没有返回 exports');
78
+ });
79
+ check('exports.apply 是函数', () => { if (typeof mod?.apply !== 'function') throw new Error('缺少 apply'); });
80
+ check('exports.inject 是数组', () => { if (!Array.isArray(mod?.inject)) throw new Error('缺少 inject'); });
81
+
82
+ // ── apply(ctx) 与组件渲染 ───────────────────────────────────────────────
83
+ const registered = [];
84
+ const ctxStub = {
85
+ effect: (fn) => { fn(); return () => {}; },
86
+ locale: { register: () => () => {} },
87
+ connection: { rpc: { call: async () => ({ ok: true, value: { tasks: [] } }) } },
88
+ slots: {
89
+ inject: (_name, fn) => { fn(); },
90
+ register: (spec, component) => { registered.push({ spec, component }); return () => {}; },
91
+ },
92
+ };
93
+ check('apply(ctx) 不抛', () => { mod.apply(ctxStub); });
94
+ check('注册到会话头部槽位', () => {
95
+ if (!registered.some((entry) => entry.spec?.name === 'conversation.session.header.actions')) {
96
+ throw new Error('未注册 conversation.session.header.actions');
97
+ }
98
+ });
99
+ check('有任务时组件不返回 null', () => {
100
+ const component = registered[0].component;
101
+ const useSessions = (select) => select({
102
+ jobsBySession: { 'session-x': [{ id: 'bash-1', kind: 'pwsh', label: 'x', status: 'running', startedAt: Date.now() }] },
103
+ });
104
+ const tree = component({ sessionId: 'session-x', useSessions, t: (key) => key });
105
+ if (tree === null || tree === undefined) throw new Error('有任务时应当渲染出节点');
106
+ });
107
+ check('无任务时组件返回 null', () => {
108
+ const component = registered[0].component;
109
+ const useSessions = (select) => select({ jobsBySession: {} });
110
+ const tree = component({ sessionId: 'session-y', useSessions, t: (key) => key });
111
+ if (tree !== null) throw new Error('无任务的会话不应渲染任何东西');
112
+ });
113
+
114
+ for (const name of passed) console.log(`ok ${name}`);
115
+ if (failed.length > 0) {
116
+ console.error('');
117
+ for (const line of failed) console.error(`FAIL ${line}`);
118
+ console.error(`\n${failed.length} 项不通过`);
119
+ process.exit(1);
120
+ }
121
+ console.log(`\nALL PASS (${passed.length})`);