@jjh32456/dsh-longlive 1.6.7 → 1.8.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.
Files changed (289) hide show
  1. package/CHANGELOG.md +70 -1508
  2. package/LICENSE +1 -1
  3. package/QUICKSTART.md +34 -13
  4. package/README.en.md +18 -7
  5. package/README.md +34 -9
  6. package/SPEC.md +5 -5
  7. package/cordis.patch.yml +23 -9
  8. package/docs/ANSWER-GUARD.md +92 -0
  9. package/docs/ARCHITECTURE.md +4 -3
  10. package/docs/AUTO-RECALL-WIRING.md +1 -1
  11. package/docs/AUTO-RECALL.md +15 -9
  12. package/docs/CONFIG.md +168 -35
  13. package/docs/ERRORS.md +17 -5
  14. package/docs/FAQ.md +7 -7
  15. package/docs/FEATURES.md +96 -28
  16. package/docs/MIGRATION-1.6.0.md +2 -2
  17. package/docs/OPERATIONS.md +6 -6
  18. package/docs/PANEL-INTEGRATION.md +6 -0
  19. package/docs/PRIVACY.md +88 -0
  20. package/docs/QUICKSTART.md +5 -5
  21. package/docs/README.en.md +4 -2
  22. package/docs/RESIDENT-WATCHER.md +1 -1
  23. package/docs/SESSION-WATCH.md +6 -6
  24. package/docs/TROUBLESHOOTING.md +4 -4
  25. package/package.json +47 -117
  26. package/runbook.md +2 -2
  27. package/scripts/backfill-auto-cards.mjs +98 -0
  28. package/scripts/install-surgery-task.ps1 +5 -2
  29. package/scripts/lib/session-watch-fixture.mjs +1 -1
  30. package/scripts/longlive-doctor.mjs +6 -0
  31. package/scripts/migrate-180.mjs +163 -0
  32. package/scripts/recall-index-build.mjs +128 -0
  33. package/scripts/session-watch-drill.mjs +1 -1
  34. package/scripts/surgery-run.cmd +1 -1
  35. package/scripts/unattended-surgery.ps1 +5 -2
  36. package/scripts/verify-card-anchors.mjs +138 -0
  37. package/src/actions/audit.js +125 -0
  38. package/src/actions/evidence.js +122 -0
  39. package/src/actions/fs.js +162 -0
  40. package/src/activity/index.js +760 -0
  41. package/src/activity/model.js +351 -0
  42. package/src/activity/store.js +644 -0
  43. package/src/activity/thinking.js +176 -0
  44. package/src/adapters/constants.js +11 -0
  45. package/src/adapters/engram/data/aliases.json +51 -0
  46. package/src/adapters/engram/data/policy.json +8 -0
  47. package/src/adapters/engram/data/routing-rules.json +546 -0
  48. package/src/adapters/engram/index.js +235 -0
  49. package/src/adapters/registry.js +315 -0
  50. package/src/answer/audit.js +193 -0
  51. package/src/answer/evidence.js +30 -0
  52. package/src/answer/guard.js +1370 -0
  53. package/src/answer/index.js +1067 -0
  54. package/src/answer/query.js +111 -0
  55. package/src/answer/second-hop.mjs +101 -0
  56. package/src/auto-truncate.js +1 -1
  57. package/src/config.js +171 -47
  58. package/src/deps.js +6 -4
  59. package/src/features.js +182 -6
  60. package/src/heartbeat.js +156 -23
  61. package/src/identity/handover.js +52 -1
  62. package/src/identity/index.js +13 -5
  63. package/src/identity/inject-position.js +2 -2
  64. package/src/index.js +97 -7
  65. package/src/lifecycle/gap-clocks.js +328 -0
  66. package/src/lifecycle/index.js +269 -0
  67. package/src/live.js +22 -2
  68. package/src/memory/activity-capture.js +229 -0
  69. package/src/memory/anchors.js +194 -0
  70. package/src/memory/audit-records.js +222 -0
  71. package/src/memory/auto-cards.js +401 -0
  72. package/src/memory/capture.js +442 -0
  73. package/src/memory/card-anchors.js +203 -0
  74. package/src/memory/conflicts.js +106 -0
  75. package/src/memory/decay.js +311 -0
  76. package/src/memory/distill.js +471 -0
  77. package/src/memory/event-index.js +917 -0
  78. package/src/memory/evidence-chain.js +295 -0
  79. package/src/memory/evidence.js +145 -0
  80. package/src/memory/filter-runner.js +1 -1
  81. package/src/memory/filter.js +5 -1
  82. package/src/memory/forget-v2.js +410 -0
  83. package/src/memory/forget.js +71 -0
  84. package/src/memory/frames-activation.js +247 -0
  85. package/src/memory/fts-query.js +174 -0
  86. package/src/memory/governance.js +604 -0
  87. package/src/memory/index.js +1 -0
  88. package/src/memory/l2-gate.js +169 -0
  89. package/src/memory/lock.js +121 -0
  90. package/src/memory/migrate.js +139 -0
  91. package/src/memory/recall-v2.js +223 -0
  92. package/src/memory/recall.js +65 -2
  93. package/src/memory/records.js +425 -0
  94. package/src/memory/retention.js +128 -0
  95. package/src/memory/review.js +124 -0
  96. package/src/memory/trust.js +88 -0
  97. package/src/memory/version-chain.js +264 -0
  98. package/src/memory/wal.js +137 -0
  99. package/src/memory/working-frame.js +516 -0
  100. package/src/metrics/classify.mjs +5 -3
  101. package/src/metrics/index.mjs +21 -1
  102. package/src/metrics/prestep-latency.mjs +300 -0
  103. package/src/metrics/store.mjs +25 -0
  104. package/src/policy-profile.js +18 -3
  105. package/src/recall/anaphora.mjs +87 -0
  106. package/src/recall/cards.mjs +7 -0
  107. package/src/recall/embedding.mjs +8 -8
  108. package/src/recall/entities.mjs +112 -0
  109. package/src/recall/index.js +235 -19
  110. package/src/recall/inject-order.mjs +10 -8
  111. package/src/recall/privacy.mjs +105 -16
  112. package/src/recall/recall-core.mjs +116 -10
  113. package/src/recall/shared-visibility.js +302 -0
  114. package/src/recall/time-normalizer.mjs +196 -0
  115. package/src/role/activation.js +191 -0
  116. package/src/role/attention.js +318 -0
  117. package/src/role/base.js +36 -0
  118. package/src/role/channels.js +423 -0
  119. package/src/role/index.js +425 -0
  120. package/src/role/interfaces.js +115 -0
  121. package/src/role/personality.js +370 -0
  122. package/src/role/readers.js +151 -0
  123. package/src/role/relationships.js +477 -0
  124. package/src/role/store.js +202 -0
  125. package/src/role/writeprotect.js +373 -0
  126. package/src/router/aliases.json +176 -25
  127. package/src/router/background/agent-runner.mjs +1 -1
  128. package/src/router/background/anchor.mjs +1 -1
  129. package/src/router/background/audit.mjs +2 -2
  130. package/src/router/background/budget-ledger.mjs +2 -2
  131. package/src/router/background/host-sim.mjs +1 -1
  132. package/src/router/background/memory.mjs +4 -4
  133. package/src/router/background/queue.mjs +1 -1
  134. package/src/router/background/retry.mjs +1 -1
  135. package/src/router/background/router.mjs +1 -1
  136. package/src/router/background/stream.mjs +1 -1
  137. package/src/router/background/tool-policy.mjs +1 -1
  138. package/src/router/design-headings.json +5 -5
  139. package/src/router/face/config.mjs +1 -1
  140. package/src/router/face/identity.mjs +3 -3
  141. package/src/router/judge.mjs +8 -7
  142. package/src/router/policies/generic.json +21 -4
  143. package/src/router/route.js +109 -22
  144. package/src/router/router.mjs +10 -6
  145. package/src/router/routing-rules.json +260 -445
  146. package/src/router/run-sim.mjs +8 -8
  147. package/src/router/runtime.js +23 -12
  148. package/src/router/service/board.mjs +95 -15
  149. package/src/router/service/config.mjs +5 -5
  150. package/src/router/service/context.mjs +1 -1
  151. package/src/router/service/hint.mjs +14 -0
  152. package/src/router/service/queue.mjs +1 -1
  153. package/src/router/service/service.mjs +27 -11
  154. package/src/router/shadow.mjs +4 -3
  155. package/src/surgery-recovery.js +2 -2
  156. package/src/tool-output.js +2 -2
  157. package/src/tools.js +39 -7
  158. package/src/truncate-visible.js +2 -2
  159. package/src/ui/actions.js +1312 -0
  160. package/src/ui/panel-api.js +367 -0
  161. package/src/ui/panel-auth.js +251 -0
  162. package/src/ui/panel-integrity.js +243 -0
  163. package/src/ui/routes-readonly.js +695 -0
  164. package/src/ui/routes.js +3 -0
  165. package/docs/DEV.md +0 -279
  166. package/docs/PRESENCE-BUS-WIRING.md +0 -491
  167. package/docs/PRESENCE-BUS.md +0 -694
  168. package/docs/SURGERY.md +0 -186
  169. package/scripts/mark-core-cards.mjs +0 -289
  170. package/scripts/pack.mjs +0 -392
  171. package/scripts/router-proposals.mjs +0 -386
  172. package/src/router/fixtures/M1.json +0 -16
  173. package/src/router/fixtures/M2.json +0 -16
  174. package/src/router/fixtures/M3.json +0 -20
  175. package/src/router/fixtures/M4.json +0 -20
  176. package/src/router/fixtures/M5.json +0 -45
  177. package/src/router/fixtures/M6.json +0 -53
  178. package/src/router/fixtures/M7.json +0 -46
  179. package/src/router/fixtures/S1.json +0 -20
  180. package/src/router/fixtures/S2.json +0 -23
  181. package/src/router/fixtures/S3.json +0 -20
  182. package/src/router/fixtures/S4.json +0 -34
  183. package/src/router/fixtures/S5.json +0 -43
  184. package/src/router/fixtures/S6.json +0 -47
  185. package/src/router/fixtures/T1.json +0 -23
  186. package/src/router/fixtures/T2.json +0 -16
  187. package/src/router/fixtures/T3.json +0 -19
  188. package/src/router/fixtures/T4.json +0 -16
  189. package/src/router/fixtures/X1.json +0 -17
  190. package/src/router/fixtures/X2.json +0 -20
  191. package/src/router/gen-design-headings.mjs +0 -107
  192. package/src/router/policies/owner-rulings-2026-11-03.json +0 -62
  193. package/test/auto-recall.test.mjs +0 -474
  194. package/test/auto-truncate.test.mjs +0 -931
  195. package/test/autobind.test.mjs +0 -385
  196. package/test/bus.test.mjs +0 -1572
  197. package/test/cli-guard.test.mjs +0 -198
  198. package/test/conclusion.test.mjs +0 -408
  199. package/test/config-schema.test.mjs +0 -204
  200. package/test/d6-output.test.mjs +0 -194
  201. package/test/d6.test.mjs +0 -395
  202. package/test/deps.test.mjs +0 -342
  203. package/test/doctor-setup.test.mjs +0 -297
  204. package/test/embedding-recall.test.mjs +0 -347
  205. package/test/feature-flags.test.mjs +0 -568
  206. package/test/filter-shadow.test.mjs +0 -170
  207. package/test/fixtures/baseline-0.8.0-registration.json +0 -556
  208. package/test/fixtures/d6-header-tools.json +0 -1
  209. package/test/fixtures/lock-child.mjs +0 -67
  210. package/test/fixtures/pre-step-container.mjs +0 -100
  211. package/test/fixtures/privacy-memory/topics/pii-unmarked.md +0 -9
  212. package/test/fixtures/privacy-memory/topics/secret-local-only.md +0 -11
  213. package/test/fixtures/profile-session-longlive-config.json +0 -20
  214. package/test/fixtures/proposal-cas-child.mjs +0 -66
  215. package/test/host-engine-status.test.mjs +0 -307
  216. package/test/hot-truncate.test.mjs +0 -1349
  217. package/test/identity-card-marker.test.mjs +0 -307
  218. package/test/identity-router/run.mjs +0 -721
  219. package/test/integration-pre-step.test.mjs +0 -553
  220. package/test/live.test.mjs +0 -1020
  221. package/test/maintenance-schedule.test.mjs +0 -538
  222. package/test/memory.test.mjs +0 -2828
  223. package/test/metrics.test.mjs +0 -478
  224. package/test/orchestrator.test.mjs +0 -266
  225. package/test/p1_probe.mjs +0 -97
  226. package/test/packaging.test.mjs +0 -182
  227. package/test/portability.test.mjs +0 -343
  228. package/test/rc4-regressions.test.mjs +0 -562
  229. package/test/rc5-regressions.test.mjs +0 -197
  230. package/test/rc6-chinese.test.mjs +0 -394
  231. package/test/router-1100/apply-off.test.mjs +0 -175
  232. package/test/router-1100/budget-reconcile.test.mjs +0 -215
  233. package/test/router-1100/config-context.test.mjs +0 -211
  234. package/test/router-1100/helpers.mjs +0 -129
  235. package/test/router-1100/hint-envelope.test.mjs +0 -150
  236. package/test/router-1100/lock-crossproc.test.mjs +0 -206
  237. package/test/router-1100/proposal-cas-crossproc.test.mjs +0 -184
  238. package/test/router-1100/router-apply.test.mjs +0 -372
  239. package/test/router-1100/router-fixb.test.mjs +0 -602
  240. package/test/router-1100/router-proposals.test.mjs +0 -405
  241. package/test/router-1100/router-queue-lease-wal.test.mjs +0 -355
  242. package/test/router-1100/router-security-cr.test.mjs +0 -333
  243. package/test/router-1100/service-lifecycle.test.mjs +0 -220
  244. package/test/router-1100/tools-contract.test.mjs +0 -164
  245. package/test/router-1100/turn-reflect-tick.test.mjs +0 -89
  246. package/test/router-1100/v6-contract.test.mjs +0 -158
  247. package/test/router-1100/v6b-contract.test.mjs +0 -293
  248. package/test/router-1100/v6c-contract.test.mjs +0 -249
  249. package/test/router-1100/wal-rotation.test.mjs +0 -375
  250. package/test/router-1100/wp1-config.test.mjs +0 -170
  251. package/test/router-1100/wp1-default-off.test.mjs +0 -77
  252. package/test/router-1100/wp1-model-status.test.mjs +0 -90
  253. package/test/router-1100/wp1-status.test.mjs +0 -220
  254. package/test/router-1100/wp1-tools.test.mjs +0 -146
  255. package/test/router-1100/wp5-cli.test.mjs +0 -240
  256. package/test/router-1100/wp5-model-auto.test.mjs +0 -418
  257. package/test/router-1100/wp6-merge.test.mjs +0 -386
  258. package/test/router-1100/wp6-wake.test.mjs +0 -502
  259. package/test/router-background/audit.test.mjs +0 -37
  260. package/test/router-background/budget-ledger.test.mjs +0 -330
  261. package/test/router-background/cross-process.test.mjs +0 -222
  262. package/test/router-background/fixtures/cross-child.mjs +0 -85
  263. package/test/router-background/fixtures/real-shapes.mjs +0 -239
  264. package/test/router-background/router.test.mjs +0 -1044
  265. package/test/router-background/stream-retry.test.mjs +0 -358
  266. package/test/router-background/tool-agent.test.mjs +0 -384
  267. package/test/router-face/bridge.test.mjs +0 -169
  268. package/test/router-face/face.test.mjs +0 -316
  269. package/test/router-failclosed.test.mjs +0 -466
  270. package/test/router-identity.test.mjs +0 -384
  271. package/test/router-l1-l2.contract.test.mjs +0 -1075
  272. package/test/router-memory-tools.test.mjs +0 -262
  273. package/test/router-policy.test.mjs +0 -225
  274. package/test/router-runtime.test.mjs +0 -438
  275. package/test/router-shadow.test.mjs +0 -287
  276. package/test/router.test.mjs +0 -903
  277. package/test/run-rc.mjs +0 -299
  278. package/test/run.mjs +0 -2469
  279. package/test/safety-defaults.test.mjs +0 -179
  280. package/test/seeded-load.test.mjs +0 -436
  281. package/test/session-original.test.mjs +0 -338
  282. package/test/surgery-recovery.test.mjs +0 -936
  283. package/test/t7_drive.mjs +0 -151
  284. package/test/tool-output.test.mjs +0 -393
  285. package/test/tool-schema.test.mjs +0 -177
  286. package/test/tools_contract.test.mjs +0 -716
  287. package/test/truncate-visible.test.mjs +0 -1150
  288. package/test/ui-flags.test.mjs +0 -477
  289. package/test/v4-hardening.test.mjs +0 -427
@@ -1,342 +0,0 @@
1
- // suite: optional runtime dependencies (0.8.0-rc.8)
2
- //
3
- // Red-first contract for src/deps.js + scripts/longlive-doctor.mjs:
4
- // - a profile counts as "enabled" if the package is in dependencies OR in
5
- // dsh.profile.bundles;
6
- // - a missing optional peer never blocks plugin activation;
7
- // - the doctor is read-only by default, machine-readable with --json, and
8
- // only runs `dsh plugin add` when --apply is explicit.
9
- //
10
- // These fixtures live under TMP only. The package tree must stay byte-identical
11
- // (test/run.mjs snapshots it before the suite runs).
12
-
13
- import fs from 'node:fs';
14
- import os from 'node:os';
15
- import path from 'node:path';
16
- import { spawnSync } from 'node:child_process';
17
- import { fileURLToPath, pathToFileURL } from 'node:url';
18
-
19
- const HERE = path.dirname(fileURLToPath(import.meta.url));
20
- const ROOT = path.resolve(HERE, '..');
21
- const ENGRAM = '@kenz1117/dsh-engram';
22
- const LONGLIVE = '@jjh32456/dsh-longlive';
23
- const DOCTOR = path.join(ROOT, 'scripts', 'longlive-doctor.mjs');
24
- const PKG = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
25
-
26
- let tempSeq = 0;
27
- let depsPromise = null;
28
-
29
- function loadDeps() {
30
- depsPromise ??= import('../src/deps.js');
31
- return depsPromise.catch((error) => {
32
- throw new Error(`src/deps.js 不存在或无法加载(${error?.code ?? 'ERR'}):${error?.message ?? error}`);
33
- });
34
- }
35
-
36
- function newHome(TMP, label) {
37
- const dir = path.join(TMP, `deps-${label}-${process.pid}-${tempSeq++}`);
38
- fs.mkdirSync(dir, { recursive: true });
39
- return dir;
40
- }
41
-
42
- function writeProfile(home, name, { bundles = [], dependencies = {} } = {}) {
43
- const dir = path.join(home, 'profiles', name);
44
- fs.mkdirSync(dir, { recursive: true });
45
- fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({
46
- name: `dsh-profile-${name}`,
47
- private: true,
48
- dsh: { profile: { bundles } },
49
- dependencies,
50
- }, null, 2));
51
- }
52
-
53
- function doctor(home, args, TMP, { pathPrefix = null, env = {} } = {}) {
54
- const clean = { ...process.env };
55
- for (const key of Object.keys(clean)) {
56
- if (key.toUpperCase() === 'PATH' || key.toUpperCase() === 'DSH_BIN' || key.toUpperCase() === 'DSH_PROFILE') {
57
- delete clean[key];
58
- }
59
- }
60
- clean.DSH_HOME = home;
61
- clean.DSH_PROFILE = '';
62
- clean.PATH = pathPrefix ?? (process.env.PATH ?? process.env.Path ?? '');
63
- Object.assign(clean, env);
64
- return spawnSync(process.execPath, [DOCTOR, ...args], {
65
- cwd: ROOT,
66
- encoding: 'utf8',
67
- env: clean,
68
- timeout: 120000,
69
- windowsHide: true,
70
- });
71
- }
72
-
73
- function parseJson(run, assert, label) {
74
- const text = String(run.stdout ?? '').trim();
75
- let parsed;
76
- try {
77
- parsed = JSON.parse(text);
78
- } catch (error) {
79
- assert(false, `${label}: --json 输出不是合法 JSON(${error.message})\nstdout=${JSON.stringify(text)}\nstderr=${JSON.stringify(String(run.stderr ?? ''))}`);
80
- }
81
- return parsed;
82
- }
83
-
84
- function makeMockDsh(dir, TMP) {
85
- const log = path.join(TMP, `mock-dsh-${process.pid}-${tempSeq++}.log`);
86
- fs.mkdirSync(dir, { recursive: true });
87
- if (process.platform === 'win32') {
88
- fs.writeFileSync(path.join(dir, 'dsh.cmd'), [
89
- '@echo off',
90
- '>>"%DSH_DOCTOR_TEST_LOG%" echo %*',
91
- '1>&2 echo mock-dsh: cannot install (exit 7)',
92
- 'exit /b 7',
93
- '',
94
- ].join('\r\n'));
95
- } else {
96
- const file = path.join(dir, 'dsh');
97
- fs.writeFileSync(file, [
98
- '#!/bin/sh',
99
- 'printf "%s\\n" "$*" >> "$DSH_DOCTOR_TEST_LOG"',
100
- 'echo "mock-dsh: cannot install (exit 7)" >&2',
101
- 'exit 7',
102
- '',
103
- ].join('\n'));
104
- fs.chmodSync(file, 0o755);
105
- }
106
- return { log, path: dir };
107
- }
108
-
109
- export async function runDepsSuite({ runCase, runAsyncCase, assert, TMP }) {
110
- const base = TMP ?? fs.mkdtempSync(path.join(os.tmpdir(), 'longlive-deps-'));
111
-
112
- // DEP1 — a DSH home with no profiles directory must not throw or demand a dep.
113
- await runAsyncCase('DEP1', '无 profile 目录:不崩、status 空、ok=true', async () => {
114
- const { checkOptionalDeps } = await loadDeps();
115
- const home = newHome(base, 'empty');
116
- const result = checkOptionalDeps({ home, env: {} });
117
- assert(result && typeof result === 'object', 'checkOptionalDeps 必须返回对象');
118
- assert(
119
- JSON.stringify(Object.keys(result).sort()) === JSON.stringify(['missing', 'ok', 'present', 'profile']),
120
- `返回字段必须恰好是 ok/profile/missing/present,实际 ${JSON.stringify(Object.keys(result).sort())}`,
121
- );
122
- assert(result.ok === true, `无 profile 时 ok 应为 true,实际 ${result.ok}`);
123
- assert(result.profile === null, `无 profile 时 profile 应为 null,实际 ${JSON.stringify(result.profile)}`);
124
- assert(Array.isArray(result.missing) && result.missing.length === 0, `missing 应为空,实际 ${JSON.stringify(result.missing)}`);
125
- assert(Array.isArray(result.present) && result.present.length === 0, `present 应为空,实际 ${JSON.stringify(result.present)}`);
126
- });
127
-
128
- // DEP2 — longlive enabled, engram absent: ok=false, one missing entry.
129
- await runAsyncCase('DEP2', 'profile 启用 longlive 但缺 engram:missing 命中', async () => {
130
- const { checkOptionalDeps } = await loadDeps();
131
- const home = newHome(base, 'missing');
132
- writeProfile(home, 'solo', { bundles: [LONGLIVE] });
133
- const result = checkOptionalDeps({ home, env: {} });
134
- assert(result.ok === false, `缺 engram 时 ok 应为 false,实际 ${result.ok}`);
135
- assert(result.profile === 'solo', `应锁定启用了 longlive 的 profile=solo,实际 ${JSON.stringify(result.profile)}`);
136
- assert(result.missing.includes(ENGRAM), `missing 应包含 ${ENGRAM},实际 ${JSON.stringify(result.missing)}`);
137
- assert(!result.present.includes(ENGRAM), `present 不应包含 ${ENGRAM}`);
138
- });
139
-
140
- // DEP3 — dependencies is enough (no bundles entry needed).
141
- await runAsyncCase('DEP3', 'dependencies 里装了 engram:present', async () => {
142
- const { checkOptionalDeps } = await loadDeps();
143
- const home = newHome(base, 'deps');
144
- writeProfile(home, 'solo', {
145
- bundles: [LONGLIVE],
146
- dependencies: { [ENGRAM]: '0.11.3' },
147
- });
148
- const result = checkOptionalDeps({ home, env: {} });
149
- assert(result.ok === true, `装了 engram 时 ok 应为 true,实际 ${result.ok},missing=${JSON.stringify(result.missing)}`);
150
- assert(result.present.includes(ENGRAM), `present 应包含 ${ENGRAM},实际 ${JSON.stringify(result.present)}`);
151
- assert(result.missing.length === 0, `missing 应为空,实际 ${JSON.stringify(result.missing)}`);
152
- });
153
-
154
- // DEP3b — bundles alone also counts as enabled (host installs bundles by name).
155
- await runAsyncCase('DEP3b', 'bundles 里列了 engram:同样算已启用', async () => {
156
- const { checkOptionalDeps } = await loadDeps();
157
- const home = newHome(base, 'bundles');
158
- writeProfile(home, 'solo', { bundles: [LONGLIVE, ENGRAM], dependencies: {} });
159
- const result = checkOptionalDeps({ home, env: {} });
160
- assert(result.ok === true, `bundles 含 engram 时 ok 应为 true,实际 ${result.ok}`);
161
- assert(result.present.includes(ENGRAM), `bundles 含 engram 时应计入 present,实际 ${JSON.stringify(result.present)}`);
162
- });
163
-
164
- // DEP4 — --json is one valid JSON object with the documented shape.
165
- await runAsyncCase('DEP4', 'doctor --json 输出结构与退出码', async () => {
166
- const home = newHome(base, 'json');
167
- writeProfile(home, 'solo', { bundles: [LONGLIVE] });
168
- const run = doctor(home, ['--json'], base);
169
- assert(run.status === 1, `缺包时 --json 应 exit 1,实际 ${run.status};stderr=${String(run.stderr ?? '')}`);
170
- const json = parseJson(run, assert, 'DEP4');
171
- assert(json.ok === false, `json.ok 应为 false,实际 ${json.ok}`);
172
- assert(json.plugin === LONGLIVE, `json.plugin 应为 ${LONGLIVE},实际 ${json.plugin}`);
173
- assert(json.version === PKG.version, `json.version 应为 ${PKG.version},实际 ${json.version}`);
174
- assert(path.resolve(json.home) === path.resolve(home), `json.home 应为 ${home},实际 ${json.home}`);
175
- assert(json.profile === 'solo', `json.profile 应为 solo,实际 ${JSON.stringify(json.profile)}`);
176
- assert(Array.isArray(json.profiles) && json.profiles.includes('solo'), `json.profiles 应含 solo,实际 ${JSON.stringify(json.profiles)}`);
177
- assert(Array.isArray(json.dependencies) && json.dependencies.length >= 1, 'json.dependencies 应至少一项');
178
- const entry = json.dependencies.find((row) => row.name === ENGRAM);
179
- assert(entry, `json.dependencies 应含 ${ENGRAM},实际 ${JSON.stringify(json.dependencies)}`);
180
- assert(entry.present === false, `engram present 应为 false,实际 ${entry.present}`);
181
- assert(entry.optional === true, `engram optional 应为 true,实际 ${entry.optional}`);
182
- assert(entry.installCommand === `dsh plugin --profile solo add ${ENGRAM}`, `installCommand 应带 --profile solo,实际 ${entry.installCommand}`);
183
- assert(typeof entry.status === 'string' && entry.status.length > 0, 'status 必须是非空字符串');
184
- });
185
-
186
- // DEP5 — default mode is a readable table, not JSON, and exits 1 when missing.
187
- await runAsyncCase('DEP5', 'doctor 默认只读表格:缺包 exit 1', async () => {
188
- const home = newHome(base, 'table');
189
- writeProfile(home, 'solo', { bundles: [LONGLIVE] });
190
- const run = doctor(home, [], base);
191
- assert(run.status === 1, `缺包时默认模式应 exit 1,实际 ${run.status}`);
192
- const out = String(run.stdout ?? '');
193
- assert(out.includes(ENGRAM), `表格应列出 ${ENGRAM},实际输出:\n${out}`);
194
- assert(out.includes('缺失'), `表格应有“缺失”状态,实际输出:\n${out}`);
195
- assert(out.includes(`dsh plugin --profile solo add ${ENGRAM}`), `表格应给出可复制安装命令(带 --profile),实际输出:\n${out}`);
196
- assert(!out.trim().startsWith('{'), '默认模式不应输出 JSON');
197
- });
198
-
199
- // DEP6 — every scoped profile has the dep: exit 0.
200
- await runAsyncCase('DEP6', 'doctor 全部已启用:exit 0', async () => {
201
- const home = newHome(base, 'ready');
202
- writeProfile(home, 'solo', {
203
- bundles: [LONGLIVE, ENGRAM],
204
- dependencies: { [ENGRAM]: '0.11.3' },
205
- });
206
- const run = doctor(home, [], base);
207
- assert(run.status === 0, `全在时应 exit 0,实际 ${run.status};stdout=${String(run.stdout ?? '')};stderr=${String(run.stderr ?? '')}`);
208
- const out = String(run.stdout ?? '');
209
- assert(out.includes('已启用'), `表格应显示“已启用”,实际输出:\n${out}`);
210
- });
211
-
212
- // DEP7 — --help documents all four usages and exits 0.
213
- await runAsyncCase('DEP7', 'doctor --help:四种用法齐全', async () => {
214
- const home = newHome(base, 'help');
215
- const run = doctor(home, ['--help'], base);
216
- assert(run.status === 0, `--help 应 exit 0,实际 ${run.status}`);
217
- const out = String(run.stdout ?? '');
218
- assert(out.includes('--setup'), `--help 应说明 --setup,实际输出:\n${out}`);
219
- assert(out.includes('YES'), `--help 应说明大写 YES 确认,实际输出:\n${out}`);
220
- assert(out.includes('--json'), `--help 应说明 --json,实际输出:\n${out}`);
221
- assert(out.includes('--apply'), `--help 应说明 --apply,实际输出:\n${out}`);
222
- assert(out.includes('只读检查'), `--help 应说明默认只读用法,实际输出:\n${out}`);
223
- assert(out.includes('退出码') || out.includes('exit'), `--help 应说明退出码,实际输出:\n${out}`);
224
- });
225
-
226
- // DEP8 — --apply --yes really invokes dsh; a non-zero child is reported, not hidden.
227
- await runAsyncCase('DEP8', 'doctor --apply --yes:mock PATH 下被调用且失败可捕获', async () => {
228
- const home = newHome(base, 'apply');
229
- writeProfile(home, 'solo', { bundles: [LONGLIVE] });
230
- const mock = makeMockDsh(path.join(base, `mock-bin-${process.pid}-${tempSeq++}`), base);
231
- const basePath = process.env.PATH ?? process.env.Path ?? '';
232
- const run = doctor(home, ['--apply', '--yes', '--json'], base, {
233
- pathPrefix: `${mock.path}${path.delimiter}${basePath}`,
234
- env: { DSH_DOCTOR_TEST_LOG: mock.log },
235
- });
236
- assert(run.status === 1, `安装失败后应 exit 1,实际 ${run.status};stderr=${String(run.stderr ?? '')}`);
237
- const json = parseJson(run, assert, 'DEP8');
238
- assert(json.ok === false, `最终 ok 应为 false,实际 ${json.ok}`);
239
- assert(Array.isArray(json.applied) && json.applied.length === 1, `applied 应有一条,实际 ${JSON.stringify(json.applied)}`);
240
- const applied = json.applied[0];
241
- assert(applied.name === ENGRAM, `applied.name 应为 ${ENGRAM},实际 ${applied.name}`);
242
- assert(applied.ok === false, '子进程 exit 7 时 applied.ok 必须为 false');
243
- assert(applied.exitCode === 7, `applied.exitCode 应为 7,实际 ${JSON.stringify(applied.exitCode)}`);
244
- assert(String(applied.command).includes(`plugin --profile solo add ${ENGRAM}`), `command 应含 plugin --profile solo add ${ENGRAM},实际 ${applied.command}`);
245
- assert(String(applied.message ?? '').includes('mock-dsh'), `message 应保留子进程 stderr,实际 ${applied.message}`);
246
- assert(fs.existsSync(mock.log), `mock dsh 未被调用(没有 ${mock.log})`);
247
- const logText = fs.readFileSync(mock.log, 'utf8');
248
- assert(logText.includes('plugin --profile solo add'), `mock dsh 收到的参数不对:${logText}`);
249
- assert(logText.includes(ENGRAM), `mock dsh 未收到包名:${logText}`);
250
- });
251
-
252
- // DEP9 — activation with the optional peer missing must still register all 22 tools.
253
- await runAsyncCase('DEP9', '缺 engram 不阻塞插件启动,只警告一次', async () => {
254
- const home = newHome(base, 'startup');
255
- writeProfile(home, 'solo', { bundles: [LONGLIVE] });
256
- const warnings = [];
257
- const registered = [];
258
- const ctx = {
259
- tools: {
260
- register(def) {
261
- registered.push(def);
262
- return () => {};
263
- },
264
- },
265
- logger() {
266
- return { warn: (text) => warnings.push(String(text)) };
267
- },
268
- };
269
- const { apply } = await import('../src/index.js');
270
- const { TOOLS: entryTools } = await import('../src/tools.js');
271
- const count = await apply(ctx, {
272
- home,
273
- env: {},
274
- heartbeatMs: 0,
275
- recoverOnStart: false,
276
- defineTool: (definition) => definition,
277
- routerRuntime: 'off',
278
- });
279
- assert(count === entryTools.length, `apply 应返回注册数 ${entryTools.length},实际 ${count}`);
280
- assert(registered.length === entryTools.length, `应注册 ${entryTools.length} 个工具,实际 ${registered.length}`);
281
- const depWarning = warnings.find((text) => text.includes(ENGRAM));
282
- assert(depWarning, `应输出包含 ${ENGRAM} 的警告,实际警告:${JSON.stringify(warnings)}`);
283
- assert(depWarning.includes(`可选依赖缺失:${ENGRAM}`), `警告应以「可选依赖缺失:<包名>」列出,实际:${depWarning}`);
284
- assert(depWarning.includes('--setup'), `警告应指向 --setup 向导,实际:${depWarning}`);
285
- assert(depWarning.includes('会先列清单再要你确认'), `警告应说明先列清单再确认,实际:${depWarning}`);
286
- });
287
-
288
- // DEP10 — the published metadata must declare the optional peer and ship the tools.
289
- await runAsyncCase('DEP10', 'package.json:可选 peer + files 白名单', async () => {
290
- assert(PKG.peerDependencies?.[ENGRAM] === '>=0.11.0', `peerDependencies 应声明 ${ENGRAM} >=0.11.0,实际 ${JSON.stringify(PKG.peerDependencies?.[ENGRAM])}`);
291
- assert(PKG.peerDependenciesMeta?.[ENGRAM]?.optional === true, `peerDependenciesMeta.${ENGRAM}.optional 应为 true`);
292
- assert(Array.isArray(PKG.files), 'package.json files 必须是数组');
293
- assert(PKG.files.includes('scripts/longlive-doctor.mjs'), 'files 白名单缺少 scripts/longlive-doctor.mjs');
294
- assert(PKG.files.includes('test/deps.test.mjs'), 'files 白名单缺少 test/deps.test.mjs');
295
- assert(PKG.files.includes('src/*.js'), 'files 白名单应保留 src/*.js(覆盖 src/deps.js)');
296
- });
297
- }
298
-
299
- // ── 单跑入口(被 run.mjs import 时不打印)──────────────────────────────
300
- const invokedDirectly = Boolean(process.argv[1])
301
- && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url;
302
-
303
- if (invokedDirectly) {
304
- const results = [];
305
- const assert = (cond, message) => {
306
- if (!cond) throw new Error(message || 'assertion failed');
307
- };
308
- const runCase = (id, name, fn) => {
309
- const started = Date.now();
310
- try {
311
- const detail = fn();
312
- results.push({ id, ok: true, detail, ms: Date.now() - started });
313
- } catch (error) {
314
- results.push({ id, ok: false, detail: error.message, ms: Date.now() - started });
315
- }
316
- const last = results[results.length - 1];
317
- console.log(`${last.ok ? 'PASS' : 'FAIL'} ${last.id} ${name} ${last.detail ?? ''}`);
318
- };
319
- const runAsyncCase = async (id, name, fn) => {
320
- const started = Date.now();
321
- try {
322
- const detail = await fn();
323
- results.push({ id, ok: true, detail, ms: Date.now() - started });
324
- } catch (error) {
325
- results.push({ id, ok: false, detail: error.message, ms: Date.now() - started });
326
- }
327
- const last = results[results.length - 1];
328
- console.log(`${last.ok ? 'PASS' : 'FAIL'} ${last.id} ${name} ${last.detail ?? ''}`);
329
- };
330
-
331
- const directTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'longlive-deps-direct-'));
332
- try {
333
- await runDepsSuite({ runCase, runAsyncCase, assert, TMP: directTmp });
334
- } catch (error) {
335
- console.error(`suite crashed: ${error?.stack ?? error}`);
336
- process.exitCode = 1;
337
- }
338
- const failedCases = results.filter((entry) => !entry.ok);
339
- const totalMs = results.reduce((sum, entry) => sum + entry.ms, 0);
340
- console.log(`\n${results.length - failedCases.length} passed, ${failedCases.length} failed, ${totalMs}ms total`);
341
- if (failedCases.length > 0) process.exitCode = 1;
342
- }
@@ -1,297 +0,0 @@
1
- // doctor --setup acceptance tests (0.8.0-rc.10).
2
- //
3
- // The wizard is a consent surface, so these tests assert the invariants that
4
- // matter more than formatting: cancellation is inert (no spawn, no file writes,
5
- // exit 0), only the literal uppercase YES proceeds, consent is recorded in the
6
- // audit log, and a non-TTY run refuses to guess.
7
- //
8
- // stdin is a pipe under spawnSync, so DSH_DOCTOR_TEST_TTY=1 is the explicit
9
- // test seam that makes the doctor treat it as a terminal. It is never set by
10
- // the product; only these tests (and the BUILD.md demo) set it.
11
-
12
- import fs from 'node:fs';
13
- import os from 'node:os';
14
- import path from 'node:path';
15
- import { spawnSync } from 'node:child_process';
16
- import { fileURLToPath, pathToFileURL } from 'node:url';
17
-
18
- const HERE = path.dirname(fileURLToPath(import.meta.url));
19
- const ROOT = path.resolve(HERE, '..');
20
- const DOCTOR = path.join(ROOT, 'scripts', 'longlive-doctor.mjs');
21
- const ENGRAM = '@kenz1117/dsh-engram';
22
- const LONGLIVE = '@jjh32456/dsh-longlive';
23
-
24
- function writeProfile(home, name, { bundles = [], dependencies = {} } = {}) {
25
- const dir = path.join(home, 'profiles', name);
26
- fs.mkdirSync(dir, { recursive: true });
27
- const manifest = { name, dependencies };
28
- if (bundles.length > 0) manifest.dsh = { profile: { bundles } };
29
- fs.writeFileSync(path.join(dir, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
30
- return dir;
31
- }
32
-
33
- function baseEnv() {
34
- const env = {};
35
- for (const [key, value] of Object.entries(process.env)) {
36
- if (/^(PATH|DSH_HOME|DSH_BIN|DSH_PROFILE|DSH_DOCTOR_TEST_TTY|DSH_DOCTOR_TEST_LOG)$/i.test(key)) continue;
37
- env[key] = value;
38
- }
39
- return env;
40
- }
41
-
42
- function runDoctor(home, args, options = {}) {
43
- const env = baseEnv();
44
- env.DSH_HOME = home;
45
- env.DSH_PROFILE = '';
46
- env.PATH = options.pathPrefix ?? process.env.PATH ?? '';
47
- if (options.tty !== false) env.DSH_DOCTOR_TEST_TTY = '1';
48
- if (options.log) env.DSH_DOCTOR_TEST_LOG = options.log;
49
- Object.assign(env, options.env ?? {});
50
- return spawnSync(process.execPath, [DOCTOR, ...args], {
51
- input: options.input ?? '',
52
- encoding: 'utf8',
53
- env,
54
- cwd: options.cwd ?? ROOT,
55
- windowsHide: true,
56
- timeout: 120000,
57
- });
58
- }
59
-
60
- /**
61
- * A fake `dsh` on PATH. It records its argv to %DSH_DOCTOR_TEST_LOG% and exits
62
- * with the requested code; it deliberately does NOT touch any profile, so the
63
- * wizard's success semantics must come from the child exit code, not from a
64
- * post-install recheck.
65
- */
66
- function makeMockDsh(dir, { exitCode = 0, stderr = '' } = {}) {
67
- fs.mkdirSync(dir, { recursive: true });
68
- if (process.platform === 'win32') {
69
- const lines = [
70
- '@echo off',
71
- '>>"%DSH_DOCTOR_TEST_LOG%" echo %*',
72
- ];
73
- if (stderr) lines.push(`1>&2 echo ${stderr}`);
74
- lines.push(`exit /b ${exitCode}`);
75
- fs.writeFileSync(path.join(dir, 'dsh.cmd'), `${lines.join('\r\n')}\r\n`, 'utf8');
76
- }
77
- const sh = [
78
- '#!/bin/sh',
79
- 'printf \'%s\\n\' "$*" >> "$DSH_DOCTOR_TEST_LOG"',
80
- ];
81
- if (stderr) sh.push(`printf '%s\\n' '${stderr}' 1>&2`);
82
- sh.push(`exit ${exitCode}`);
83
- fs.writeFileSync(path.join(dir, 'dsh'), `${sh.join('\n')}\n`, 'utf8');
84
- try {
85
- fs.chmodSync(path.join(dir, 'dsh'), 0o755);
86
- } catch {
87
- /* Windows does not need the bit */
88
- }
89
- return dir;
90
- }
91
-
92
- function auditLines(home) {
93
- const file = path.join(home, 'longlive-doctor-audit.log');
94
- if (!fs.existsSync(file)) return [];
95
- return fs.readFileSync(file, 'utf8').split(/\r?\n/).filter((line) => line.trim() !== '');
96
- }
97
-
98
- function assertCancelled(run, home, logPath, assert) {
99
- assert(run.status === 0, `取消应 exit 0,实际 ${run.status};stderr=${run.stderr}`);
100
- assert(run.stdout.includes('已取消'), `取消应打印「已取消」,实际 stdout=${run.stdout}`);
101
- assert(run.stdout.includes('未做任何改动'), `取消应打印「未做任何改动」,实际 stdout=${run.stdout}`);
102
- assert(!fs.existsSync(home), `取消不得创建/改动 DSH home,实际存在:${home}`);
103
- assert(!fs.existsSync(path.join(home, 'longlive-doctor-audit.log')), '取消不得写 audit 日志');
104
- assert(!fs.existsSync(logPath), `取消不得 spawn dsh(mock 日志存在):${logPath}`);
105
- }
106
-
107
- export async function runDoctorSetupSuite({ runCase, runAsyncCase, assert, TMP }) {
108
- const base = path.join(TMP, 'doctor-setup');
109
- fs.mkdirSync(base, { recursive: true });
110
- const loadDeps = () => import('../src/deps.js');
111
-
112
- // SETUP1 — "no" cancels: exit 0, nothing spawned, nothing written.
113
- await runCase('SETUP1', '--setup 输入 no:取消、exit 0、零副作用', () => {
114
- const home = path.join(base, 'cancel-no', 'home');
115
- const mock = path.join(base, 'cancel-no', 'bin');
116
- const logPath = path.join(base, 'cancel-no', 'dsh.log');
117
- makeMockDsh(mock);
118
- const run = runDoctor(home, ['--setup'], { input: 'no\n', pathPrefix: mock, log: logPath });
119
- assertCancelled(run, home, logPath, assert);
120
- assert(run.stdout.includes(`plugin add ${ENGRAM}`), `计划里应有确切命令,实际 stdout=${run.stdout}`);
121
- assert(run.stdout.includes('>=0.11.0'), `计划里应有版本约束,实际 stdout=${run.stdout}`);
122
- return 'exit 0 · no spawn · home untouched';
123
- });
124
-
125
- // SETUP2 — lowercase yes is not consent.
126
- await runCase('SETUP2', '--setup 输入小写 yes:视为取消', () => {
127
- const home = path.join(base, 'cancel-lower', 'home');
128
- const mock = path.join(base, 'cancel-lower', 'bin');
129
- const logPath = path.join(base, 'cancel-lower', 'dsh.log');
130
- makeMockDsh(mock);
131
- const run = runDoctor(home, ['--setup'], { input: 'yes\n', pathPrefix: mock, log: logPath });
132
- assertCancelled(run, home, logPath, assert);
133
- return 'lowercase yes rejected as cancel';
134
- });
135
-
136
- // SETUP3 — literal YES: exactly one plugin add, typed-YES audit line.
137
- await runCase('SETUP3', '--setup 输入 YES:只执行一条 plugin add 并写 audit', () => {
138
- const caseDir = path.join(base, 'confirm');
139
- const home = path.join(caseDir, 'home');
140
- writeProfile(home, 'work', { bundles: [LONGLIVE] });
141
- const mock = makeMockDsh(path.join(caseDir, 'bin'));
142
- const logPath = path.join(caseDir, 'dsh.log');
143
- const run = runDoctor(home, ['--setup'], {
144
- input: 'YES\n', pathPrefix: mock, log: logPath, env: { DSH_DOCTOR_TEST_LOG: logPath },
145
- });
146
- assert(run.status === 0, `YES 安装成功应 exit 0,实际 ${run.status};stdout=${run.stdout} stderr=${run.stderr}`);
147
- const calls = fs.readFileSync(logPath, 'utf8').split(/\r?\n/).filter((line) => line.trim() !== '');
148
- assert(calls.length === 1, `只应 spawn 一次 dsh,实际 ${calls.length} 次:${JSON.stringify(calls)}`);
149
- assert(calls[0].trim() === `plugin --profile work add ${ENGRAM}`, `命令应为 plugin --profile work add ${ENGRAM},实际 ${JSON.stringify(calls[0])}`);
150
- const lines = auditLines(home);
151
- assert(lines.length === 1, `应写一行 audit,实际 ${lines.length} 行:${JSON.stringify(lines)}`);
152
- const entry = JSON.parse(lines[0]);
153
- assert(entry.consent === 'typed-YES', `audit consent 应为 typed-YES,实际 ${JSON.stringify(entry.consent)}`);
154
- assert(typeof entry.timestamp === 'string' && !Number.isNaN(Date.parse(entry.timestamp)), 'audit timestamp 应为可解析时间');
155
- assert(typeof entry.user === 'string' && entry.user !== '', 'audit user 不应为空');
156
- assert(Array.isArray(entry.deps) && entry.deps.includes(ENGRAM), 'audit deps 应包含缺失包');
157
- assert(entry.result === 'ok', `audit result 应为 ok,实际 ${JSON.stringify(entry.result)}`);
158
- assert(entry.exitCode === 0, `audit exitCode 应为 0,实际 ${JSON.stringify(entry.exitCode)}`);
159
- assert(run.stdout.includes('重启宿主后生效'), `成功应打印重启指引,实际 stdout=${run.stdout}`);
160
- return 'one plugin add · typed-YES audit · restart guidance';
161
- });
162
-
163
- // SETUP4 — child exit 7: non-zero, stderr excerpt, failed audit.
164
- await runCase('SETUP4', '--setup YES + mock exit 7:非零退出、stderr 片段、audit failed', () => {
165
- const caseDir = path.join(base, 'fail');
166
- const home = path.join(caseDir, 'home');
167
- writeProfile(home, 'work', { bundles: [LONGLIVE] });
168
- const mock = makeMockDsh(path.join(caseDir, 'bin'), { exitCode: 7, stderr: 'mock-dsh: cannot install (exit 7)' });
169
- const logPath = path.join(caseDir, 'dsh.log');
170
- const run = runDoctor(home, ['--setup'], {
171
- input: 'YES\n', pathPrefix: mock, log: logPath, env: { DSH_DOCTOR_TEST_LOG: logPath },
172
- });
173
- assert(run.status !== 0, `安装失败应非零退出,实际 ${run.status}`);
174
- assert(run.stderr.includes('mock-dsh'), `stderr 应含子进程输出片段,实际 stderr=${run.stderr}`);
175
- const lines = auditLines(home);
176
- assert(lines.length === 1, `失败也应写一行 audit,实际 ${lines.length} 行`);
177
- const entry = JSON.parse(lines[0]);
178
- assert(entry.result === 'failed', `audit result 应为 failed,实际 ${JSON.stringify(entry.result)}`);
179
- assert(entry.exitCode !== 0, `audit exitCode 应非零,实际 ${JSON.stringify(entry.exitCode)}`);
180
- assert(Array.isArray(entry.deps) && entry.deps.includes(ENGRAM), 'audit deps 应记录尝试安装的包');
181
- return 'exit 1 · stderr excerpt · failed audit';
182
- });
183
-
184
- // SETUP5 — unattended: non-TTY + --yes executes and records consent "--yes".
185
- await runCase('SETUP5', '--setup --yes 非 TTY:跳过交互直接执行', () => {
186
- const caseDir = path.join(base, 'unattended');
187
- const home = path.join(caseDir, 'home');
188
- writeProfile(home, 'work', { bundles: [LONGLIVE] });
189
- const mock = makeMockDsh(path.join(caseDir, 'bin'));
190
- const logPath = path.join(caseDir, 'dsh.log');
191
- const run = runDoctor(home, ['--setup', '--yes'], {
192
- input: '', tty: false, pathPrefix: mock, log: logPath, env: { DSH_DOCTOR_TEST_LOG: logPath },
193
- });
194
- assert(run.status === 0, `--setup --yes 应成功,实际 ${run.status};stderr=${run.stderr}`);
195
- assert(run.stdout.includes(`plugin --profile work add ${ENGRAM}`), '仍应先打印计划');
196
- const calls = fs.readFileSync(logPath, 'utf8').split(/\r?\n/).filter((line) => line.trim() !== '');
197
- assert(calls.length === 1 && calls[0].trim() === `plugin --profile work add ${ENGRAM}`, `应执行一条命令,实际 ${JSON.stringify(calls)}`);
198
- const entry = JSON.parse(auditLines(home)[0]);
199
- assert(entry.consent === '--yes', `audit consent 应为 --yes,实际 ${JSON.stringify(entry.consent)}`);
200
- assert(entry.result === 'ok', `audit result 应为 ok,实际 ${JSON.stringify(entry.result)}`);
201
- return 'non-TTY --yes executed · consent --yes';
202
- });
203
-
204
- // SETUP6 — non-TTY without --yes must refuse with exit 2, before any write.
205
- await runCase('SETUP6', '--setup 非 TTY 无 --yes:exit 2 且什么都不做', () => {
206
- const home = path.join(base, 'refuse', 'home');
207
- const mock = path.join(base, 'refuse', 'bin');
208
- const logPath = path.join(base, 'refuse', 'dsh.log');
209
- makeMockDsh(mock);
210
- const run = runDoctor(home, ['--setup'], { input: '', tty: false, pathPrefix: mock, log: logPath });
211
- assert(run.status === 2, `非 TTY 无 --yes 应 exit 2,实际 ${run.status}`);
212
- assert(run.stderr.includes('--setup 在非交互环境'), `提示应说明 --setup 在非交互环境的规则,实际 stderr=${run.stderr}`);
213
- assert(run.stderr.includes('--yes'), `提示应告诉用户用 --yes,实际 stderr=${run.stderr}`);
214
- assert(!fs.existsSync(logPath), '拒绝时不得 spawn dsh');
215
- assert(!fs.existsSync(path.join(home, 'longlive-doctor-audit.log')), '拒绝时不得写 audit');
216
- return 'exit 2 · --yes hint · inert';
217
- });
218
-
219
- // SETUP7 — startup copy is a pure function; it must point at --setup.
220
- await runAsyncCase('SETUP7', '启动指引文案包含 --setup 与确认提示', async () => {
221
- const { formatMissingDepsWarning } = await loadDeps();
222
- assert(typeof formatMissingDepsWarning === 'function', 'src/deps.js 应导出 formatMissingDepsWarning');
223
- const message = formatMissingDepsWarning([ENGRAM]);
224
- assert(typeof message === 'string', '缺包时应有文案');
225
- assert(message.includes(`可选依赖缺失:${ENGRAM}`), `文案应以「可选依赖缺失:<包名>」开头,实际:${message}`);
226
- assert(message.includes('--setup'), `文案应指向 --setup,实际:${message}`);
227
- assert(message.includes('会先列清单再要你确认'), `文案应说明先列清单再确认,实际:${message}`);
228
- assert(formatMissingDepsWarning([]) === null, '无缺失时应返回 null,保持安静');
229
- return 'startup copy points at --setup';
230
- });
231
-
232
- // SETUP8 — EOF is a cancellation, not a hang or a crash.
233
- await runCase('SETUP8', '--setup 遇到 EOF:取消且零副作用', () => {
234
- const home = path.join(base, 'eof', 'home');
235
- const mock = path.join(base, 'eof', 'bin');
236
- const logPath = path.join(base, 'eof', 'dsh.log');
237
- makeMockDsh(mock);
238
- const run = runDoctor(home, ['--setup'], { input: '', pathPrefix: mock, log: logPath });
239
- assertCancelled(run, home, logPath, assert);
240
- return 'EOF treated as cancel';
241
- });
242
-
243
- // SETUP9 — --help must advertise the new main entry point.
244
- await runCase('SETUP9', '--help 列出 --setup 与 YES 确认', () => {
245
- const home = path.join(base, 'help', 'home');
246
- const run = runDoctor(home, ['--help'], { tty: false });
247
- assert(run.status === 0, `--help 应 exit 0,实际 ${run.status}`);
248
- assert(run.stdout.includes('--setup'), '--help 应提到 --setup');
249
- assert(run.stdout.includes('YES'), '--help 应说明需要输入大写 YES');
250
- return 'help advertises --setup';
251
- });
252
- }
253
-
254
- // ── 单跑入口(被 run.mjs import 时不打印)──────────────────────────────
255
- const invokedDirectly = Boolean(process.argv[1])
256
- && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url;
257
-
258
- if (invokedDirectly) {
259
- const results = [];
260
- const assert = (cond, message) => {
261
- if (!cond) throw new Error(message || 'assertion failed');
262
- };
263
- const runCase = (id, name, fn) => {
264
- const started = Date.now();
265
- try {
266
- const detail = fn();
267
- results.push({ id, ok: true, detail, ms: Date.now() - started });
268
- } catch (error) {
269
- results.push({ id, ok: false, detail: error.message, ms: Date.now() - started });
270
- }
271
- const last = results[results.length - 1];
272
- console.log(`${last.ok ? 'PASS' : 'FAIL'} ${last.id} ${name} ${last.detail ?? ''}`);
273
- };
274
- const runAsyncCase = async (id, name, fn) => {
275
- const started = Date.now();
276
- try {
277
- const detail = await fn();
278
- results.push({ id, ok: true, detail, ms: Date.now() - started });
279
- } catch (error) {
280
- results.push({ id, ok: false, detail: error.message, ms: Date.now() - started });
281
- }
282
- const last = results[results.length - 1];
283
- console.log(`${last.ok ? 'PASS' : 'FAIL'} ${last.id} ${name} ${last.detail ?? ''}`);
284
- };
285
-
286
- const directTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'longlive-doctor-setup-direct-'));
287
- try {
288
- await runDoctorSetupSuite({ runCase, runAsyncCase, assert, TMP: directTmp });
289
- } catch (error) {
290
- console.error(`suite crashed: ${error?.stack ?? error}`);
291
- process.exitCode = 1;
292
- }
293
- const failedCases = results.filter((entry) => !entry.ok);
294
- const totalMs = results.reduce((sum, entry) => sum + entry.ms, 0);
295
- console.log(`\n${results.length - failedCases.length} passed, ${failedCases.length} failed, ${totalMs}ms total`);
296
- if (failedCases.length > 0) process.exitCode = 1;
297
- }