dsh-ppt-studio 1.1.2 → 1.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -18
- package/agent-presets/ppt/agent.cordis.yml +22 -15
- package/cordis.patch.yml +19 -14
- package/docs/01-/351/234/200/346/261/202/344/270/216/347/233/256/346/240/207.md +1 -1
- package/docs/02-/346/212/200/346/234/257/346/212/245/345/221/212.md +5 -5
- package/docs/03-/346/233/264/346/226/260/346/227/245/345/277/227.md +105 -0
- package/docs/05-/350/277/255/344/273/243/346/265/201/347/250/213.md +34 -3
- package/docs/06-/350/257/204/345/256/241/344/270/216/346/265/213/350/257/225.md +5 -3
- package/examples/fx/fx-pro/preview/04_pages_04_chart.html +1 -1
- package/examples/fx/fx-pro/preview/deck.html +1 -1
- package/examples/fx/fx-pro/preview/measured.json +1 -1
- package/examples/fx/fx-pro.pptx +0 -0
- package/examples/fx/seed.pptx +0 -0
- package/examples/fx/single.pptx +0 -0
- package/examples/fx/spliced.pptx +0 -0
- package/examples/smoke/imported/deck.yaml +6 -6
- package/examples/smoke/imported/import-styles.json +1 -33
- package/examples/smoke/imported/loss-audit.json +99 -0
- package/examples/smoke/imported/pages/slide_03.yaml +5 -36
- package/examples/smoke/imported/preview-rt/03_pages_slide_03.html +1 -1
- package/examples/smoke/imported/preview-rt/deck.html +1 -1
- package/examples/smoke/imported/preview-rt/layout.json +26 -78
- package/examples/smoke/imported/reference/previews/03.png +0 -0
- package/examples/smoke/imported/source.pptx +0 -0
- package/examples/smoke/out-smoke.pptx +0 -0
- package/examples/smoke/preview/03_pages_03_chart.html +1 -1
- package/examples/smoke/preview/deck.html +1 -1
- package/lib/capabilities.js +189 -0
- package/lib/home.js +32 -0
- package/lib/index.js +278 -112
- package/lib/office-qa.js +111 -0
- package/lib/pptd/export-pptx.js +159 -10
- package/lib/pptd/import-pptx.js +12 -0
- package/lib/pptd/loss-audit.js +363 -0
- package/lib/pptd/nativeChart.js +454 -0
- package/lib/pptd/schema.js +48 -1
- package/lib/pptd/svgCharts.js +180 -37
- package/lib/pptxPy.js +96 -11
- package/lib/preset-delivery.js +107 -9
- package/lib/preview-server.js +7 -5
- package/lib/router.js +3 -2
- package/lib/state.js +8 -6
- package/lib/templates.js +4 -4
- package/lib/tools.js +81 -21
- package/package.json +2 -1
- package/scripts/audit-manual-facts.mjs +4 -2
- package/scripts/e2e-1.0.mjs +2 -2
- package/scripts/fixtures/workflow-baseline.json +10 -10
- package/scripts/install.mjs +13 -11
- package/scripts/smoke.mjs +390 -66
- package/scripts/verify-loss-audit.mjs +245 -0
- package/scripts/verify-native-charts.mjs +529 -0
- package/scripts/verify-preset-scope.mjs +593 -0
- package/skills/ppt-studio-data/SKILL.md +5 -7
- package/skills/ppt-studio-manual/SKILL.md +2 -2
package/scripts/smoke.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { join, dirname } from 'node:path'
|
|
|
6
6
|
import { fileURLToPath } from 'node:url'
|
|
7
7
|
import { rm, mkdir } from 'node:fs/promises'
|
|
8
8
|
import { existsSync } from 'node:fs'
|
|
9
|
+
import { homedir } from 'node:os'
|
|
9
10
|
import zlib from 'node:zlib'
|
|
10
11
|
import { resolveDeck } from '../lib/pptd/schema.js'
|
|
11
12
|
import { renderDeck } from '../lib/pptd/render-html.js'
|
|
@@ -17,6 +18,16 @@ import { zipRead, decodeXml } from '../lib/zips.js'
|
|
|
17
18
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
18
19
|
const smokeDir = join(root, 'examples', 'smoke')
|
|
19
20
|
|
|
21
|
+
// ── 全局用户目录隔离(2026-09-26,方案 B 第二批)──────────────────────────────────────────
|
|
22
|
+
// 为什么:`state.js` / `preview-server.js` 的用户目录此前是**模块常量** `homedir()/.dsh`(忽略 DSH_HOME),
|
|
23
|
+
// 而 `templates.js` / `index.js`(diag) 是调用时求值 ⇒ 本机 `npm test` 会把 smoke 模板与预览缓存写进
|
|
24
|
+
// 开发机**真实**的 `~/.dsh/ppt-studio/`(实测复现、清理过两次),与下面那句"不碰真实目录"的承诺冲突。
|
|
25
|
+
// 现在四处都统一到 `src/home.js`(调用时求值)⇒ 这里把 DSH_HOME 指到构建目录内的临时 home,
|
|
26
|
+
// **整轮 smoke 的用户层读写都关在里面**,真实用户目录一个字节都不碰。位置必须在任何用户目录解析之前。
|
|
27
|
+
const PREV_SMOKE_HOME = process.env.DSH_HOME
|
|
28
|
+
const SMOKE_HOME = join(smokeDir, '.tmp-home')
|
|
29
|
+
process.env.DSH_HOME = SMOKE_HOME
|
|
30
|
+
|
|
20
31
|
let pass = 0
|
|
21
32
|
let fail = 0
|
|
22
33
|
const ok = (name, cond, extra = '') => {
|
|
@@ -396,6 +407,24 @@ ok('v0.3.2:D4 导入探测跨页带 → 建议 safeArea 注释(未启用)'
|
|
|
396
407
|
const bandPageYaml = await (await import('node:fs/promises')).readFile(join(bandDeck, 'imported', 'pages', 'slide_01.yaml'), 'utf8')
|
|
397
408
|
ok('v0.4:导入保样式——text color/fontFamily/italic 回环', bandPageYaml.includes('fontFamily') && bandPageYaml.includes('2E4B9F'), bandPageYaml.split('\n').filter((l) => l.includes('fontFamily') || l.includes('color') || l.includes('italic')).join(' | '))
|
|
398
409
|
ok('v0.4:import-styles.json 生成(含样式清单)', (await import('node:fs/promises')).stat(join(bandDeck, 'imported', 'import-styles.json')).then(() => true).catch(() => false))
|
|
410
|
+
// ── 16b. 启发 B(审计半,2026-09-26 第二轮):重建有损审计 ────────────────────────────────
|
|
411
|
+
// 为什么必须钉:我们的导出来自 DSL 中间层,**不支持**阴影/动画/超链接/切换/SmartArt 等;
|
|
412
|
+
// "整册重渲"对既有精美 PPT 是有损的,而这件事此前**不可见**(用户以为无损)。审计要放进导入产物与输出行。
|
|
413
|
+
// 判据来源:`ppt_import` 落盘 `<outDir>/loss-audit.json`(独立文件,理由见 import-pptx.js 注释)。
|
|
414
|
+
const { lossAuditLine } = await import('../lib/pptd/loss-audit.js')
|
|
415
|
+
const lossAuditJson = JSON.parse(await (await import('node:fs/promises')).readFile(join(bandDeck, 'imported', 'loss-audit.json'), 'utf8'))
|
|
416
|
+
ok('启发B:ppt_import 落盘 loss-audit.json(7 类齐全且每类 count === evidence.length)',
|
|
417
|
+
lossAuditJson.version === 1
|
|
418
|
+
&& lossAuditJson.categories.map((c) => c.id).join(',') === 'animation,effect,hyperlink,transition,smartart,media,gradient'
|
|
419
|
+
&& lossAuditJson.categories.every((c) => c.count === c.evidence.length),
|
|
420
|
+
`version=${lossAuditJson.version}|类别=${lossAuditJson.categories.map((c) => c.id).join(',')}`)
|
|
421
|
+
ok('启发B:自产导出回环审计 **0 命中**(防误报:空 a:effectLst / 主题 effectStyleLst 不算阴影)',
|
|
422
|
+
lossAuditJson.clean === true && lossAuditJson.total === 0 && String(bandImp.lossLine).includes('未发现'),
|
|
423
|
+
`clean=${lossAuditJson.clean} total=${lossAuditJson.total}`)
|
|
424
|
+
ok('启发B:干净源稿的输出行**不制造噪声**(无 ⚠、无 ppt_splice 建议)',
|
|
425
|
+
!String(bandImp.lossLine).includes('ppt_splice') && !String(bandImp.lossLine).includes('⚠'), String(bandImp.lossLine).slice(0, 90))
|
|
426
|
+
ok('启发B:输出行由报告生成(lossLine === lossAuditLine(落盘 JSON)),不是手写文案',
|
|
427
|
+
bandImp.lossLine === lossAuditLine(lossAuditJson), String(bandImp.lossLine).slice(0, 90))
|
|
399
428
|
ok('v0.4:theme 聚合建议块写入 deck.yaml', bandDeckYaml.includes('# 建议主题') && bandDeckYaml.includes('textStyles'))
|
|
400
429
|
const mnErr = validatePage({ elements: [{ elementId: 'l', elementType: 'line', points: [[10, 10], [100, 10], [200, 10]] }] }, 'test.yaml')
|
|
401
430
|
ok('v0.4:多点折线显式报错(P2-3,不再静默截断)', mnErr !== null && mnErr.messages.some((m) => m.includes('仅支持 2 点')), mnErr?.messages?.join('; '))
|
|
@@ -437,13 +466,10 @@ ok('v0.6.2:随包模板每套都自包含(元数据齐 + 有预览图)',
|
|
|
437
466
|
builtins.map((t) => `${t.id}:${t.preview ? 'png' : 'MISSING'}`).join(' '))
|
|
438
467
|
|
|
439
468
|
// ── 17b. v1.0.6 用户自建模板库:两层目录 + 入库(.pptx)/ 物化 / 删除 ──
|
|
440
|
-
// DSH_HOME
|
|
441
|
-
//
|
|
469
|
+
// 用户层目录现在是**整轮隔离**的(见文件顶部:DSH_HOME → `examples/smoke/.tmp-home`),
|
|
470
|
+
// 所以这里不必再单独切换 DSH_HOME,也不会碰到开发机/CI 上真实的 `~/.dsh/ppt-studio/templates`。
|
|
442
471
|
{
|
|
443
|
-
const libHome =
|
|
444
|
-
await rm(libHome, { recursive: true, force: true })
|
|
445
|
-
const prevHome = process.env.DSH_HOME
|
|
446
|
-
process.env.DSH_HOME = libHome
|
|
472
|
+
const libHome = SMOKE_HOME
|
|
447
473
|
try {
|
|
448
474
|
const libUserDir = tplMod.userTemplatesDir()
|
|
449
475
|
ok('v1.0.6:用户模板层在插件包之外(升级重新物化包也丢不了)',
|
|
@@ -474,9 +500,8 @@ ok('v0.6.2:随包模板每套都自包含(元数据齐 + 有预览图)',
|
|
|
474
500
|
!!libRefuse && /随包/.test(libRefuse.message) && existsSync(join(tplMod.TEMPLATES_DIR, 'business-blue', 'template.yaml')),
|
|
475
501
|
libRefuse?.message?.slice(0, 46) ?? '(没拦住)')
|
|
476
502
|
} finally {
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
await rm(libHome, { recursive: true, force: true })
|
|
503
|
+
// 这里**不**恢复 DSH_HOME、也不删 SMOKE_HOME:隔离是本轮 smoke 的全局前提(见文件顶部),
|
|
504
|
+
// 整轮结束后统一恢复与清理。
|
|
480
505
|
}
|
|
481
506
|
}
|
|
482
507
|
let tplAllOk = true
|
|
@@ -555,7 +580,7 @@ ok('v0.7:内置模板正式页(首母版副本)0 错误', tplV0.text.split
|
|
|
555
580
|
// 收纳清洗升级:registerTemplate 声明出界元素 + 剩余错误分类(bandDeck safeArea 外元素)
|
|
556
581
|
const reg2 = await tplMod.registerTemplate(bandDeck, { id: `smoke-wash-${Date.now().toString(36)}`, name: '洗涤测试' }, {})
|
|
557
582
|
ok('v0.7:收纳清洗——出界声明/重叠声明/剩余分类进入 meta', typeof reg2.meta.cleanup === 'string' && reg2.meta.cleanup.includes('剩余'), reg2.meta.cleanup ?? '')
|
|
558
|
-
await (await import('node:fs/promises')).rm(join(tplMod.
|
|
583
|
+
await (await import('node:fs/promises')).rm(join(tplMod.userTemplatesDir(), reg2.id), { recursive: true, force: true })
|
|
559
584
|
|
|
560
585
|
// ── 18. v0.5.1:外部模板收纳(ppt_template_add)——导入工程 → 模板库 ──────
|
|
561
586
|
const regId = `smoke-tpl-${Date.now().toString(36)}`
|
|
@@ -567,7 +592,7 @@ const regWS = await tplMod.templateWorkspace(regId)
|
|
|
567
592
|
const ctxReg = await resolveDeck(regWS.dir)
|
|
568
593
|
ok('v0.5.1:收纳模板工作区可校验(theme/页面保留)', ctxReg.theme.colors.primary === '#2563EB' && ctxReg.pages.length === 3, `pages=${ctxReg.pages.length}`)
|
|
569
594
|
// 清理测试模板
|
|
570
|
-
await (await import('node:fs/promises')).rm(join(tplMod.
|
|
595
|
+
await (await import('node:fs/promises')).rm(join(tplMod.userTemplatesDir(), regId), { recursive: true, force: true })
|
|
571
596
|
|
|
572
597
|
// ── 19. v0.6.0:对话内预览(ppt_preview)——预览根构建 + 同源相对 URL ────
|
|
573
598
|
const { buildPreview } = await import('../lib/preview-server.js')
|
|
@@ -669,7 +694,7 @@ if (previewSrc) {
|
|
|
669
694
|
const dualCtx = await resolveDeck(dualWS)
|
|
670
695
|
ok('v0.9.0:带 referenceTemplate 的 deck 通过校验(非渲染字段)', dualCtx.pages.length === 1 && dualCtx.deck.referenceTemplate?.id === dualId)
|
|
671
696
|
// 清理双轨测试模板 + 假 source
|
|
672
|
-
await (await import('node:fs/promises')).rm(join(tplMod.
|
|
697
|
+
await (await import('node:fs/promises')).rm(join(tplMod.userTemplatesDir(), dualId), { recursive: true, force: true }).catch(() => {})
|
|
673
698
|
await rm(dualSrc, { force: true }).catch(() => {})
|
|
674
699
|
await rm(dualWS, { recursive: true, force: true })
|
|
675
700
|
|
|
@@ -1949,6 +1974,36 @@ if (pyEnv.has) {
|
|
|
1949
1974
|
} else {
|
|
1950
1975
|
ok('python-pptx 兜底:生成脚本真跑出 .pptx,且 python-pptx 能把它读回(无 python-pptx:跳过)', true)
|
|
1951
1976
|
}
|
|
1977
|
+
// 43.3 原生图表(2026-09-26 第二轮 ③):兜底引擎的图表必须是**原生可编辑图表**
|
|
1978
|
+
// ——包里要有 `ppt/charts/*.xml`(图表部件)**与** `ppt/embeddings/*.xlsx`(内嵌数据工作簿,数据随文件走)。
|
|
1979
|
+
// 旧行为是"图表降级为表格"(用户拿到成品看不见图、也改不了数据),这条断言挡住回退。
|
|
1980
|
+
if (pyEnv.has) {
|
|
1981
|
+
const chartDeck = join(pyDeck, 'charts')
|
|
1982
|
+
await mkdir(join(chartDeck, 'pages'), { recursive: true })
|
|
1983
|
+
await writeFile(join(chartDeck, 'deck.yaml'), ['version: 1', 'title: chart-py', 'size: [960, 540]', 'theme:',
|
|
1984
|
+
' colors: {primary: "#2563EB", text: "#1F2937"}', ' textStyles:',
|
|
1985
|
+
' body: {fontSize: 16, color: "$text"}', 'pages:', ' - pages/01.yaml', ''].join('\n'), 'utf8')
|
|
1986
|
+
await writeFile(join(chartDeck, 'pages', '01.yaml'), ['pageType: content', 'elements:',
|
|
1987
|
+
' - elementId: bar', ' elementType: chart', ' bounds: [60, 60, 400, 260]',
|
|
1988
|
+
' chart: {type: bar, data: {cols: ["季度", "营收"], rows: [["Q1", 12], ["Q2", 18]]}}',
|
|
1989
|
+
' - elementId: pie', ' elementType: chart', ' bounds: [500, 60, 300, 260]',
|
|
1990
|
+
' chart: {type: pie, data: {cols: ["份额", "值"], rows: [["甲", 60], ["乙", 40]]}}', ''].join('\n'), 'utf8')
|
|
1991
|
+
let pass433 = false
|
|
1992
|
+
let info433 = ''
|
|
1993
|
+
try {
|
|
1994
|
+
const outC = join(chartDeck, 'out-charts.pptx')
|
|
1995
|
+
await runPythonExport(await resolveDeck(chartDeck), outC)
|
|
1996
|
+
const { zipRead } = await import('../lib/zips.js')
|
|
1997
|
+
const keys = [...zipRead(readFileSync(outC)).keys()]
|
|
1998
|
+
const charts = keys.filter((k) => k.includes('/charts/') && k.endsWith('.xml') && !k.includes('_rels')).length
|
|
1999
|
+
const embeds = keys.filter((k) => k.includes('embeddings')).length
|
|
2000
|
+
pass433 = charts === 2 && embeds === 2
|
|
2001
|
+
info433 = `chart 部件=${charts} 内嵌工作簿=${embeds}(期望各 2)`
|
|
2002
|
+
} catch (e) { info433 = String(e.message).slice(0, 220) }
|
|
2003
|
+
ok('python-pptx 兜底:图表是**原生可编辑图表**(`ppt/charts/*.xml` + 内嵌数据工作簿各就位,不再降级为表格)', pass433, info433)
|
|
2004
|
+
} else {
|
|
2005
|
+
ok('python-pptx 兜底:图表是原生可编辑图表(无 python-pptx:跳过)', true)
|
|
2006
|
+
}
|
|
1952
2007
|
await rm(pyDeck, { recursive: true, force: true })
|
|
1953
2008
|
|
|
1954
2009
|
// ── 44. 表格字号:audit 档的最小字号断言不得被表格自己架空(2026-09-18 新增)──────────────
|
|
@@ -2203,27 +2258,36 @@ ok('bundle:已转为公开发布 npm(无 private + publishConfig.access=publ
|
|
|
2203
2258
|
`package=${rootPkg.name}|patch=${insertRow?.name ?? '(缺)'}|预设行=${presetRowName ?? '无(正确:随包预设不含插件行)'}`)
|
|
2204
2259
|
}
|
|
2205
2260
|
|
|
2206
|
-
//
|
|
2207
|
-
//
|
|
2208
|
-
//
|
|
2209
|
-
//
|
|
2210
|
-
//
|
|
2211
|
-
//
|
|
2261
|
+
// 第四次修订(2026-09-26)**按预设隔离**:`apply()` 按"作用域档"分两种——
|
|
2262
|
+
// · profile 档(`config.scope` 缺省/`auto`/`profile`)= 全局管道 + 预设声明;缺省档还追加一行
|
|
2263
|
+
// **自定位行**(本包入口的绝对 `file://`),让本包被自己的预设再挂一次 ⇒ 预设档。
|
|
2264
|
+
// · preset 档(`config.scope === 'preset'`,由自定位行挂载)= 22 工具 / `/ppt` 命令 / 4 技能 /
|
|
2265
|
+
// 语义路由 / 提示段**全部注册在预设作用域** ⇒ 只对该预设的会话可见(其他预设零影响)。
|
|
2266
|
+
// 安全网:声明失败、注册表不可见、或"声明成功但预设档实例未装配" ⇒ **自动回落全局能力面**。
|
|
2267
|
+
// 历史两次失败的正确结论(2026-09-26 源码 + 真宿主实测):① 只对"插件自己按 agent 动态门控"成立;
|
|
2268
|
+
// ② 只对**裸包名/相对名**成立——绝对 `file://` 行不受 baseUrl 影响,正是它的解药。
|
|
2212
2269
|
{
|
|
2213
2270
|
const indexMod = await import('../lib/index.js')
|
|
2271
|
+
const presetMod = await import('../lib/preset-delivery.js')
|
|
2214
2272
|
let tools = 0, cmds = 0, skills = 0, listeners = 0
|
|
2215
2273
|
const handlers = new Map()
|
|
2216
|
-
const skillsSvc = { register: () => { skills++ } }
|
|
2274
|
+
const skillsSvc = { register: () => { skills++ }, get: () => undefined }
|
|
2217
2275
|
// 预设注册表 stub(**按 DSH 0.1.7 的真实契约**):`register()` 对重复 id **抛错**
|
|
2218
2276
|
// (真源码:`if (this.definitions.has(definition.id)) throw new Error(\`Duplicate agent preset: ${def.id}\`)`),
|
|
2219
2277
|
// 并返回注销函数。于是"双挂载会不会把同一个预设声明两次"在这里是可判定的。
|
|
2220
2278
|
const declared = []
|
|
2221
2279
|
const seenIds = new Set()
|
|
2280
|
+
// 真实时序:预设档实例是在 `register()` **期间**被挂载并 apply 的 ⇒ 隔离信号晚于 `apply()` 起点。
|
|
2281
|
+
// 只有本开关打开时 stub 才在 register 里置位信号(用于"隔离生效"场景),别处一律不置位(走回落)。
|
|
2282
|
+
let publishSignalOnRegister = false
|
|
2222
2283
|
const registry = {
|
|
2223
2284
|
register: (def) => {
|
|
2224
2285
|
if (seenIds.has(def.id)) return Promise.reject(new Error(`Duplicate agent preset: ${def.id}`))
|
|
2225
2286
|
seenIds.add(def.id)
|
|
2226
2287
|
declared.push(def)
|
|
2288
|
+
if (publishSignalOnRegister) {
|
|
2289
|
+
globalThis.__pptScopeApplied = { at: Date.now(), presetId: def.id, pid: process.pid }
|
|
2290
|
+
}
|
|
2227
2291
|
return Promise.resolve(async () => { seenIds.delete(def.id) })
|
|
2228
2292
|
},
|
|
2229
2293
|
}
|
|
@@ -2240,65 +2304,134 @@ ok('bundle:已转为公开发布 npm(无 private + publishConfig.access=publ
|
|
|
2240
2304
|
effect: (fn) => { fn(); return () => {} },
|
|
2241
2305
|
logger: () => ({ info: () => {}, warn: () => {} }),
|
|
2242
2306
|
})
|
|
2243
|
-
//
|
|
2244
|
-
delete globalThis.__pptCoreReg
|
|
2245
|
-
indexMod.apply(makeCtx(), { presetIds: ['ppt'] })
|
|
2307
|
+
// 装配防重 / 隔离信号都是**进程级**(globalThis):本节要自己从 0 起算,否则会被前面的用例带偏。
|
|
2246
2308
|
const flush = () => new Promise((r) => setTimeout(r, 0))
|
|
2309
|
+
const grace = () => new Promise((r) => setTimeout(r, 600)) // 健康判定宽限 400ms + 余量
|
|
2310
|
+
const resetAll = () => {
|
|
2311
|
+
delete globalThis.__pptCoreReg
|
|
2312
|
+
delete globalThis.__pptScopeApplied
|
|
2313
|
+
declared.length = 0
|
|
2314
|
+
seenIds.clear()
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
// ── 自定位行:隔离的载体(本包入口的**绝对 file:// URL**,预设行按注册表 baseUrl 解析 ⇒ 只有绝对名不受影响)
|
|
2318
|
+
const selfRow = presetMod.selfPresetRow()
|
|
2319
|
+
ok('§40 自定位行:指向本包入口的绝对 `file://` URL,且 `config.scope=preset` / `autoPreset=false`',
|
|
2320
|
+
typeof selfRow?.name === 'string' && selfRow.name.startsWith('file:///') && /\/lib\/index\.js$/.test(selfRow.name)
|
|
2321
|
+
&& selfRow?.config?.scope === 'preset' && selfRow?.config?.autoPreset === false,
|
|
2322
|
+
JSON.stringify(selfRow))
|
|
2323
|
+
ok('§40 自定位行:入口文件真实存在(拿不到入口就不追加该行——宁可不隔离,也不加一行注定 broken 的)',
|
|
2324
|
+
selfRow !== null && existsSync(fileURLToPath(selfRow.name)), String(selfRow?.name))
|
|
2325
|
+
|
|
2326
|
+
// ── 缺省档(auto):声明 20 行(19 + 自定位行)→ 预设档实例未出现 ⇒ 自动回落全局 ────────────
|
|
2327
|
+
resetAll()
|
|
2328
|
+
indexMod.apply(makeCtx(), { presetIds: ['ppt'] })
|
|
2247
2329
|
await flush() // 声明走的是异步队列(inject → register),让它落地
|
|
2248
|
-
ok('
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
ok('0.1.7:声明形状取自包内 `agent-presets/ppt/`(名字/简介/序号 + 19 条 standard 组合行)',
|
|
2260
|
-
d0?.name === 'PPT 工作室' && d0?.order === 2 && d0?.plugins?.length === 19
|
|
2261
|
-
&& typeof d0?.description === 'string' && d0.description.length > 10,
|
|
2262
|
-
`name=${d0?.name ?? '(无)'}|order=${d0?.order}|rows=${d0?.plugins?.length}|desc=${d0?.description?.length ?? 0} 字`)
|
|
2263
|
-
// `!!js` 必须**保真**成 Loader 认的 `{__jsExpr}`:降级成普通字符串会让
|
|
2264
|
-
// `Boolean(<非空字符串>) === true` ⇒ Windows 上 pwsh 行被误停用(= 没有 shell)。
|
|
2265
|
-
const rowOf = (def, id) => def?.plugins?.find((r) => r.id === id)
|
|
2266
|
-
const bashD = rowOf(d0, 'tool-bash')?.disabled
|
|
2267
|
-
const pwshD = rowOf(d0, 'tool-pwsh')?.disabled
|
|
2268
|
-
ok('0.1.7:`!!js` 表达式保真为 `{__jsExpr}`(降级成字符串 ⇒ Windows 上 pwsh 行会被误停用)',
|
|
2269
|
-
bashD?.__jsExpr === "process.platform === 'win32'" && pwshD?.__jsExpr === "process.platform !== 'win32'",
|
|
2270
|
-
`bash=${JSON.stringify(bashD)}|pwsh=${JSON.stringify(pwshD)}`)
|
|
2271
|
-
const first = { tools, cmds, skills }
|
|
2272
|
-
indexMod.apply(makeCtx(), { presetIds: ['ppt'] }) // 第二次装配(双挂载场景)
|
|
2273
|
-
await flush()
|
|
2274
|
-
ok('回滚:同进程第二次装配被防重拦下(首个生效 + 计数),不重复注册',
|
|
2275
|
-
tools === first.tools && cmds === first.cmds && skills === first.skills,
|
|
2276
|
-
`二次后 tools=${tools}(应=${first.tools})cmds=${cmds} skills=${skills}|refcount=${globalThis.__pptCoreReg?.count ?? '?'}`)
|
|
2277
|
-
ok('0.1.7:双挂载也**不会重复声明**预设(真注册表对重复 id 直接 throw ⇒ 声明只发一次)',
|
|
2278
|
-
declared.length === 1, `声明次数=${declared.length}(应=1)`)
|
|
2279
|
-
ok('回滚:apply 装的全局管道仍在(预览路由/语义路由/提示段注入靠这些监听器)',
|
|
2280
|
-
listeners >= 2, `listeners=${listeners}`)
|
|
2330
|
+
ok('§40 缺省档:声明 20 行 = 19 条 standard 组合行 + 第 20 行自定位行("只在预设里可见"靠它)',
|
|
2331
|
+
declared.length === 1 && declared[0]?.plugins?.length === 20 && declared[0]?.plugins?.at(-1)?.id === 'ppt-studio-scope',
|
|
2332
|
+
`声明=${declared.length} 次|行数=${declared[0]?.plugins?.length}|末行=${declared[0]?.plugins?.at(-1)?.id ?? '(无)'}`)
|
|
2333
|
+
ok('§40 缺省档:健康判定期间**不注册**能力面(隔离生效时 profile 档必须零能力面)',
|
|
2334
|
+
tools === 0 && cmds === 0 && skills === 0 && listeners === 0,
|
|
2335
|
+
`tools=${tools} cmds=${cmds} skills=${skills} listeners=${listeners}`)
|
|
2336
|
+
await grace()
|
|
2337
|
+
ok('§40 自动回落:声明成功但预设档实例未装配 ⇒ 回落注册全局能力面(**功能不丢**,只是没隔离)',
|
|
2338
|
+
tools === 22 && cmds >= 1 && skills === 4 && listeners >= 2
|
|
2339
|
+
&& presetMod.presetDeliveryStatus().isolation === 'profile-fallback',
|
|
2340
|
+
`tools=${tools} cmds=${cmds} skills=${skills} listeners=${listeners}|isolation=${presetMod.presetDeliveryStatus().isolation}`)
|
|
2281
2341
|
let promptOk = false
|
|
2282
2342
|
try {
|
|
2283
2343
|
const h = handlers.get('system-prompt/assemble')
|
|
2284
2344
|
const out = h ? await h({ sections: [] }, { agent: { session: { id: 'no-such-session-xyz' } } }, async () => ({ sections: [] })) : null
|
|
2285
2345
|
promptOk = Boolean(out)
|
|
2286
2346
|
} catch { promptOk = false }
|
|
2287
|
-
ok('
|
|
2288
|
-
|
|
2347
|
+
ok('§40 回落档:提示段注入仍在(读不到会话状态时原样返回,不抛)', promptOk, promptOk ? '原样返回 ✓' : '未注册/抛错')
|
|
2348
|
+
|
|
2349
|
+
// ── 隔离生效:预设档实例置位信号 ⇒ profile 档零能力面 ─────────────────────────────
|
|
2350
|
+
resetAll()
|
|
2351
|
+
const isoBase = { tools, cmds, skills, listeners }
|
|
2352
|
+
publishSignalOnRegister = true // 忠实模拟:预设档实例在 register() 期间装配 ⇒ 信号晚于 apply 起点
|
|
2353
|
+
indexMod.apply(makeCtx(), {})
|
|
2354
|
+
await grace()
|
|
2355
|
+
publishSignalOnRegister = false
|
|
2356
|
+
ok('§40 隔离生效:预设档实例已装配 ⇒ profile 档**一个能力面都不注册**(其他预设零影响)',
|
|
2357
|
+
tools === isoBase.tools && cmds === isoBase.cmds && skills === isoBase.skills && listeners === isoBase.listeners
|
|
2358
|
+
&& presetMod.presetDeliveryStatus().isolation === 'preset',
|
|
2359
|
+
`增量 tools=${tools - isoBase.tools} cmds=${cmds - isoBase.cmds} skills=${skills - isoBase.skills} listeners=${listeners - isoBase.listeners}|isolation=${presetMod.presetDeliveryStatus().isolation}`)
|
|
2360
|
+
|
|
2361
|
+
// ── 预设档(由自定位行挂载):能力面 + 路由全在预设作用域;**不声明**预设 ─────────────────
|
|
2362
|
+
resetAll()
|
|
2363
|
+
const tierBase = { tools, cmds, skills, listeners }
|
|
2364
|
+
indexMod.apply(makeCtx(), { scope: 'preset', autoPreset: false, presetIds: ['ppt'] })
|
|
2365
|
+
await flush()
|
|
2366
|
+
ok('§40 预设档:22 工具 + `/ppt` 命令 + 4 技能 + 2 监听器**全部注册在预设作用域**(隔离的实现主体)',
|
|
2367
|
+
tools - tierBase.tools === 22 && cmds - tierBase.cmds >= 1 && skills - tierBase.skills === 4 && listeners - tierBase.listeners === 2,
|
|
2368
|
+
`增量 tools=${tools - tierBase.tools} cmds=${cmds - tierBase.cmds} skills=${skills - tierBase.skills} listeners=${listeners - tierBase.listeners}`)
|
|
2369
|
+
ok('§40 预设档:**不声明**预设(否则注册表对重复 id 直接 throw ⇒ 预设消失)',
|
|
2370
|
+
declared.length === 0, `声明次数=${declared.length}`)
|
|
2371
|
+
ok('§40 预设档:置位"预设档实例已装配"信号(profile 档据此判定隔离生效)',
|
|
2372
|
+
typeof globalThis.__pptScopeApplied?.at === 'number' && globalThis.__pptScopeApplied.at > 0,
|
|
2373
|
+
JSON.stringify(globalThis.__pptScopeApplied))
|
|
2374
|
+
|
|
2375
|
+
// ── 传统档(scope=profile):不隔离、能力面全局,声明回到 19 行(不含自定位行)──────────────
|
|
2376
|
+
resetAll()
|
|
2377
|
+
const trBase = { tools, skills }
|
|
2378
|
+
indexMod.apply(makeCtx(), { scope: 'profile' })
|
|
2379
|
+
await grace()
|
|
2380
|
+
ok('§40 传统档:能力面注册在装入层(全局可见),且声明**不含**自定位行(19 行)',
|
|
2381
|
+
tools - trBase.tools === 22 && skills - trBase.skills === 4 && declared.length === 1
|
|
2382
|
+
&& declared[0]?.plugins?.length === 19 && declared[0]?.plugins?.at(-1)?.id !== 'ppt-studio-scope'
|
|
2383
|
+
&& presetMod.presetDeliveryStatus().isolation === 'profile',
|
|
2384
|
+
`增量 tools=${tools - trBase.tools} skills=${skills - trBase.skills}|行数=${declared[0]?.plugins?.length}|isolation=${presetMod.presetDeliveryStatus().isolation}`)
|
|
2385
|
+
|
|
2386
|
+
// ── 注册表不可见:只告警不抛 + **立即**回落(不留"装上了却什么都没有"的窗口)──────────────
|
|
2387
|
+
resetAll()
|
|
2388
|
+
const noRegBase = { tools, skills }
|
|
2289
2389
|
let noRegThrew = false
|
|
2290
|
-
delete globalThis.__pptCoreReg
|
|
2291
2390
|
try {
|
|
2292
2391
|
indexMod.apply({
|
|
2293
|
-
tools: { register: () => {} }, commands: { register: () => {} }, skills: skillsSvc,
|
|
2294
|
-
get: () => undefined, inject: () => {}, on: (
|
|
2392
|
+
tools: { register: () => { tools++ } }, commands: { register: () => { cmds++ } }, skills: skillsSvc,
|
|
2393
|
+
get: () => undefined, inject: () => {}, on: (ev, fn) => { listeners++; handlers.set(ev, fn) },
|
|
2394
|
+
effect: (fn) => { fn(); return () => {} },
|
|
2295
2395
|
logger: () => ({ info: () => {}, warn: () => {} }),
|
|
2296
2396
|
}, {})
|
|
2297
|
-
|
|
2397
|
+
// 真实场景:本部署**没有** agentPresets 服务 ⇒ `inject(['agentPresets'], cb)` 的回调**永不触发**
|
|
2398
|
+
// (不是"触发并给 undefined")⇒ 声明既不成功也不失败 ⇒ 必须靠兜底超时回落(否则零能力面)。
|
|
2399
|
+
await new Promise((r) => setTimeout(r, 3400))
|
|
2298
2400
|
} catch { noRegThrew = true }
|
|
2299
|
-
ok('
|
|
2300
|
-
|
|
2301
|
-
|
|
2401
|
+
ok('§40 注册表不可见:只告警不抛,并靠**兜底超时**回落全局能力面(极简部署仍完整可用)',
|
|
2402
|
+
// 注意:这个假 ctx 连 `skills` 服务都没有(`get` 恒 undefined)⇒ 技能数应为 0,不是 4。
|
|
2403
|
+
noRegThrew === false && tools - noRegBase.tools === 22 && cmds >= 1
|
|
2404
|
+
&& presetMod.presetDeliveryStatus().isolation === 'profile-fallback',
|
|
2405
|
+
`抛错=${noRegThrew}|增量 tools=${tools - noRegBase.tools} cmds=${cmds}|isolation=${presetMod.presetDeliveryStatus().isolation}`)
|
|
2406
|
+
|
|
2407
|
+
// ── 防重(按作用域):同一 ctx 重复 apply 只装配一次 ───────────────────────────────
|
|
2408
|
+
resetAll()
|
|
2409
|
+
const dedupeCtx = makeCtx()
|
|
2410
|
+
const ddBase = { tools }
|
|
2411
|
+
indexMod.apply(dedupeCtx, {})
|
|
2412
|
+
indexMod.apply(dedupeCtx, {})
|
|
2413
|
+
await grace()
|
|
2414
|
+
ok('§40 防重:同一作用域重复 apply 只装配一次(profile 与 preset 是两个不同作用域,各自只该装配一次)',
|
|
2415
|
+
tools - ddBase.tools === 22 && declared.length === 1 && [...(globalThis.__pptCoreReg?.keys ?? [])].join() === 'profile',
|
|
2416
|
+
`增量 tools=${tools - ddBase.tools}(应 22)|声明=${declared.length}|keys=${[...(globalThis.__pptCoreReg?.keys ?? [])].join()}`)
|
|
2417
|
+
resetAll()
|
|
2418
|
+
// ── 0.1.7 的**真适配**:预设靠"向注册表声明"交付(写目录那条路已被上游删除)────────────
|
|
2419
|
+
// 判据分三层:① 真的调到 register 了;② 声明形状对(id/名/序/行数/内容);③ 双挂载不会声明两次。
|
|
2420
|
+
const declDirect = presetMod.presetDeclaration()
|
|
2421
|
+
ok('§40 预设组合:名字/简介/序号取自包内 `agent-presets/ppt/`(19 条 standard 组合行;选择器里显示的就是这些)',
|
|
2422
|
+
declDirect.name === 'PPT 工作室' && declDirect.order === 2 && declDirect.plugins.length === 19
|
|
2423
|
+
&& typeof declDirect.description === 'string' && declDirect.description.length > 10,
|
|
2424
|
+
`name=${declDirect.name}|order=${declDirect.order}|行数=${declDirect.plugins.length}|desc=${declDirect.description.length} 字`)
|
|
2425
|
+
// `!!js` 必须**保真**成 Loader 认的 `{__jsExpr}`:降级成普通字符串会让
|
|
2426
|
+
// `Boolean(<非空字符串>) === true` ⇒ Windows 上 pwsh 行被误停用(= 没有 shell)。
|
|
2427
|
+
const rowOf = (def, id) => def?.plugins?.find((r) => r.id === id)
|
|
2428
|
+
const bashD = rowOf(declDirect, 'tool-bash')?.disabled
|
|
2429
|
+
const pwshD = rowOf(declDirect, 'tool-pwsh')?.disabled
|
|
2430
|
+
ok('§40 预设组合:`!!js` 表达式保真为 `{__jsExpr}`(降级成字符串 ⇒ Windows 上 pwsh 行会被误停用)',
|
|
2431
|
+
bashD?.__jsExpr === "process.platform === 'win32'" && pwshD?.__jsExpr === "process.platform !== 'win32'",
|
|
2432
|
+
`bash=${JSON.stringify(bashD)}|pwsh=${JSON.stringify(pwshD)}`)
|
|
2433
|
+
ok('§40 隔离代码历史:`mountForAgent` 那套"插件自己按 agent 门控"仍不恢复(实现改走预设作用域)',
|
|
2434
|
+
indexMod.mountForAgent === undefined, `mountForAgent=${typeof indexMod.mountForAgent}`)
|
|
2302
2435
|
}
|
|
2303
2436
|
|
|
2304
2437
|
// ── 41. 安装器 + 预设**声明**(2026-09-24 为 DSH 0.1.7-rc.1 重写)──────────────────────
|
|
@@ -2553,7 +2686,9 @@ ok('npm 发布通道:发布的是**下载下来的 Release 资产**(等 .tgz
|
|
|
2553
2686
|
delete globalThis.__pptCoreReg
|
|
2554
2687
|
delete globalThis.__pptRouteReg
|
|
2555
2688
|
let applyThrew = null
|
|
2556
|
-
|
|
2689
|
+
// 本节测的是**管道韧性**(附加能力坏掉只该降级),与隔离无关 ⇒ 用传统档(`scope: 'profile'`)
|
|
2690
|
+
// 让能力面直接落在装入层;隔离路径的判定见 §40。
|
|
2691
|
+
try { indexMod2.apply(pipeCtx, { scope: 'profile', presetIds: ['ppt'] }) } catch (e) { applyThrew = e }
|
|
2557
2692
|
ok('§52 装配韧性:全局管道抛错时 apply 仍完成装配(22 工具 / 命令面 / 4 技能)——附加能力坏掉只该降级',
|
|
2558
2693
|
applyThrew === null && pTools === 22 && pCmds >= 1 && pSkills === 4,
|
|
2559
2694
|
`threw=${applyThrew ? String(applyThrew.message) : 'null'}|tools=${pTools} cmds=${pCmds} skills=${pSkills}`)
|
|
@@ -2588,7 +2723,7 @@ ok('npm 发布通道:发布的是**下载下来的 Release 资产**(等 .tgz
|
|
|
2588
2723
|
delete globalThis.__pptCoreReg
|
|
2589
2724
|
delete globalThis.__pptRouteReg
|
|
2590
2725
|
let injThrew = null
|
|
2591
|
-
try { indexMod2.apply(injCtx, { presetIds: ['ppt'] }) } catch (e) { injThrew = e }
|
|
2726
|
+
try { indexMod2.apply(injCtx, { scope: 'profile', presetIds: ['ppt'] }) } catch (e) { injThrew = e }
|
|
2592
2727
|
await new Promise((r) => setTimeout(r, 10))
|
|
2593
2728
|
ok('§52 预览路由:`webServer` 比插件晚激活时(真宿主实测的组合顺序)走 `inject` 仍要真的挂上路由',
|
|
2594
2729
|
injThrew === null && iTools === 22 && injPrefixes.has('/ppt-preview'),
|
|
@@ -2695,6 +2830,189 @@ ok('npm 发布通道:发布的是**下载下来的 Release 资产**(等 .tgz
|
|
|
2695
2830
|
await fsp.rm(gWork, { recursive: true, force: true })
|
|
2696
2831
|
}
|
|
2697
2832
|
|
|
2833
|
+
// ── 55. 用户目录口径统一 + 测试隔离(2026-09-26,方案 B 第二批)──────────────────────────
|
|
2834
|
+
// 事故形状:`state.js` / `preview-server.js` 的用户目录曾是**模块常量** `homedir()/.dsh`(忽略 DSH_HOME),
|
|
2835
|
+
// 而 `templates.js` / `index.js`(diag) 是调用时求值 ⇒ ① 设了 DSH_HOME 的隔离部署里,会话状态与预览
|
|
2836
|
+
// 逃出隔离目录(实测:状态文件落在真实 `~/.dsh`);② 本机 `npm test` 把 smoke 模板与预览缓存写进
|
|
2837
|
+
// 开发机真实 `~/.dsh/ppt-studio/`(实测复现、清理过两次)。修法 = 统一到 `src/home.js`(调用时求值)。
|
|
2838
|
+
{
|
|
2839
|
+
const homeMod = await import('../lib/home.js')
|
|
2840
|
+
const stateMod = await import('../lib/state.js')
|
|
2841
|
+
const { previewRoot } = await import('../lib/preview-server.js')
|
|
2842
|
+
const { userTemplatesDir } = await import('../lib/templates.js')
|
|
2843
|
+
const prevHome55 = process.env.DSH_HOME
|
|
2844
|
+
const probeHome = join(smokeDir, '.tmp-home-probe')
|
|
2845
|
+
process.env.DSH_HOME = probeHome
|
|
2846
|
+
const set55 = { home: homeMod.dshHome(), studio: homeMod.pptStudioDir(), session: stateMod.sessionDir(), preview: previewRoot(), tpl: userTemplatesDir() }
|
|
2847
|
+
delete process.env.DSH_HOME
|
|
2848
|
+
const unset55 = { home: homeMod.dshHome(), studio: homeMod.pptStudioDir(), session: stateMod.sessionDir(), preview: previewRoot(), tpl: userTemplatesDir() }
|
|
2849
|
+
process.env.DSH_HOME = prevHome55
|
|
2850
|
+
ok('§55 用户目录:`dshHome()` 认 DSH_HOME(设/未设两种),未设时回落 `~/.dsh` —— 普通用户行为零变化',
|
|
2851
|
+
set55.home === probeHome && unset55.home === join(homedir(), '.dsh'),
|
|
2852
|
+
`设=${set55.home}|未设=${unset55.home}`)
|
|
2853
|
+
ok('§55 用户目录:state / preview / templates / diag 四个消费方**同源**(都挂在同一个 ppt-studio 根下,不再各写一套)',
|
|
2854
|
+
set55.studio === join(probeHome, 'ppt-studio') && unset55.studio === join(homedir(), '.dsh', 'ppt-studio')
|
|
2855
|
+
&& [set55.session, set55.preview, set55.tpl].every((p) => p.startsWith(set55.studio))
|
|
2856
|
+
&& [unset55.session, unset55.preview, unset55.tpl].every((p) => p.startsWith(unset55.studio)),
|
|
2857
|
+
`studio=${set55.studio}|session=${set55.session}|preview=${set55.preview}|templates=${set55.tpl}`)
|
|
2858
|
+
|
|
2859
|
+
// 真写一遍:会话状态必须落在**当前** DSH_HOME 下,且真实家目录里不出现这个 probe 文件
|
|
2860
|
+
const sid55 = `probe-${Date.now().toString(36)}`
|
|
2861
|
+
await stateMod.saveSession(sid55, { routing: 'auto', probe55: true })
|
|
2862
|
+
const inIso = join(SMOKE_HOME, 'ppt-studio', `session-${sid55}.json`)
|
|
2863
|
+
const inReal = join(homedir(), '.dsh', 'ppt-studio', `session-${sid55}.json`)
|
|
2864
|
+
const back55 = await stateMod.loadSession(sid55)
|
|
2865
|
+
ok('§55 会话状态:写进当前 DSH_HOME 下的 `<home>/ppt-studio/`,真实家目录不出现该文件(此前漏掉的正是这一半)',
|
|
2866
|
+
existsSync(inIso) && back55.probe55 === true && !existsSync(inReal),
|
|
2867
|
+
`隔离内=${existsSync(inIso)}|真实家目录=${existsSync(inReal)}|读回 probe=${back55.probe55}`)
|
|
2868
|
+
ok('§55 测试隔离:整轮 smoke 的 DSH_HOME 指向构建目录内的临时 home(本轮所有用户层写入都被关在里面)',
|
|
2869
|
+
process.env.DSH_HOME === SMOKE_HOME && SMOKE_HOME.startsWith(root) && SMOKE_HOME.includes('.tmp-'),
|
|
2870
|
+
`DSH_HOME=${process.env.DSH_HOME}`)
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2873
|
+
// ── 56. 兜底增强(2026-09-26,第二轮):能力探测 + 优先用捆绑 Python ─────────────────────
|
|
2874
|
+
// 背景:官方三个 office 技能(`office-docx/pptx/xlsx`)只在桌面端具备,它们假定的运行时
|
|
2875
|
+
// (捆绑 Python + LibreOffice kit)也只在桌面端;于是兜底路径必须**先探测再说话**。
|
|
2876
|
+
// 本节钉四件事:① 探测契约与磁盘一致(不编造);② 候选根的顺序(env > 安装副本 > carrier);
|
|
2877
|
+
// ③ python-pptx 兜底**优先**用捆绑 Python;④ `ppt_state` 把能力与装配状态一次暴露。
|
|
2878
|
+
{
|
|
2879
|
+
const capMod = await import('../lib/capabilities.js')
|
|
2880
|
+
const caps = capMod.detectCapabilities()
|
|
2881
|
+
ok('§56 能力探测:契约形状完整(平台 / 捆绑 Python·Node / LibreOffice kit / office 资产 / primary-runtime 候选)',
|
|
2882
|
+
['platform', 'bundledPython', 'bundledNode', 'libreofficeKit', 'officeSkillsAssets', 'primaryRuntime'].every((k) => k in caps)
|
|
2883
|
+
&& Array.isArray(caps.primaryRuntime) && caps.primaryRuntime.length >= 1 // 候选根数**随部署变化**(桌面 3 个 / CLI 1 个),别写死
|
|
2884
|
+
&& caps.primaryRuntime.every((r) => typeof r.root === 'string' && typeof r.source === 'string' && r.present === existsSync(r.root)),
|
|
2885
|
+
`platform=${caps.platform}|python=${caps.bundledPython ? '有' : '无'}|kit=${caps.libreofficeKit ? '有' : '无'}|候选根=${caps.primaryRuntime.length}`)
|
|
2886
|
+
ok('§56 能力探测:探测不到就给 null(**不编造**),给出的路径都真实存在',
|
|
2887
|
+
(caps.bundledPython === null || existsSync(caps.bundledPython.path))
|
|
2888
|
+
&& (caps.libreofficeKit === null || existsSync(caps.libreofficeKit.cli))
|
|
2889
|
+
&& (caps.officeSkillsAssets === null || existsSync(caps.officeSkillsAssets.skillsDir)),
|
|
2890
|
+
`python=${caps.bundledPython?.path ?? '(无)'}|kit=${caps.libreofficeKit?.cli ?? '(无)'}|office=${caps.officeSkillsAssets?.skillsDir ?? '(无)'}`)
|
|
2891
|
+
const cands = capMod.pythonCandidates()
|
|
2892
|
+
const order = cands.map((c) => (c.source.startsWith('env') ? 'env' : c.source.startsWith('installed') ? 'installed' : 'carrier'))
|
|
2893
|
+
ok('§56 候选顺序:env 缝(若设置)> 安装副本(`dsh-runtimes/dsh-primary-runtime`)> carrier(`resources/runtime`)',
|
|
2894
|
+
// 顺序要**单调**;carrier 只在桌面端存在、env 只在 SDK/容器部署设置 ⇒ 不要求三者齐全。
|
|
2895
|
+
cands.length >= 1
|
|
2896
|
+
&& cands.every((c, i) => i === 0 || ({ env: 0, installed: 1, carrier: 2 })[order[i - 1]] <= ({ env: 0, installed: 1, carrier: 2 })[order[i]])
|
|
2897
|
+
&& order[0] === ((process.env.DSH_PRIMARY_RUNTIME || process.env.DSH_BUNDLED_PRIMARY_RUNTIME) ? 'env' : 'installed')
|
|
2898
|
+
&& cands.every((c) => typeof c.root === 'string' && typeof c.node === 'string'),
|
|
2899
|
+
order.join(' > '))
|
|
2900
|
+
const bundled = capMod.findBundledPython()
|
|
2901
|
+
const found = (await import('../lib/pptxPy.js')).findPython()
|
|
2902
|
+
ok('§56 解释器优先级:探测到捆绑 Python 就必须优先用它(`source=bundled:…`),否则才退回 PATH',
|
|
2903
|
+
bundled === null
|
|
2904
|
+
? found.source !== 'bundled'
|
|
2905
|
+
: (found.has === true && found.cmd === bundled.path && String(found.source).startsWith('bundled:')),
|
|
2906
|
+
`bundled=${bundled?.path ?? '(无)'}|findPython=${JSON.stringify(found)}`)
|
|
2907
|
+
const fakeNone = await capMod.probeOfficeSkills({ get: () => undefined })
|
|
2908
|
+
const fakeAll = await capMod.probeOfficeSkills({
|
|
2909
|
+
get: (k) => (k === 'skills' ? { get: async () => ({ provider: 'dsh-office', source: 'bundled', content: 'x' }) } : undefined),
|
|
2910
|
+
})
|
|
2911
|
+
ok('§56 官方 office 技能探测:服务缺失 ⇒ available=false(带原因);三本都在 ⇒ available=true',
|
|
2912
|
+
fakeNone.available === false && typeof fakeNone.reason === 'string' && fakeNone.found.length === 0
|
|
2913
|
+
&& fakeAll.available === true && fakeAll.found.length === 3,
|
|
2914
|
+
`无服务=${fakeNone.available}(${String(fakeNone.reason).slice(0, 24)})|齐全=${fakeAll.available}(${fakeAll.found.join(',')})`)
|
|
2915
|
+
// resources 上溯:桌面端里插件可能跑在深层子进程 node 中 ⇒ 必须能从任意深度找回 `<install>/resources`。
|
|
2916
|
+
// 用一棵合成目录树验证(与真实安装无关,机器无关)。
|
|
2917
|
+
const synth = join(smokeDir, '.tmp-resources-probe')
|
|
2918
|
+
await rm(synth, { recursive: true, force: true })
|
|
2919
|
+
const deep = join(synth, 'install', 'resources', 'runtime', 'primary-runtime', 'dependencies', 'node', 'bin')
|
|
2920
|
+
await mkdir(deep, { recursive: true })
|
|
2921
|
+
await writeFile(join(synth, 'install', 'resources', 'app.asar'), 'x', 'utf8')
|
|
2922
|
+
await writeFile(join(deep, 'node.exe'), 'x', 'utf8')
|
|
2923
|
+
const foundRes = capMod.resourcesFrom(deep)
|
|
2924
|
+
ok('§56 resources 上溯:从深层子进程 node 的目录能找回 `<install>/resources`(以 app.asar 为锚点)',
|
|
2925
|
+
foundRes === join(synth, 'install', 'resources'),
|
|
2926
|
+
`${String(foundRes).replace(synth, '<合成树>')}`)
|
|
2927
|
+
await rm(synth, { recursive: true, force: true })
|
|
2928
|
+
// 官方 QA 通道(②):用**合成资产树 + 桩 checker**验证契约(与桌面端是否安装无关,机器无关)
|
|
2929
|
+
const qaMod = await import('../lib/office-qa.js')
|
|
2930
|
+
const qaSynth = join(smokeDir, '.tmp-qa-probe')
|
|
2931
|
+
await rm(qaSynth, { recursive: true, force: true })
|
|
2932
|
+
const qaSkills = join(qaSynth, 'runtime', 'office-skills')
|
|
2933
|
+
await mkdir(join(qaSkills, 'scripts'), { recursive: true })
|
|
2934
|
+
await writeFile(join(qaSynth, 'app.asar'), 'x', 'utf8') // 让 resourcesFrom 认这棵树
|
|
2935
|
+
const prevRes = process.env.PPT_STUDIO_RESOURCES_DIR
|
|
2936
|
+
process.env.PPT_STUDIO_RESOURCES_DIR = qaSynth
|
|
2937
|
+
const anyPy = capMod.findAnyPython()
|
|
2938
|
+
const qaDummy = join(qaSynth, 'dummy.pptx')
|
|
2939
|
+
await writeFile(qaDummy, 'not-a-real-pptx', 'utf8') // 桩 checker 不读内容,但我们的入口会先做存在性检查
|
|
2940
|
+
if (anyPy !== null) {
|
|
2941
|
+
await writeFile(join(qaSkills, 'scripts', 'check_office.py'),
|
|
2942
|
+
'import json\nprint(json.dumps({"format":"pptx","verdict":"pass","checks":[],"summary":{"slides":3}}))\n', 'utf8')
|
|
2943
|
+
const qa1 = qaMod.runOfficeStructureCheck(qaDummy, { count: 3 })
|
|
2944
|
+
ok('§56 官方 QA:有资产 + 有 Python 时真的跑 `check_office.py` 并解析 JSON 判定(pass ⇒ ok,带回 summary)',
|
|
2945
|
+
qa1.available === true && qa1.ok === true && qa1.summary?.slides === 3, JSON.stringify(qa1).slice(0, 150))
|
|
2946
|
+
await writeFile(join(qaSkills, 'scripts', 'check_office.py'),
|
|
2947
|
+
'import json\nprint(json.dumps({"format":"pptx","verdict":"fail","checks":[{"id":"package","status":"fail","detail":"boom"}]}))\n', 'utf8')
|
|
2948
|
+
const qa2 = qaMod.runOfficeStructureCheck(qaDummy, { count: 3 })
|
|
2949
|
+
ok('§56 官方 QA:`verdict=fail` 必须判失败,并把失败项带回(不吞诊断)',
|
|
2950
|
+
qa2.available === true && qa2.ok === false && /boom/.test(String(qa2.reason)), String(qa2.reason).slice(0, 150))
|
|
2951
|
+
} else {
|
|
2952
|
+
const qa0 = qaMod.runOfficeStructureCheck(qaDummy, {})
|
|
2953
|
+
ok('§56 官方 QA:本机没有任何 Python ⇒ 明确报"不可用 + 原因"(不假装跑过)', qa0.available === false && qa0.reason.length > 0, String(qa0.reason).slice(0, 90))
|
|
2954
|
+
ok('§56 官方 QA:无 Python 时 `officeQaStatus()` 也不该声称可用', qaMod.officeQaStatus().available === false, JSON.stringify(qaMod.officeQaStatus()))
|
|
2955
|
+
}
|
|
2956
|
+
const qa3 = qaMod.runLibreOfficeRender(qaDummy, join(qaSynth, 'out'))
|
|
2957
|
+
ok('§56 官方 QA:资产树里没有 LibreOffice kit ⇒ 渲染通道明确报"不可用 + 原因",不假装跑过',
|
|
2958
|
+
qa3.available === false && typeof qa3.reason === 'string' && qa3.reason.length > 0, String(qa3.reason).slice(0, 90))
|
|
2959
|
+
if (prevRes === undefined) delete process.env.PPT_STUDIO_RESOURCES_DIR
|
|
2960
|
+
else process.env.PPT_STUDIO_RESOURCES_DIR = prevRes
|
|
2961
|
+
await rm(qaSynth, { recursive: true, force: true })
|
|
2962
|
+
|
|
2963
|
+
const captured56 = new Map()
|
|
2964
|
+
const idxMod56 = await import('../lib/index.js')
|
|
2965
|
+
idxMod56.statusToolFor({ effect: (cb) => { cb(); return () => {} }, tools: { register: (d) => captured56.set(d.name, d) }, get: () => undefined }, 'profile')
|
|
2966
|
+
const out56 = JSON.parse(await captured56.get('ppt_state').execute({}, { agent: { session: { id: 'probe-56' } } }))
|
|
2967
|
+
ok('§56 ppt_state:一次看全"装配作用域 + 隔离是否生效 + 能力(含 office 技能)+ 路径"',
|
|
2968
|
+
out56.assembly?.isolation !== undefined && out56.capabilities?.platform !== undefined
|
|
2969
|
+
&& out56.capabilities?.officeSkills?.available !== undefined && out56.paths?.pptStudio !== undefined,
|
|
2970
|
+
`assembly=${out56.assembly?.isolation}|capabilities=${Object.keys(out56.capabilities ?? {}).join(',')}`)
|
|
2971
|
+
}
|
|
2972
|
+
|
|
2973
|
+
// ── 57. 成品内容自证(2026-09-26 第二轮 启发 A):从**产物**反推"该写的字/该画的图形在不在" ──────
|
|
2974
|
+
// 为什么必须补:此前 parity 只有计数(表/图/线/媒体)+ audit 的页数/尺寸/字号 ⇒
|
|
2975
|
+
// "预览层对、成品层丢字/串行/表格漏格"这一类抓不到;图表更是**一条 parity 都没有**。
|
|
2976
|
+
{
|
|
2977
|
+
const work57 = join(smokeDir, '.tmp-content-proof')
|
|
2978
|
+
await rm(work57, { recursive: true, force: true })
|
|
2979
|
+
await mkdir(join(work57, 'pages'), { recursive: true })
|
|
2980
|
+
await writeFile(join(work57, 'deck.yaml'), ['version: 1', 'title: content-proof', 'size: [960, 540]', 'theme:',
|
|
2981
|
+
' colors: {primary: "#2563EB", text: "#1F2937"}', ' textStyles:',
|
|
2982
|
+
' body: {fontSize: 16, color: "$text"}', 'pages:', ' - pages/01.yaml', ''].join('\n'), 'utf8')
|
|
2983
|
+
await writeFile(join(work57, 'pages', '01.yaml'), ['pageType: content', 'elements:',
|
|
2984
|
+
' - elementId: t1', ' elementType: text', ' bounds: [40, 40, 700, 60]',
|
|
2985
|
+
' content: {text: "第一行 <a&b> \\"引号\\"", style: "$body"}',
|
|
2986
|
+
' - elementId: tb', ' elementType: table', ' bounds: [40, 120, 400, 90]',
|
|
2987
|
+
' cols: ["指标", "值"]', ' rows: [["营收 <2026>", "1,234"], ["毛利 & 净利", "56%"]]',
|
|
2988
|
+
' - elementId: ch57', ' elementType: chart', ' bounds: [40, 240, 500, 200]',
|
|
2989
|
+
' chart: {type: bar, data: {cols: ["季度", "值"], rows: [["Q1", 12], ["Q2", 18]]}}',
|
|
2990
|
+
' - elementId: t2', ' elementType: text', ' bounds: [560, 40, 360, 40]',
|
|
2991
|
+
' content: {text: "第二行\\n第三行", style: "$body"}', ''].join('\n'), 'utf8')
|
|
2992
|
+
const r57 = await exportPptx(await resolveDeck(work57), { out: join(work57, 'out.pptx') })
|
|
2993
|
+
const p57 = r57.parity ?? {}
|
|
2994
|
+
ok('§57 成品内容自证:文本/表格内容在产物里**逐条**能反查到(含 <>& 引号与 \\n 多行)——"预览对、成品丢字"这类抓得到',
|
|
2995
|
+
(p57.textExp ?? 0) >= 8 && (p57.textMissing ?? ['x']).length === 0 && p57.ok === true,
|
|
2996
|
+
`textExp=${p57.textExp}|缺失=${JSON.stringify(p57.textMissing ?? null)}|ok=${p57.ok}`)
|
|
2997
|
+
// 默认走原生(用户 2026-09-26 拍板):包里必须出现图表部件 + 内嵌数据工作簿,且数据与 deck **逐格**一致
|
|
2998
|
+
ok('§57 成品内容自证:图表默认 = **原生可编辑图表部件**(`ppt/charts/*.xml` + 内嵌数据工作簿),数据与 deck 逐格一致',
|
|
2999
|
+
p57.chartsExp === 1 && p57.chartsOut === 1 && p57.chartEmbedsOut === 1 && p57.chartSheetsChecked === 1
|
|
3000
|
+
&& (p57.chartDataMismatch ?? ['x']).length === 0 && (p57.chartRelsMissing ?? ['x']).length === 0 && p57.ok === true,
|
|
3001
|
+
`部件=${p57.chartsOut}/${p57.chartsExp}|工作簿=${p57.chartEmbedsOut}|已比对工作表=${p57.chartSheetsChecked}|数据不符=${JSON.stringify(p57.chartDataMismatch ?? null)}`)
|
|
3002
|
+
// 显式 render: vector 的降级路必须**还在**(形状计数自证不丢,且该页不产图表部件)
|
|
3003
|
+
await writeFile(join(work57, 'pages', '02.yaml'), ['pageType: content', 'elements:',
|
|
3004
|
+
' - elementId: vbar', ' elementType: chart', ' bounds: [40, 40, 500, 300]',
|
|
3005
|
+
' chart: {type: bar, data: {cols: ["季度", "值"], rows: [["Q1", 12], ["Q2", 18]]}, render: vector}', ''].join('\n'), 'utf8')
|
|
3006
|
+
const deckText57 = await (await import('node:fs/promises')).readFile(join(work57, 'deck.yaml'), 'utf8')
|
|
3007
|
+
await writeFile(join(work57, 'deck.yaml'), deckText57.replace(' - pages/01.yaml', ' - pages/01.yaml\n - pages/02.yaml'), 'utf8')
|
|
3008
|
+
const r57v = await exportPptx(await resolveDeck(work57), { out: join(work57, 'out-v.pptx') })
|
|
3009
|
+
const p57v = r57v.parity ?? {}
|
|
3010
|
+
ok('§57 成品内容自证:`chart.render=vector` 仍走矢量拼绘(形状计数自证不丢,且该页不产图表部件)',
|
|
3011
|
+
p57v.chartShapesExp === 2 && p57v.chartShapesOut === 2 && p57v.chartsOut === 1 && r57v.slides === 2,
|
|
3012
|
+
`形状=${p57v.chartShapesOut}/${p57v.chartShapesExp}|全册图表部件=${p57v.chartsOut}(只第 1 页有原生)|页数=${r57v.slides}`)
|
|
3013
|
+
await rm(work57, { recursive: true, force: true })
|
|
3014
|
+
}
|
|
3015
|
+
|
|
2698
3016
|
// 37.10 【必须是最后一条断言】引用计数自证:文档里 "smoke … N 断言" 必须等于本次真实断言总数。
|
|
2699
3017
|
// 历史形状:加断言后 README×3 + docs/02 + docs/06×2 + 手册 全靠人工同步,迟早漏一处。
|
|
2700
3018
|
// 只扫"当前状态"文档(README / 技术报告 / 评审测试矩阵 / 使用手册);docs/01/03/04 里的历史数字是记录,不动。
|
|
@@ -2721,5 +3039,11 @@ for (const rel of countFiles) {
|
|
|
2721
3039
|
ok('文档计数自证:所有"smoke … N 断言 / N/N"都等于本次真实断言数(加断言必须同步 6 处引用)',
|
|
2722
3040
|
staleCounts.length === 0, staleCounts.length ? `过期引用:${staleCounts.join('、')}` : `全部 = ${liveTotal}`)
|
|
2723
3041
|
|
|
3042
|
+
// 收尾:恢复 DSH_HOME 并清掉整轮隔离目录(用户层产物都在里面,见文件顶部的全局隔离说明)。
|
|
3043
|
+
try { await rm(SMOKE_HOME, { recursive: true, force: true }) } catch { /* 忽略 */ }
|
|
3044
|
+
try { await rm(join(smokeDir, '.tmp-home-probe'), { recursive: true, force: true }) } catch { /* 忽略 */ }
|
|
3045
|
+
if (PREV_SMOKE_HOME === undefined) delete process.env.DSH_HOME
|
|
3046
|
+
else process.env.DSH_HOME = PREV_SMOKE_HOME
|
|
3047
|
+
|
|
2724
3048
|
console.log(`\n==== 结果:${pass} 通过 / ${fail} 失败 ====`)
|
|
2725
3049
|
process.exit(fail > 0 ? 1 : 0)
|