diff --git a/.github/workflows/html2video-for-mcode-smoke.yml b/.github/workflows/html2video-for-mcode-smoke.yml new file mode 100644 index 00000000..24dbc216 --- /dev/null +++ b/.github/workflows/html2video-for-mcode-smoke.yml @@ -0,0 +1,97 @@ +# html2video-for-mcode · scoped smoke CI +# 官方主 CI(ci.yml)只跑 validate + node --test(无 ffmpeg/playwright, 渲染冒烟会 skip)。 +# 本 workflow 按 CONTRIBUTING/ci.yml 注释给出的"单插件 scoped workflow"模式, +# 装齐依赖后真实执行全部测试 —— 这是 PR #41 评审要求的可执行测试证据。 +# 触发条件: 仅本插件目录(.github/workflows/ 本文件除外)或本文件自身变更。 +# +# 第三轮 review 修正(2026-09-18, 1.7.1 才真正推上来 —— 1.7.0 时这份修正只改了本地没推送, +# PR 分支上一直是旧枚举版, 漏跑 cover-transition / review-round2 / review-round3 / subtitles-invalidate): +# ① 测试文件不再逐个枚举, 改用 shell glob `tests/*.test.mjs` —— 枚举写法每加一个测试文件就要 +# 手工记得改这里, 历史上已经漏过 css-kit / tokens-fx / chart-kit / table-kit 与上述四个文件。 +# ② 补 permissions: contents: read(与本仓库其余 scoped workflow 一致, 只读仓库)。 +# ③ actions 按仓库主 ci.yml 的同一批版本做**完整 SHA pin**(附版本注释), 不用浮动 tag; +# SHA 由 gh api repos///commits/ 解析得到, 40 位整(主 ci.yml 的 checkout +# 那行多了一位字符, 那是宿主仓库自己的笔误, 不去动它 —— 见 PR comment)。 +# +# 第四轮 CHANGES_REQUESTED 修正(2026-09-21): 评审要求 exact-head 证据覆盖 **Windows** —— +# 新增 windows-latest 作业(同一条命令, 依赖换成 choco ffmpeg + chromium)。两个作业的命令与 +# 依赖组合已先在技能镜像仓的 CI 上双平台跑绿(248 pass / 0 fail / 0 skip, 两个平台各一次), +# Node 版本也从 22 提到 24 与那份证过的配置对齐(此 workflow 此前因审批门从未真正执行过)。 +# ffmpeg 一律走系统包(PATH 发现): 测试会把脚本放进临时项目目录当 cwd 跑, node_modules 发现 +# 依赖 cwd/上两层锚点, 在插件这种深层布局里够不到仓库根的 node_modules。 +name: html2video-for-mcode smoke + +on: + pull_request: + paths: + - 'plugins/Wzdhehe/html2video-for-mcode/**' + - '.github/workflows/html2video-for-mcode-smoke.yml' + push: + branches: [main] + paths: + - 'plugins/Wzdhehe/html2video-for-mcode/**' + - '.github/workflows/html2video-for-mcode-smoke.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + smoke-linux: + name: smoke (ubuntu-latest · ffmpeg + chromium) + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + + - name: Install ffmpeg + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg + + - name: Install playwright (no package.json / lockfile changes) + run: | + npm install --no-save --no-package-lock playwright + npx playwright install --with-deps chromium + + # 一条命令跑完 tests/ 下的**全部** *.test.mjs(安全 / 策略 / 模板 / 受管块 / 渲染冒烟)。 + # glob 交给 shell 展开: 以后新增测试文件自动进 CI, 不需要改这里(旧枚举写法漏过 8 个文件)。 + # 单测超时 300s: 渲染冒烟里的 capture / build-video 在 CI 上要几十秒。 + - name: All tests (security · policy · templates · css blocks · render smoke) + run: | + node --test --test-timeout=300000 \ + plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/*.test.mjs + + smoke-windows: + name: smoke (windows-latest · ffmpeg + chromium) + runs-on: windows-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + + # choco 的 ffmpeg 包含 ffmpeg.exe 与 ffprobe.exe, 落在 PATH 上 + - name: Install ffmpeg + shell: bash + run: choco install ffmpeg -y --no-progress + + - name: Install playwright (no package.json / lockfile changes) + shell: bash + run: | + npm install --no-save --no-package-lock playwright + npx playwright install chromium + + - name: All tests (security · policy · templates · css blocks · render smoke) + shell: bash + run: | + node --test --test-timeout=300000 \ + plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/*.test.mjs diff --git a/plugins/Wzdhehe/html2video-for-mcode/.claude-plugin/plugin.json b/plugins/Wzdhehe/html2video-for-mcode/.claude-plugin/plugin.json new file mode 100644 index 00000000..282237ec --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "html2video-for-mcode", + "version": "1.9.10", + "description": "Turn a topic, outline, or script into a narrated MP4: HTML slides with staged entrance animations, TTS voiceover, ffmpeg assembly, and ASR verification.", + "skills": [ + "./skills/html2video-for-mcode/SKILL.md" + ] +} diff --git a/plugins/Wzdhehe/html2video-for-mcode/.gitattributes b/plugins/Wzdhehe/html2video-for-mcode/.gitattributes new file mode 100644 index 00000000..c304db3a --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/.gitattributes @@ -0,0 +1,10 @@ +# Force LF for this plugin's text files. Added in 1.9.3: nine files carried CRLF +# (authored on Windows), which made `git diff --check` report trailing-whitespace +# errors on the PR under default whitespace rules. Same convention as +# plugins/antianqi/mcode-island/.gitattributes. +# Override at clone time: `git config core.autocrlf input`. +* text=auto eol=lf +*.md text eol=lf +*.mjs text eol=lf +*.json text eol=lf +LICENSE text eol=lf diff --git a/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md b/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md new file mode 100644 index 00000000..5298d5a3 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/CHANGELOG.md @@ -0,0 +1,475 @@ +# Changelog + +## 1.9.10 — 2026-09-23 + +**Round-21 review: subtitle line-width geometry corrected, and the 18-char rule's drift closed** (two-axis review of 1.9.9 found one hard inconsistency and one wrong baseline — both reproduced before fixing) + +- **Rule drift (hard):** 1.9.9 rewrote the subtitle wrap rule in `SKILL.md` and `references/authoring.md`, but three surfaces kept the old ">18 chars" rule — `references/render.md` ("A single sentence >18 chars … gets a warning from plan-timings"), `references/tts-and-timing.md` ("the single-line subtitle limit is 18 chars for Chinese / 42 characters for English") and `evals/evals.json` ("单句建议 ≤18 字"). All three now carry the width-aware rule (and `render.md` is a file 1.9.9 itself had edited — so this was a missed spot, not an untouched file). +- **The 1080 baseline published in 1.9.9 was wrong** — correction to that entry: its "≈18 CJK chars per line at 1080, ≈32 at 1920" figures were a restatement of the superseded vertical baseline. The pill's font **and** padding both scale with `--sub-scale = clamp(W/1920, 0.75, 1.25)`, so per-line capacity is **not** linear in width: true values are ≈**24** CJK chars at 1080, ≈**33** at 1920, ≈**35** at 2560. The `18 × W/1080` estimate over-warned at 1080 by ~27% and — the dangerous direction — missed real three-line wraps at 2560 (estimated 42 vs true 35.6). All five doc surfaces now state the corrected numbers and separate the **craft guideline** (≈18 CJK / ≈42 English for one-liners) from the **pill capacity** the gate uses. +- **Single source:** the pill geometry (box width 0.729167, side padding 34px, font 40px, the scale clamp) now lives once in `tools.SUB_GEOMETRY` / `subScale` / `subLineCap`; `plan-timings` derives the line cap from it and `capture` builds the `.kit-sub` CSS from the same constants — the two copies had already drifted (the review's Duplicated Code finding). +- **Entry guard (same review):** the same-basename fallback now **announces itself on stderr** when it fires — the trade is "a loud, self-annotated extra run" against "a silent no-op" (the silent kind cost a 10-minute mystery in the field); its stated justification no longer claims cases `realpath` already covers. Also from the review's small items: the unused `fileURLToPath` import in `preview-page.mjs` is gone, the `real` helper is renamed `realpathOf`, and the guard's `!entry` branch is now actually covered (explicit `null` — passing `undefined` hits the default parameter and never reached it). +- **Tests +1 (257 in fourteen files):** the geometry numbers are pinned (`subLineCap` = 24/33/35 CJK, 55 Latin; reverting to the linear formula turns it red), and the plan-timings case now exercises 2/3/4-line samples so the "three or more" predicate is tested at its exact boundary (the review noted the 1.9.9 title claimed a 3-line case it didn't run). + +## 1.9.9 — 2026-09-22 + +**Cloud-sandbox field batch: entry guard could silently no-op; subtitle wrap warning mis-fired** (report from a mavis-style web sandbox session, sorted by the reporter into skill issues vs environment quirks) + +- **🔴 Silent no-op entry guard (the core bug).** `preview-page.mjs`'s "run `main` only when executed directly" check compared `path.resolve(argv[1])` with `fileURLToPath(import.meta.url)` — for an entry reached through a **symlinked path** the module URL is the real path while `argv[1]` is the link path, so the comparison is always false: the script exits 0 with **no output and no work done** (the reporter burned ~10 minutes proving `main` hadn't run). The check is now `tools.isMainModule` (single source): realpath both sides, plus a same-basename fallback so the "silent skip" shape is impossible in practice. Clarified in the same comment: `node -e "import(…)"` does not run `main` by design (import semantics) — run CLIs as `node `; + const m = /]*>/i.exec(html); + if (!m) return script + html; + return html.slice(0, m.index + m[0].length) + '\n' + script + html.slice(m.index + m[0].length); +} + +// ─────────────────────────── 放映页 ─────────────────────────── + +export function buildPlayPage({ + topic = '', lang = 'zh', slides = [], cssNote = '', generatedAt = '', + narration = true, // 是否加载口播文案 UI(没有 clauses 或 --no-script 时为 false) + timing = false, // 是否有对时数据(只影响标题上的"(未对时)"标注) + fallbackNote = '', // "还没对时/等间隔预览"的如实说明(与口播 UI 无关, 画面上也要说清) + canvas = { w: 1920, h: 1080 }, // 画布尺寸(竖版 1080×1920): 舞台缩放与缩略图比例都按它算 +} = {}) { + // UI 双语(实测反馈: 英文项目的放映页整套中文按钮)。lang 来自 script.json(en* → 英文, 其余中文) + const T = String(lang).toLowerCase().startsWith('en') ? { + title: 'Play', fxOn: 'Motion on', fxOff: 'Motion off', narrOn: 'Narration on', narrOff: 'Narration off', + overview: 'Overview', panelH: 'Narration for this slide', untimed: '(untimed)', + fxTitle: 'Motion on / off (key X)', narrTitle: 'Narration on / off (key P)', + prevTitle: 'Previous level / slide', nextTitle: 'Next level / slide', + hintStep: 'reveal level / slide', hintFx: 'motion on / off', hintNarr: 'narration on / off', + hintOv: 'overview', hintFs: 'fullscreen', level: 'step', + cut: 'Cut', xfade: 'Dissolve', transTitle: 'Slide transition: cut / dissolve (compare, then set script.json transition)', hintTrans: 'slide transition', + gen: 'Snapshots generated', genNote: 'same source as the final video (no audio, no subtitles)', + noThumbA: 'No thumbnail (preview/', ovH: 'Overview · click any slide to jump (thumbnails from preview/*.png)', + } : { + title: '放映页', fxOn: '动效开', fxOff: '动效关', narrOn: '口播开', narrOff: '口播关', + overview: '总览', panelH: '本张口播文案', untimed: '(未对时)', + fxTitle: '动效开 / 动效关(快捷键 X)', narrTitle: '口播开 / 口播关(快捷键 P)', + prevTitle: '上一级 / 上一张', nextTitle: '下一级 / 下一张', + hintStep: '逐级入场 / 翻页', hintFx: '动效开 / 动效关', hintNarr: '口播开 / 口播关', + hintOv: '总览', hintFs: '全屏', level: '级', + cut: '硬切', xfade: '溶解', transTitle: '切页方式:硬切 / 溶解(现场对比后写进 script.json 的 transition)', hintTrans: '切页方式', + gen: '快照生成于', genNote: '画面与成片同源(无声、无字幕)', + noThumbA: '无缩略图(preview/', ovH: '总览 · 点任意一张跳转(缩略图来自 preview/*.png)', + }; + const model = slides.map(s => ({ + id: s.id, name: s.name, title: s.title ?? '', + src: s.copy, nofx: s.copyNofx, + steps: (s.steps && s.steps.length) ? s.steps : [1], // 动效开时逐级揭示的 stage 序列 + clauses: (s.clauses ?? []).map(c => ({ stage: c.stage ?? null, text: c.text ?? '', text2: c.text2 ?? '' })), + })); + const json = JSON.stringify(model).replace(/⚠ ${esc(cssNote)}

` : ''; + const notice = fallbackNote ? `
${esc(fallbackNote)}
` : ''; + + return ` + + + + + +${T.title} · ${esc(topic) || 'html2video'} · html2video-for-mcode + + + +
+
+ ${esc(topic) || T.title} + 1 / ${slides.length} + +
+ ${notice} +
+
+
+ ${narration ? `` : ''} +
+
+ + ←→ ${T.hintStep} + X ${T.hintFx} + ${narration ? `P ${T.hintNarr}` : ''} + O ${T.hintOv} + F ${T.hintFs} + T ${T.hintTrans} + + + + + ${narration ? `` : ''} + + + + + ${T.gen} ${esc(generatedAt)} · ${T.genNote} +
+

${T.ovH}

+ + + +`; +} + +// ─────────────────────────── 主流程 ─────────────────────────── + +function main() { + const argv = process.argv.slice(2); + const dir = positionalDir(argv); + const OPEN = argv.includes('--open'); + const NO_SCRIPT = argv.includes('--no-script'); // 不加载口播文案 UI(只想看 HTML 画面时) + process.env.KIT_PROJECT_DIR = dir; + + const scriptPath = path.join(dir, 'script.json'); + if (!fs.existsSync(scriptPath)) { console.error(`✗ 找不到 ${scriptPath}`); process.exit(1); } + const script = JSON.parse(fs.readFileSync(scriptPath, 'utf8')); + validateScriptPaths(script, dir); // id/html 派生路径收监(与 capture 同一道门) + + const timingsPath = path.join(dir, 'build', 'timings.json'); + const hasTimings = fs.existsSync(timingsPath); + const timings = hasTimings ? JSON.parse(fs.readFileSync(timingsPath, 'utf8')) : null; + + const slidesDir = path.join(dir, 'slides'); + const outDir = safeOut(dir, 'preview', 'play'); + fs.mkdirSync(outDir, { recursive: true }); + + // 项目 tokens.css 落后于技能当前版本(缺 no-fx / 图表 / 表格任一段)时给副本兜底注入当前版, + // 并在页面与终端如实说明 —— 否则"关动效对照"会把画面锁在入场前的透明态, 旧图表规则也会 + // 把新版画法(如 .chart-ticks)渲染坏。注入的 +
小标题

标题两行标题两行

`); + assert.equal(runSkill('plan-timings.mjs', [proj]).status, 0); + const bad = runSkill('capture.mjs', [proj, '--mode', 'still']); + assert.equal(bad.status, 0, bad.stdout + bad.stderr); + assert.match(bad.stdout + bad.stderr, /布局几何/, '溢出必须被点名: ' + (bad.stdout + bad.stderr).slice(-400)); + assert.match(bad.stdout + bad.stderr, /溢出|贴顶/, '要给出数字方向的定位'); + // 矮内容不许误报(03-kpi-grid 那种健康页) + fs.writeFileSync(slide, ` + +

短标题

一点内容

`); + const ok = runSkill('capture.mjs', [proj, '--mode', 'still']); + assert.equal(ok.status, 0, ok.stdout + ok.stderr); + assert.ok(!/布局几何/.test(ok.stdout + ok.stderr), '矮内容不该报: ' + (ok.stdout + ok.stderr).slice(-300)); +}); + +test('骨架 .layout 必须是 safe center + 上下 padding(溢出退回顶部对齐, 顶部永不被裁)', () => { + const proj = tmpdir(); + assert.equal(runSkill('init-project.mjs', [proj, '--topic', 'TplContract']).status, 0); + const tpl = fs.readFileSync(path.join(proj, 'slides', '_template.html'), 'utf8'); + assert.match(tpl, /justify-content:\s*safe center/, '普通 center 溢出时会把内容推上画布裁掉(实测 kicker y≈17px)'); + assert.match(tpl, /padding:\s*120px\s+160px\s+190px/, '上 padding 给品牌栏、下 padding 给字幕带'); +}); + +// 2026-09-22 云沙箱反馈 + 2026-09-23 复核: 字幕"会换行"警告曾按 18 字一刀切 —— 18 是被取代的 +// 竖屏基准, 真实行宽 = tools.subLineCap(缩放带钳位, 非线性): 中文 1080≈24 字/行、1920≈33、2560≈35。 +// 折 2 行可读, 折 3 行起才警告。样例覆盖 2/3/4 行三档。 +test('plan-timings 字幕行宽: 折 2 行不报、3 行起报(样例 2/3/4 行)', async t => { + if (!FFMPEG) return t.skip('无 ffmpeg/ffprobe(ffprobe 实测音频)'); + const proj = tmpdir(); + assert.equal(runSkill('init-project.mjs', [proj, '--topic', 'SubCap']).status, 0); + const g = spawnSync(FFMPEG, ['-v', 'error', '-f', 'lavfi', '-i', 'anullsrc=r=32000:cl=mono', '-t', '3', + '-c:a', 'libmp3lame', '-b:a', '64k', '-y', path.join(proj, 'audio', '01.mp3')], { windowsHide: true }); + assert.equal(g.status, 0, g.stderr ?? ''); + const sp = path.join(proj, 'script.json'); + const s = JSON.parse(fs.readFileSync(sp, 'utf8')); + s.width = 1920; s.height = 1080; + s.slides = [{ id: '01', html: '01-title.html', audio: '01.mp3', clauses: [ + { stage: 1, text: '一'.repeat(60) }, // 60 ÷ ≈33 字/行 = 2 行 —— 可读, 不报 + { stage: 2, text: '二'.repeat(70) }, // 70 = 3 行 —— 报 + { stage: 3, text: '三'.repeat(100) }, // 100 = 4 行 —— 报 + ] }]; + fs.writeFileSync(sp, JSON.stringify(s, null, 2)); + fs.writeFileSync(path.join(proj, 'slides', '01-title.html'), + '
'); + const r = runSkill('plan-timings.mjs', [proj]); + assert.equal(r.status, 0, r.stdout + r.stderr); + const out = r.stdout + r.stderr; + assert.ok(!/第 1 句.*会折/.test(out), '折 2 行不该报(1920 宽行宽 ≈33 字/行): ' + out.slice(-300)); + assert.match(out, /第 2 句.*会折 3 行/, '折 3 行必须报: ' + out.slice(-300)); + assert.match(out, /第 3 句.*会折 4 行/, '折 4 行必须报: ' + out.slice(-300)); +}); + +// 2026-09-23 复核钉死: 胶囊几何唯一来源 tools.subLineCap —— 缩放带钳位, 按宽度线性外推是错的 +// (1.9.9 的 18×W/1080 在 1080 过报 27%、2560 漏报真折行)。数字来自 .kit-sub 的 CSS 常量。 +test('subLineCap: 字幕胶囊行宽几何(钳位后非线性, 数字钉死)', async () => { + const { subLineCap, subScale } = await import('file://' + path.join(SCRIPTS, 'tools.mjs').split(path.sep).join('/')); + assert.equal(subScale(1080), 0.75, '1080 宽钳到下限 0.75'); + assert.equal(subLineCap(1080), 24); // (0.729167·1080 − 68·0.75)/(40·0.75) ≈ 24.6 + assert.equal(subLineCap(1920), 33); // (0.729167·1920 − 68)/40 ≈ 33.3 + assert.equal(subLineCap(2560), 35); // 缩放钳到 1.25 ≈ 35.6 —— 线性外推会给 42(错) + assert.equal(subLineCap(1080, 0.44), 55); // 拉丁 ≈0.44em/字符 +}); diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/review-round2.test.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/review-round2.test.mjs new file mode 100644 index 00000000..83f743e2 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/review-round2.test.mjs @@ -0,0 +1,290 @@ +// 二审(2026-09-18)修复的回归测试: 输出收监 / 网络边界 / 属性解析 / ASR 错误计入 / ffmpeg 发现 / 路径别名 +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import http from 'node:http'; +import { spawnSync } from 'node:child_process'; +import { runSkill, runSkillAsync, mkproj, tmpdir, skill } from './helpers.mjs'; +import { assertResolvedHost, PolicyError } from '../scripts/url-policy.mjs'; +import { SCRIPTS } from './helpers.mjs'; +import { findTool, loadPackage } from '../scripts/tools.mjs'; + +const HAS_PW = !!(await loadPackage('playwright')); +const { injectHtmlVars, addNoFx } = await import('file://' + path.join(SCRIPTS, 'preview-page.mjs').replace(/\\/g, '/')); + +describe('二审 · 输出收监(项目内目录段是符号链接时, 不得写到/删到项目外)', () => { + // Windows 无开发者模式时 symlinkSync 会失败, 但**目录 junction 不需要管理员**(cmd mklink /J); + // 两条路都试, 都不行才 skip —— 这个 canary 用例必须在能建的平台上真跑 + const linkDir = (target, linkPath) => { + try { fs.symlinkSync(target, linkPath, 'dir'); return true; } catch { /* 试 junction */ } + try { + const r = spawnSync('cmd', ['/c', 'mklink', '/J', linkPath, target], { encoding: 'utf8', windowsHide: true }); + return r.status === 0 && fs.existsSync(linkPath); + } catch { return false; } + }; + const canLink = (() => { + try { + const a = tmpdir(), b = tmpdir(); + const ok = linkDir(b, path.join(a, 'l')); + fs.rmSync(a, { recursive: true, force: true }); + return ok; + } catch { return false; } + })(); + + // 这两个 canary 都要靠 capture / preview-page 真跑到"收监检查"才观察得到越界拒绝 —— + // 而两个脚本启动第一件事就是加载 playwright: 无 playwright 时它们先退出 2, + // 用例要么误判"通过"(preview-page 只断言非零), 要么误报"失败"(capture 还断言报错文案)。 + // 所以无 playwright 必须带原因跳过(与 ASR 用例同理) —— 否则在无 playwright 但可建链接的 + // 环境(ubuntu 裸 CI)上, 这两条会把宿主主 CI 染红(终轮复查在 Spec 轴实测抓到)。 + const CANARY_SKIP = [ + !canLink && '当前环境建不了符号链接/junction', + !HAS_PW && '无 playwright(capture/preview-page 启动即需要, 观察不到收监检查; 真跑由 scoped smoke workflow 覆盖)', + ].filter(Boolean).join(' · '); + test('capture: build/frames 是指向项目外的符号链接 → 拒绝, canary 目录完好', { skip: CANARY_SKIP || undefined }, () => { + const proj = tmpdir(); + const canary = tmpdir(); + fs.writeFileSync(path.join(canary, 'keep.txt'), 'DO NOT DELETE'); + mkproj(proj, { slides: [{ id: '01', html: '01.html', audio: '01.mp3', clauses: [{ stage: 1, text: 'x' }] }] }); + fs.writeFileSync(path.join(proj, 'build', 'timings.json'), JSON.stringify({ fps: 30, total: 1, slides: [{ id: '01', duration: 1, stages: { 1: 0 }, clauses: [{ stage: 1, start: 0, text: 'x' }] }] })); + fs.writeFileSync(path.join(proj, 'slides', '01.html'), '

x

'); + fs.rmSync(path.join(proj, 'build', 'frames'), { recursive: true, force: true }); + assert.ok(linkDir(canary, path.join(proj, 'build', 'frames')), '测试前置: 链接要建成功'); + const r = runSkill('capture.mjs', [proj, '--mode', 'motion']); + assert.notEqual(r.status, 0, '输出目录经符号链接越界必须拒绝'); + assert.ok(/符号链接|越出项目目录/.test(r.stdout + r.stderr), `要点明原因, 实际: ${(r.stdout + r.stderr).slice(-200)}`); + assert.equal(fs.readFileSync(path.join(canary, 'keep.txt'), 'utf8'), 'DO NOT DELETE', 'canary 必须完好'); + }); + + test('preview-page: preview 是指向项目外的符号链接 → 拒绝写快照', { skip: CANARY_SKIP || undefined }, () => { + const proj = tmpdir(); + const canary = tmpdir(); + mkproj(proj, { slides: [{ id: '01', html: '01.html', audio: '01.mp3', clauses: [{ stage: 1, text: 'x' }] }] }); + fs.writeFileSync(path.join(proj, 'slides', '01.html'), '

x

'); + assert.ok(linkDir(canary, path.join(proj, 'preview')), '测试前置: 链接要建成功'); + const r = runSkill('preview-page.mjs', [proj]); + assert.notEqual(r.status, 0); + assert.deepEqual(fs.readdirSync(canary), [], 'canary 目录里不得出现任何文件'); + }); + + test('preview-page: outDir 里的叶子(index.html)是文件符号链接 → 拒绝, canary 文件完好(1.7.5 复查①)', { skip: CANARY_SKIP || undefined }, (t) => { + // 目录收监了不等于叶子安全: 预置在 preview/play/ 里的**文件**符号链接会把 writeFileSync 引到 + // 项目外。文件符号链接不需要管理员, 但需要 Windows 开发者模式 —— 建不了就带因跳过(ubuntu CI 真跑)。 + const proj = tmpdir(); + const canaryFile = path.join(tmpdir(), 'victim.html'); + fs.writeFileSync(canaryFile, 'ORIGINAL'); + mkproj(proj, { slides: [{ id: '01', html: '01.html', audio: '01.mp3', clauses: [{ stage: 1, text: 'x' }] }] }); + fs.writeFileSync(path.join(proj, 'slides', '01.html'), '

x

'); + fs.mkdirSync(path.join(proj, 'preview', 'play'), { recursive: true }); + let made = false; + try { fs.symlinkSync(canaryFile, path.join(proj, 'preview', 'play', 'index.html'), 'file'); made = true; } catch { /* 需要开发者模式 */ } + if (!made) return t.skip('当前环境建不了文件符号链接(Windows 需开发者模式; ubuntu CI 真跑)'); + const r = runSkill('preview-page.mjs', [proj]); + assert.notEqual(r.status, 0, `叶子符号链接必须拒绝, 实际: ${(r.stdout + r.stderr).slice(-200)}`); + assert.equal(fs.readFileSync(canaryFile, 'utf8'), 'ORIGINAL', 'canary 文件不得被改写'); + + // 场景②: 逐张叶子(01.html)同样收监 —— 三个叶子写点中 index.html 之外的路径(十三轮 review 指出 + // canary 只埋了 index 一个)。独立项目, 埋 01.html, expect 拒绝且 canary 完好。 + const proj2 = tmpdir(); + const canary2 = path.join(tmpdir(), 'victim2.html'); + fs.writeFileSync(canary2, 'ORIGINAL'); + mkproj(proj2, { slides: [{ id: '01', html: '01.html', audio: '01.mp3', clauses: [{ stage: 1, text: 'x' }] }] }); + fs.writeFileSync(path.join(proj2, 'slides', '01.html'), '

x

'); + fs.mkdirSync(path.join(proj2, 'preview', 'play'), { recursive: true }); + try { fs.symlinkSync(canary2, path.join(proj2, 'preview', 'play', '01.html'), 'file'); } catch { + return t.skip('当前环境建不了文件符号链接(Windows 需开发者模式; ubuntu CI 真跑)'); + } + const r2 = runSkill('preview-page.mjs', [proj2]); + assert.notEqual(r2.status, 0, `逐张叶子符号链接必须拒绝, 实际: ${(r2.stdout + r2.stderr).slice(-200)}`); + assert.equal(fs.readFileSync(canary2, 'utf8'), 'ORIGINAL', 'canary 文件不得被改写'); + }); + + test('grab-frames: introspect 叶子是文件符号链接 → 拒绝, canary 完好(九轮 review 同类收尾)', { skip: !findTool('ffmpeg') && '无 ffmpeg(需从成片抽帧; 真跑由 scoped smoke workflow 覆盖)' }, (t) => { + // 与 preview-page 叶子同类: 目录收监了, 但 frame--.png 若是预置的文件符号链接, + // ffmpeg -y 会顺着写出去。文件符号链接需要 Windows 开发者模式 —— 建不了带因跳过, CI 真跑。 + const proj = tmpdir(); + const canaryFile = path.join(tmpdir(), 'victim.png'); + fs.writeFileSync(canaryFile, 'ORIGINAL'); + mkproj(proj, { slides: [{ id: '01', html: '01.html', audio: '01.mp3', clauses: [{ stage: 1, text: 'x' }] }] }); + fs.writeFileSync(path.join(proj, 'slides', '01.html'), '

x

'); + fs.mkdirSync(path.join(proj, 'build'), { recursive: true }); + fs.writeFileSync(path.join(proj, 'build', 'timings.json'), JSON.stringify( + { fps: 30, total: 1, slides: [{ id: '01', duration: 1, stages: { 1: 0 }, clauses: [{ stage: 1, start: 0, text: 'x' }] }] })); + const FFMPEG = findTool('ffmpeg'); + fs.mkdirSync(path.join(proj, 'out'), { recursive: true }); + assert.equal(spawnSync(FFMPEG, ['-v', 'error', '-f', 'lavfi', '-i', 'color=c=0x808080:s=640x360:r=30', '-t', '1', + '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-y', path.join(proj, 'out', 'final.mp4')], + { windowsHide: true }).status, 0, '应能造出占位成片'); + fs.mkdirSync(path.join(proj, 'build', 'introspect'), { recursive: true }); + let made = false; + try { fs.symlinkSync(canaryFile, path.join(proj, 'build', 'introspect', 'frame-01-end.png'), 'file'); made = true; } catch { /* 需要开发者模式 */ } + if (!made) return t.skip('当前环境建不了文件符号链接(Windows 需开发者模式; ubuntu CI 真跑)'); + const r = runSkill('grab-frames.mjs', [proj]); + assert.notEqual(r.status, 0, `叶子符号链接必须拒绝, 实际: ${(r.stdout + r.stderr).slice(-200)}`); + assert.equal(fs.readFileSync(canaryFile, 'utf8'), 'ORIGINAL', 'canary 文件不得被 ffmpeg 改写'); + }); + + test('asr 超限转码的临时文件不落输入目录(十轮 review: 同类最后一处)', { skip: !findTool('ffmpeg') && '无 ffmpeg(需真转码 500s+ 音频)' }, async (t) => { + // 旧实现把 .asr-<名>.16k.mp3 写在输入文件旁边 —— 输入在项目内时, 预置的同名文件符号链接 + // 会被 ffmpeg -y 写穿。现在临时文件进 os.tmpdir() 专属目录: 输入目录里不得出现任何转码产物。 + const FFMPEG = findTool('ffmpeg'); + const proj = tmpdir(); + mkproj(proj, { slides: [] }); + fs.mkdirSync(path.join(proj, 'audio'), { recursive: true }); + const big = path.join(proj, 'audio', 'long.mp3'); + assert.equal(spawnSync(FFMPEG, ['-v', 'error', '-f', 'lavfi', '-i', 'anullsrc=r=32000:cl=mono', + '-t', '501', '-c:a', 'libmp3lame', '-b:a', '32k', '-y', big], { windowsHide: true }).status, 0, + '应能造出 501s 音频(超过 500s 上限 → 触发转码)'); + const canaryFile = path.join(tmpdir(), 'victim.mp3'); + fs.writeFileSync(canaryFile, 'ORIGINAL'); + const planted = path.join(proj, 'audio', '.asr-long.16k.mp3'); + let made = false; + try { fs.symlinkSync(canaryFile, planted, 'file'); made = true; } catch { /* 需要开发者模式 */ } + if (!made) return t.skip('当前环境建不了文件符号链接(Windows 需开发者模式; ubuntu CI 真跑)'); + // 注意: planted 本身就是个存在的路径(链接), 不能用 existsSync 断言"没出现"(必真) —— + // 用目录前后 diff 断言"没有**新**条目"(1.7.7 canary 的这个断言被十一轮 review 抓到不可满足)。 + const before = new Set(fs.readdirSync(path.join(proj, 'audio'))); + const r = await runSkillAsync('asr.mjs', [proj, '--file', big, '--provider', 'api', + '--allow-any-endpoint', '--base-url', 'http://127.0.0.1:9'], // 转码后请求死端口 → 快速失败, 不碰真网 + { env: { ...process.env, MINIMAX_API_KEY: 'fake-key-for-test' } }); + assert.notEqual(r.status, 0, '死端口请求必须失败(转码在前, 网络在后)'); + const newEntries = fs.readdirSync(path.join(proj, 'audio')).filter(f => !before.has(f)); + assert.deepEqual(newEntries, [], `输入目录不得出现转码产物: ${newEntries.join(', ')}`); + assert.equal(fs.readFileSync(canaryFile, 'utf8'), 'ORIGINAL', 'canary 文件不得被写穿'); + assert.equal(fs.lstatSync(planted).isSymbolicLink(), true, '预置链接不得被替换成普通文件'); + assert.deepEqual(fs.readdirSync(os.tmpdir()).filter(f => f.startsWith('asr-16k-')), [], + 'os.tmpdir() 的转码临时目录必须已清理(asr 的 finally 整目录删除)'); + }); +}); + +describe('二审 · 网络边界: 拒绝的目标必须一个请求都收不到', () => { + test('被策略拒绝的目标: 本地服务器收到的请求数必须是 0', async () => { + let hits = 0; + const srv = http.createServer((req, res) => { hits++; res.end('x'); }); + await new Promise(r => srv.listen(0, '127.0.0.1', r)); + const port = srv.address().port; + try { + // --url 直下模式(不需要 playwright): 目标是 loopback → 策略层就该拦住 + const r = await runSkillAsync('fetch-official-images.mjs', ['--url', `http://127.0.0.1:${port}/x.png`, '--out-dir', 'assets'], { cwd: tmpdir() }); + assert.equal(hits, 0, '被拒目标不得收到任何请求(闸门必须在连接之前)'); + assert.notEqual(r.status, 0); + // 公网域名形式但解析回环(rebinding): 字符串层放行, DNS 层必须拦 + const r2 = await runSkillAsync('fetch-official-images.mjs', ['--url', `http://localtest.me:${port}/x.png`, '--out-dir', 'assets'], { cwd: tmpdir() }); + if (/localtest\.me/.test(r2.stdout + r2.stderr)) { // 解析成功才可能有结论 + assert.equal(hits, 0, 'DNS 解析回环也必须拦在连接之前'); + assert.notEqual(r2.status, 0); + } + } finally { srv.close(); } + }); + + test('assertResolvedHost: 注入假解析器即可测(不需要真 DNS)', async () => { + const lookup = async () => [{ address: '127.0.0.1', family: 4 }]; + await assert.rejects(() => assertResolvedHost('https://cdn.example.com/a.png', { lookup }), (e) => e instanceof PolicyError); + const publicLookup = async () => [{ address: '93.184.216.34', family: 4 }]; + await assert.doesNotReject(() => assertResolvedHost('https://cdn.example.com/a.png', { lookup: publicLookup })); + // 云元数据地址同样要拦 + await assert.rejects(() => assertResolvedHost('https://evil.example.com/', { lookup: async () => [{ address: '169.254.169.254', family: 4 }] })); + // 裸主机名(无点)本就被字符串层拦, 这里直接放行不查 + await assert.doesNotReject(() => assertResolvedHost('http://localhost/', { lookup })); + }); +}); + +describe('二审 · 放映页属性解析(单引号/无引号不得产生重复属性)', () => { + test('单引号 style/class: 就地改写, 不得出现重复属性', () => { + const html = ``; + const out = injectHtmlVars(html, '--t2:4866ms'); + assert.equal((out.match(/style=/g) || []).length, 1, `不得出现第二个 style 属性: ${out}`); + assert.ok(out.includes('--t2:800ms;--t2:4866ms'), `应就地在原值后追加: ${out}`); + const nofx = addNoFx(html); + assert.equal((nofx.match(/class=/g) || []).length, 1, `不得出现第二个 class 属性: ${nofx}`); + assert.ok(/class='theme-a no-fx'|class="theme-a no-fx"/.test(nofx), `应在原类后追加: ${nofx}`); + }); + + test('无引号属性与已有 no-fx: 也要认出来', () => { + const out = injectHtmlVars('', '--t1:0ms'); + assert.equal((out.match(/style=/g) || []).length, 1, out); + assert.ok(out.includes('color:red;--t1:0ms'), out); + assert.equal(addNoFx(''), ''); + const once = addNoFx(''); + assert.equal(addNoFx(once), once, '已带 no-fx 时不重复添加'); + assert.equal(addNoFx(``).match(/no-fx/g).length, 1); + }); +}); + +describe('二审 · ASR 请求错误必须算失败并非零退出', () => { + // asr.mjs 启动第一件事就是 requireTool('ffmpeg')(切音频要用), 无 ffmpeg 时走不到请求那一步。 + // 主仓 CI(npm run check → 根目录 node --test)没有 ffmpeg —— 这条例外必须像其它渲染用例一样 + // 带原因跳过, 否则整个插件把主仓 CI 染红(第三轮复查实测踩到)。 + test('转写请求全失败(503) → 退出码非零且不计入通过', { skip: !findTool('ffmpeg') && '无 ffmpeg(asr 启动即需要; 真跑由 scoped smoke workflow 覆盖)' }, async () => { + // 本地假服务器: 一律 503(不需要真 Key, 也不需要真 ASR) + const srv = http.createServer((req, res) => { res.statusCode = 503; res.end('{"error":"busy"}'); }); + await new Promise(r => srv.listen(0, '127.0.0.1', r)); + const port = srv.address().port; + const proj = tmpdir(); + mkproj(proj, { slides: [{ id: '01', audio: '01.mp3', clauses: [{ stage: 1, text: '一。' }] }] }); + // asr.mjs 读的是 build-video --asr 切好的 asr/part-*.mp3, 这里直接造一个占位文件 + fs.mkdirSync(path.join(proj, 'asr'), { recursive: true }); + fs.writeFileSync(path.join(proj, 'asr', 'part-01-1.mp3'), 'not-really-mp3'); + try { + const r = await runSkillAsync('asr.mjs', [proj, '--provider', 'api', '--allow-any-endpoint', '--base-url', `http://127.0.0.1:${port}`], { + cwd: proj, env: { ...process.env, MINIMAX_API_KEY: 'fake-key-for-test', MINIMAX_REGION: 'cn' }, + }); + assert.notEqual(r.status, 0, `请求失败不能被当成通过: ${r.stdout}${r.stderr}`); + assert.match(r.stdout + r.stderr, /请求失败/, '要点名"请求失败"而不是混进通过数'); + } finally { srv.close(); } + }); + + test('asr --out: 越出项目目录 / 已存在不覆盖 → 在任何网络请求之前拒绝(1.7.5 复查③)', () => { + // --out 校验前置到 transcribe 之前, 所以这两条拒绝路径不需要网络也不需要 ffmpeg + const proj = tmpdir(); + const outside = path.dirname(proj); + mkproj(proj, { slides: [] }); + fs.writeFileSync(path.join(proj, 'asr-src.mp3'), 'stub-audio'); + fs.writeFileSync(path.join(proj, 'exists.srt'), 'OLD'); + const env = { ...process.env, MINIMAX_API_KEY: 'fake-key-for-test' }; + // ① 相对路径穿越 + const r1 = runSkill('asr.mjs', [proj, '--file', path.join(proj, 'asr-src.mp3'), '--out', '../escaped.txt'], { env }); + assert.notEqual(r1.status, 0, '穿越项目目录的 --out 必须被拒'); + assert.match(r1.stdout + r1.stderr, /越出项目目录|符号链接/, r1.stdout + r1.stderr); + assert.equal(fs.existsSync(path.join(outside, 'escaped.txt')), false, '拒绝必须发生在写入之前'); + // ② 绝对路径 + const r2 = runSkill('asr.mjs', [proj, '--file', path.join(proj, 'asr-src.mp3'), '--out', path.join(outside, 'abs.txt')], { env }); + assert.notEqual(r2.status, 0, '绝对路径 --out 必须被拒'); + assert.match(r2.stdout + r2.stderr, /相对路径/, r2.stdout + r2.stderr); + // ③ 项目内已存在 → 不覆盖 + const r3 = runSkill('asr.mjs', [proj, '--file', path.join(proj, 'asr-src.mp3'), '--out', 'exists.srt'], { env }); + assert.notEqual(r3.status, 0, '已存在的 --out 必须先拒绝'); + assert.match(r3.stdout + r3.stderr, /不覆盖/, r3.stdout + r3.stderr); + assert.equal(fs.readFileSync(path.join(proj, 'exists.srt'), 'utf8'), 'OLD', '旧内容不得被改写'); + }); +}); + +describe('二审 · ffmpeg 发现(项目内 node_modules)', () => { + test('findTool: 从项目目录调用时能找到项目内的 ffmpeg-static', async () => { + const { findTool } = await import('../scripts/tools.mjs'); + const proj = tmpdir(); + const exe = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'; + const dir = path.join(proj, 'node_modules', 'ffmpeg-static'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, exe), 'stub'); + // 模拟"在项目目录里执行": KIT_PROJECT_DIR 未设, 靠 cwd 找到 + const prev = process.env.KIT_PROJECT_DIR; + delete process.env.KIT_PROJECT_DIR; + const cwd = process.cwd(); + try { + process.chdir(proj); + assert.ok(findTool('ffmpeg', process.cwd()), '应能在项目目录里发现 ffmpeg-static'); + } finally { + process.chdir(cwd); + if (prev !== undefined) process.env.KIT_PROJECT_DIR = prev; + } + }); + + test('prep-image / asr 会把项目目录交给工具发现(源码级断言, 防回退)', () => { + const pre = fs.readFileSync(skill('prep-image.mjs'), 'utf8'); + assert.match(pre, /requireTool\('ffmpeg',\s*process\.cwd\(\)\)/, 'prep-image 必须把项目目录(此处为 cwd)传给 requireTool'); + const asr = fs.readFileSync(skill('asr.mjs'), 'utf8'); + assert.match(asr, /requireTool\('ffmpeg',\s*dir\)/, 'asr 必须把项目目录传给 requireTool'); + }); +}); diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/review-round3.test.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/review-round3.test.mjs new file mode 100644 index 00000000..d9b4d6c7 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/review-round3.test.mjs @@ -0,0 +1,475 @@ +// 第三轮 review(外部评审 + /code-review)修复的回归: D1–D6 功能缺陷 + 陈旧闸门 +// 每条都对应一个"能静默产生错误产物"的具体路径, 不是风格问题: +// D1 check-timing --calibrate 混着写回半份实测 + 谎报匹配方法 +// D2 `--topic X <项目目录>` 把 X 当项目目录, 在 cwd 静默建骨架 +// D3 fetch-official-images 的 --json 吃掉位置参数 / --min 只比宽度 / 全失败还退 0 +// D4 capture --mode/--dsf 与 grab-frames --at 的非法值一路带到 playwright/ffmpeg +// D5 check-slides 字幕带闸门误报 padding-bottom(且不要求绝对定位) +// D6 transition 裸字符串在闸门与渲染器里结论不一致 +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { runSkill, mkproj, tmpdir, SCRIPTS, LEGACY_TOKENS } from './helpers.mjs'; +import { pathToFileURL } from 'node:url'; +import { findTool, loadPackage } from '../scripts/tools.mjs'; + +const FFMPEG = findTool('ffmpeg'); +const { positionals, readTransition, positionalDir, VALUE_FLAGS } = await import( + pathToFileURL(path.join(SCRIPTS, 'tools.mjs')).href); + +const SLIDE = ` +

标题

`; + +describe('D1 · check-timing --calibrate 不写回半份实测', () => { + // 造一段"两句话 + 句间静音"的音频: 0–1.0s 噪声(第 1 句) → 1.0–1.6s 静音 → 1.6–2.6s 噪声(第 2 句) + // (silencedetect 阈值为 -38dB/0.18s, 所以句间用真静音、句子用可闻噪声) + const makeAudio = (proj) => { + const out = path.join(proj, 'audio', '01.mp3'); + const g = spawnSync(FFMPEG, ['-v', 'error', + '-f', 'lavfi', '-i', 'anoisesrc=d=1.0:c=pink:a=0.4:r=32000', + '-f', 'lavfi', '-i', 'anullsrc=r=32000:cl=mono:d=0.6', + '-f', 'lavfi', '-i', 'anoisesrc=d=1.0:c=pink:a=0.4:r=32000', + '-filter_complex', '[0:a][1:a][2:a]concat=n=3:v=0:a=1[out]', '-map', '[out]', + '-c:a', 'libmp3lame', '-b:a', '64k', '-y', out], { windowsHide: true }); + assert.equal(g.status, 0, g.stderr ?? ''); + return out; + }; + const mk = (clauses) => { + const proj = mkproj(tmpdir(), { + slides: [{ id: '01', html: '01.html', audio: '01.mp3', clauses }], + }); + fs.writeFileSync(path.join(proj, 'slides', '01.html'), SLIDE); + fs.writeFileSync(path.join(proj, 'script.json'), JSON.stringify({ + topic: 't', fps: 30, width: 1920, height: 1080, + slides: [{ id: '01', html: '01.html', audio: '01.mp3', title: '开场', clauses }], + }, null, 2)); + return proj; + }; + + test('静音段不足(有缺口) → 报 sparse, 一个字节都不写回', { skip: !FFMPEG && '无 ffmpeg' }, () => { + const clauses = [ + { stage: 1, start: 0, text: '第一句。' }, + { stage: 1, start: 1.2, text: '第二句。' }, + { stage: 1, start: 2.2, text: '第三句。' }, // 需要 2 个句边界, 音频里只有 1 个静音段 + ]; + const proj = mk(clauses); + makeAudio(proj); + const tp = path.join(proj, 'build', 'timings.json'); + fs.mkdirSync(path.dirname(tp), { recursive: true }); + fs.writeFileSync(tp, JSON.stringify({ fps: 30, total: 2.6, tts: 2.6, slides: [{ id: '01', tts: 2.6, duration: 2.6, clauses }] }, null, 2)); + const before = fs.readFileSync(tp, 'utf8'); + const r = runSkill('check-timing.mjs', [proj, '--calibrate']); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /静音段不足\(未写回\)/, '要如实报 sparse: ' + r.stdout); + assert.equal(fs.readFileSync(tp, 'utf8'), before, '半份实测不得写回 timings.json'); + }); + + test('全命中 → 写回, 且每个值都来自实测(不是估算)', { skip: !FFMPEG && '无 ffmpeg' }, () => { + const clauses = [ + { stage: 1, start: 0, text: '第一句。' }, + { stage: 2, start: 1.0, text: '第二句。' }, + ]; + const proj = mk(clauses); + makeAudio(proj); + const tp = path.join(proj, 'build', 'timings.json'); + fs.mkdirSync(path.dirname(tp), { recursive: true }); + fs.writeFileSync(tp, JSON.stringify({ fps: 30, total: 2.6, tts: 2.6, slides: [{ id: '01', tts: 2.6, duration: 2.6, clauses }] }, null, 2)); + const r = runSkill('check-timing.mjs', [proj, '--calibrate']); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /(精确\(标点对齐\)|最近邻\(全命中\))|已写回/, r.stdout); + const after = JSON.parse(fs.readFileSync(tp, 'utf8')); + const c2 = after.slides[0].clauses[1]; + assert.ok(Math.abs(c2.start - 1.6) < 0.35, `第 2 句开口应≈1.6s(实测静音段末端), 实际 ${c2.start}(${r.stdout.slice(-200)})`); + assert.notEqual(c2.start, 1.0, '不能原样留着估算值'); + assert.ok(after.slides[0].stages && Object.keys(after.slides[0].stages).length > 0, 'stage 时刻也要写回'); + }); +}); + +describe('D2 · 位置参数解析: 取值型 flag 的值不是项目目录', () => { + test('positionalDir 跳过取值型 flag 的值', () => { + assert.ok(VALUE_FLAGS.has('--topic')); + assert.equal(positionalDir(['--topic', '我的主题', './proj']), path.resolve('./proj')); + assert.equal(positionalDir(['./proj', '--topic', 'x']), path.resolve('./proj')); + assert.equal(positionalDir(['--ids', '01,03', './proj']), path.resolve('./proj')); + assert.equal(positionalDir(['--check-css']), path.resolve('.')); + }); + + test('init-project --topic X <项目目录> → 只在目标目录建骨架, cwd 不得被污染', () => { + const cwd = tmpdir(); + const proj = path.join(cwd, 'proj'); + const r = runSkill('init-project.mjs', ['--topic', '我的主题', proj], { cwd }); + assert.equal(r.status, 0, r.stderr); + assert.ok(fs.existsSync(path.join(proj, 'slides', 'tokens.css')), '目标目录要有骨架'); + assert.deepEqual(fs.readdirSync(cwd), ['proj'], `cwd 只能多出 proj/, 实际 ${fs.readdirSync(cwd).join(',')}`); + const tp = fs.readFileSync(path.join(proj, 'script.json'), 'utf8'); + assert.ok(tp.includes('我的主题'), '主题名要写进 script.json: ' + tp.slice(0, 200)); + }); + + test('--topic 写在项目目录之后也照样生效', () => { + const cwd = tmpdir(); + const proj = path.join(cwd, 'p2'); + const r = runSkill('init-project.mjs', [proj, '--topic', 'V2'], { cwd }); + assert.equal(r.status, 0, r.stderr); + assert.ok(fs.readFileSync(path.join(proj, 'script.json'), 'utf8').includes('V2')); + assert.deepEqual(fs.readdirSync(cwd), ['p2']); + }); +}); + +describe('D3 · fetch-official-images 的参数与退出码', () => { + const proj = () => { const d = tmpdir(); fs.mkdirSync(path.join(d, 'assets'), { recursive: true }); return d; }; + + test('--json 是布尔 flag: 放在网址前后都不吃掉位置参数', () => { + const d = proj(); + for (const args of [['--json', 'http://127.0.0.1/x'], ['http://127.0.0.1/x', '--json']]) { + const r = runSkill('fetch-official-images.mjs', args, { cwd: d }); + // 两种情况都必须走到"网址校验"(内网被拒), 而不是报用法错误 + assert.ok(!r.stderr.includes('用法:'), `--json 位置 ${args[0]} 不该导致缺位置参数: ${r.stderr}`); + assert.equal(r.status, 1); + assert.ok(r.stderr.includes('内网') || r.stderr.includes('拒绝') || r.stderr.includes('127.0.0.1'), r.stderr); + } + }); + + // 终轮复查抓到: --json 在末尾(文档写的用法)时, flagValue 取"下一个参数"得到 undefined, + // 分支为假 → 静默退化成人类可读列表。上面的用例只断言"走到了网址校验", + // 观察不到这个退化 —— 必须端到端断言真的输出了 JSON。 + test('--json 真的输出 JSON(两种位置都对 file:// 夹具页生效)', async t => { + const playwright = await loadPackage('playwright'); + if (!playwright) return t.skip('无 playwright(列表路径要开浏览器; 真跑由 scoped smoke workflow 覆盖)'); + try { const b = await playwright.chromium.launch({ headless: true }); await b.close(); } catch { + return t.skip('chromium 未安装(npx playwright install chromium)'); + } + const d = proj(); + fs.writeFileSync(path.join(d, 'pic.png'), + Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64')); + fs.writeFileSync(path.join(d, 'page.html'), + 'fixture'); + const pageUrl = pathToFileURL(path.join(d, 'page.html')).href; // 临时路径含空格/非 ASCII 也安全 + for (const args of [[pageUrl, '--allow-file', '--json'], ['--json', pageUrl, '--allow-file']]) { + const r = runSkill('fetch-official-images.mjs', args, { cwd: d }); + assert.equal(r.status, 0, `--json 位置 ${args[0]}: ${r.stdout}${r.stderr}`); + let arr = null; + try { arr = JSON.parse(r.stdout); } catch { /* 下面断言报出实际输出 */ } + assert.ok(Array.isArray(arr) && arr.length >= 1 && arr[0].src, + `--json 必须输出候选 JSON 数组, 实际: ${(r.stdout || r.stderr).slice(0, 200)}`); + assert.ok(!r.stdout.includes('候选图'), 'JSON 模式不得再混人类可读列表'); + } + }); + + test('--min 非法写法 → 退出 1 并说清支持的形式', () => { + const d = proj(); + for (const bad of ['abc', '800x', 'x', '']) { + const r = runSkill('fetch-official-images.mjs', ['https://example.com/', '--min', bad], { cwd: d }); + assert.equal(r.status, 1, `--min ${JSON.stringify(bad)} 必须被拒`); + assert.ok(r.stderr.includes('--min'), r.stderr); + } + }); + + test('--url 全部失败 → 退出 1(不再"全失败还退 0")', () => { + const d = proj(); + const r = runSkill('fetch-official-images.mjs', + ['--url', 'http://127.0.0.1/a.png,http://169.254.169.254/b.png', '--out-dir', path.join(d, 'assets')], { cwd: d }); + assert.equal(r.status, 1); + assert.deepEqual(fs.readdirSync(path.join(d, 'assets')), [], '拒绝时不得落盘'); + }); + + test('--url 重定向循环守卫: 只存在于 followRedirects 单一实现(--url 与 --get 共用; 十三轮 review 去重)', () => { + // 行为级环测试无法本地构造: --url 路径的 SSRF 策略拒绝 loopback(设计使然, 没有测试例外), + // 公网 URL 在 CI 不可靠。退而求其次钉住结构: 逐跳循环只有一份, 环守卫/0 字节守卫在共享核心里, + // 两条下载路径(--url 直下与 --get 浏览器)都必须走它 —— 第二份拷贝再出现就红。 + const src = fs.readFileSync(path.join(SCRIPTS, 'fetch-official-images.mjs'), 'utf8'); + assert.equal((src.match(/for \(let hop = 0/g) || []).length, 1, '逐跳循环必须只有一份实现'); + assert.match(src, /async function followRedirects\(/, '共享核心必须存在'); + assert.match(src, /seen\.has\(next\)/, '环守卫(ping-pong)必须在共享核心里'); + assert.match(src, /await followRedirects\(c\.src/, '--get(浏览器)路径必须走共享核心'); + assert.match(src, /await followRedirects\(src, async u => \{/, '--url 直下路径必须走共享核心'); + assert.equal((src.match(/响应为空\(0 字节\)/g) || []).length, 2, '--url 与 --get 两条下载路径必须各有一条 0 字节守卫(此前 --get 路径缺这条)'); + }); +}); + +describe('D4 · --mode / --dsf / --at 的非法值必须在入口被拒', () => { + const proj = () => { + const p = mkproj(tmpdir(), { slides: [{ id: '01', html: '01.html', audio: '01.mp3' }] }); + fs.writeFileSync(path.join(p, 'slides', '01.html'), SLIDE); + return p; + }; + + test('capture 的 script.width/height 必须是 16–16384 整数(与 preview-page/build-video 同一道门)', () => { + // 第八轮复查 F3: capture 曾对同一字段裸用(非整数一路带进 viewport)。闸门在 playwright + // 加载之前, 所以本用例不需要任何外部工具 —— 无工具 CI 上照样真跑。 + const p = proj(); + fs.mkdirSync(path.join(p, 'build'), { recursive: true }); + fs.writeFileSync(path.join(p, 'build', 'timings.json'), JSON.stringify( + { fps: 30, total: 1, slides: [{ id: '01', duration: 1, stages: { 1: 0 }, clauses: [] }] })); + const setWH = (w, h) => { + const s = JSON.parse(fs.readFileSync(path.join(p, 'script.json'), 'utf8')); + s.width = w; s.height = h; + fs.writeFileSync(path.join(p, 'script.json'), JSON.stringify(s, null, 2)); + }; + for (const [w, h] of [[99999, 1080], [8, 1080], [1920, 1079.5], [0, 0]]) { + setWH(w, h); + const r = runSkill('capture.mjs', [p]); + assert.equal(r.status, 1, `width=${JSON.stringify(w)}, height=${JSON.stringify(h)} 必须被拒`); + assert.ok(r.stderr.includes('width/height'), r.stderr); + } + }); + + test('capture --mode 只收 still/motion', () => { + const p = proj(); + for (const bad of ['motions', 'Still', 'video']) { + const r = runSkill('capture.mjs', [p, '--mode', bad]); + assert.equal(r.status, 1, `--mode ${bad} 必须被拒`); + assert.ok(r.stderr.includes('--mode'), r.stderr); + } + }); + + test('capture --dsf 只收 1–4 的整数', () => { + const p = proj(); + for (const bad of ['0', '9', '2.5', 'abc', '-1']) { + const r = runSkill('capture.mjs', [p, '--dsf', bad]); + assert.equal(r.status, 1, `--dsf ${bad} 必须被拒`); + assert.ok(r.stderr.includes('--dsf'), r.stderr); + } + }); + + test('grab-frames --at 只收 (0,1] 的小数', { skip: !FFMPEG && '无 ffmpeg' }, () => { + const p = mkproj(tmpdir()); + fs.mkdirSync(path.join(p, 'build'), { recursive: true }); + fs.mkdirSync(path.join(p, 'out'), { recursive: true }); + fs.writeFileSync(path.join(p, 'build', 'timings.json'), JSON.stringify({ + fps: 30, total: 2.2, slides: [{ id: '01', duration: 1.1 }, { id: '02', duration: 1.1 }], + })); + const g = spawnSync(FFMPEG, ['-v', 'error', '-f', 'lavfi', '-i', 'testsrc=size=320x240:rate=10', '-t', '2.6', + '-pix_fmt', 'yuv420p', '-y', path.join(p, 'out', 'final.mp4')], { windowsHide: true }); + assert.equal(g.status, 0, g.stderr ?? ''); + for (const bad of ['0', '-1', '2', 'abc', '']) { + const r = runSkill('grab-frames.mjs', [p, '--at', bad]); + assert.equal(r.status, 1, `--at ${JSON.stringify(bad)} 必须被拒`); + assert.ok(r.stderr.includes('--at'), r.stderr); + } + // 合法值照常出帧 + const ok = runSkill('grab-frames.mjs', [p, '--at', '0.5']); + assert.equal(ok.status, 0, ok.stdout + ok.stderr); + assert.ok(fs.existsSync(path.join(p, 'build', 'introspect', 'frame-01-end.png'))); + // --ids 一个都不匹配 → 零帧 = 退出 1(别静默成功) + const none = runSkill('grab-frames.mjs', [p, '--ids', '99']); + assert.equal(none.status, 1, '零帧必须退出 1: ' + none.stdout); + assert.ok(none.stderr.includes('一帧都没抽到'), none.stderr); + }); +}); + +describe('D5 · 字幕带闸门只对"绝对定位的 bottom"报警', () => { + const mk = html => { + const p = mkproj(tmpdir(), { slides: [{ id: '01', html: '01.html', audio: '01.mp3' }] }); + fs.writeFileSync(path.join(p, 'slides', '01.html'), html); + return p; + }; + test('padding-bottom / margin-bottom 不再误报(实测误报点)', () => { + const r = runSkill('check-slides.mjs', [mk(` + +

x

图:来源
`)]); + assert.equal(r.status, 0, r.stdout + r.stderr); + assert.ok(!/字幕带/.test(r.stdout), 'padding/margin-bottom 不该被当 bottom: ' + r.stdout.slice(-400)); + }); + test('流内元素写 bottom 不报(不产生位移), 绝对定位才报', () => { + const stat = runSkill('check-slides.mjs', [mk(` + +

x

图:来源
`)]); + assert.ok(!/字幕带/.test(stat.stdout), '没有 position:absolute 就不该报: ' + stat.stdout.slice(-300)); + const abs = runSkill('check-slides.mjs', [mk(` + +

x

图:来源
`)]); + assert.match(abs.stdout, /字幕带/, '绝对定位贴底要报'); + assert.equal(abs.status, 0, '这是提示级, 不阻塞流水线'); + // 行内 style 也要能认出来 + const inline = runSkill('check-slides.mjs', [mk(` +

x

图:来源
`)]); + assert.match(inline.stdout, /字幕带/, '行内 style 的 absolute+bottom 同样要报'); + }); +}); + +describe('柱状图结构闸门: 百分比柱体不得裸放 flex column(2026-09-22 域外实测踩坑)', () => { + const mk = html => { + const p = mkproj(tmpdir(), { slides: [{ id: '01', html: '01.html', audio: '01.mp3' }] }); + fs.writeFileSync(path.join(p, 'slides', '01.html'), html); + return p; + }; + test('坑形态: flex column + 百分比柱体 + 无 .chart-plot-cell → 提示(不阻塞)', () => { + const r = runSkill('check-slides.mjs', [mk(` + +
+
+
`)]); + assert.match(r.stdout + r.stderr, /没包 \.chart-plot-cell/, r.stdout + r.stderr); + assert.equal(r.status, 0, '提示级不阻塞流水线: ' + r.stdout + r.stderr); + }); + test('规范形态: .chart-plot-cell.grid 包柱体 → 不报', () => { + const r = runSkill('check-slides.mjs', [mk(` + +
+
`)]); + assert.ok(!/没包 \.chart-plot-cell/.test(r.stdout), r.stdout.slice(-300)); + }); + test('误报防线: 柱状语义类 + 百分比高度但张内没有 flex column → 不报', () => { + const r = runSkill('check-slides.mjs', [mk(` + +
+
+
`)]); + assert.ok(!/没包 \.chart-plot-cell/.test(r.stdout), '非 flex column 是合法用法: ' + r.stdout.slice(-300)); + }); +}); + +describe('D6 · transition 单一解析(闸门与渲染器结论一致)', () => { + test('readTransition 接受裸字符串与对象, 结论相同', () => { + assert.deepEqual(readTransition('xfade'), { type: 'xfade', dur: 0.4, label: '交叉溶解 xfade 0.4s' }); + assert.deepEqual(readTransition({ type: 'xfade' }), readTransition('xfade')); + assert.deepEqual(readTransition('cut'), { type: 'cut', dur: 0, label: '硬切 cut' }); + assert.deepEqual(readTransition(null), readTransition(undefined)); + assert.deepEqual(readTransition({ type: 'xfade', duration: 1.2 }).dur, 1.2); + for (const bad of ['fade', 42, { type: 'fade' }, { type: 'xfade', duration: 9 }, { type: 'xfade', duration: 0 }]) { + assert.throws(() => readTransition(bad), /非法/, `${JSON.stringify(bad)} 必须报错`); + } + }); + + test('check-slides 与 build-video 对 "xfade" 字符串给同一结论', { skip: !FFMPEG && '无 ffmpeg(用 check-slides 的标签确认)', }, () => { + const p = mkproj(tmpdir(), { slides: [{ id: '01', html: '01.html', audio: '01.mp3' }] }); + fs.writeFileSync(path.join(p, 'slides', '01.html'), SLIDE); + // build-video 读 timings.json 在 --dry-run 之前, 所以最小骨架要补一份 + fs.mkdirSync(path.join(p, 'build'), { recursive: true }); + fs.writeFileSync(path.join(p, 'build', 'timings.json'), JSON.stringify({ + fps: 30, total: 3.0, tts: 3.0, slides: [{ id: '01', duration: 3.0, tts: 3.0, clauses: [] }], + }, null, 2)); + // build-video 的输入检查(帧序列 / preview/.png)在 --dry-run 之前也会做, 所以给一张占位静帧 + const png = path.join(p, 'preview', '01.png'); + fs.mkdirSync(path.dirname(png), { recursive: true }); + const g = spawnSync(FFMPEG, ['-v', 'error', '-f', 'lavfi', '-i', 'color=c=#123456:s=1920x1080', '-frames:v', '1', '-y', png], { windowsHide: true }); + assert.equal(g.status, 0, '应能造出占位静帧'); + // 音轨也要在(缺音频会在自检之前就退出, 看不到我们要断言的转场行) + const au = spawnSync(FFMPEG, ['-v', 'error', '-f', 'lavfi', '-i', 'anullsrc=r=32000:cl=mono', '-t', '3', + '-c:a', 'libmp3lame', '-b:a', '64k', '-y', path.join(p, 'audio', '01.mp3')], { windowsHide: true }); + assert.equal(au.status, 0, '应能造出静音音轨'); + const setT = v => { + const s = JSON.parse(fs.readFileSync(path.join(p, 'script.json'), 'utf8')); + s.transition = v; + fs.writeFileSync(path.join(p, 'script.json'), JSON.stringify(s, null, 2)); + }; + setT('xfade'); + const str = runSkill('check-slides.mjs', [p]); + assert.match(str.stdout, /交叉溶解 xfade/, str.stdout.slice(-300)); + setT({ type: 'xfade' }); + const obj = runSkill('check-slides.mjs', [p]); + assert.equal(obj.stdout.match(/转场 = ([^;]*)/)?.[1], str.stdout.match(/转场 = ([^;]*)/)?.[1], + '裸字符串与对象在闸门里必须一样(旧实现只认对象里的 type, 字符串会显示成非法值)'); + setT('fade'); + const bad = runSkill('check-slides.mjs', [p]); + assert.match(bad.stdout, /✗/, '非法值要显式报出来'); + }); +}); + +describe('复查补充 · 取值型 flag 清单 + 落盘收监 + transition 两端一致', () => { + test('VALUE_FLAGS 不得包含布尔开关(否则它后面那个位置参数会被当值吃掉)', () => { + // 成因: 清单里混进 --json / --allow-stale-css 这类开关后, `capture --allow-stale-css <项目>` + // 会把 <项目> 当开关的值跳过 → 位置参数落到 cwd, 对着调用目录干活(2026-09-18 复查实测)。 + const DUAL = new Set(['--transition']); // 既能当开关又能带值(实测里有 argv.includes 也有取值) + const used = new Set(); + for (const f of fs.readdirSync(SCRIPTS)) { + if (!f.endsWith('.mjs')) continue; + const src = fs.readFileSync(path.join(SCRIPTS, f), 'utf8'); + for (const m of src.matchAll(/argv\.includes\(['"](--[a-z-]+)['"]\)/g)) used.add(m[1]); + } + const bad = [...used].filter(f => !DUAL.has(f) && VALUE_FLAGS.has(f)); + assert.deepEqual(bad, [], `这些开关被当成取值型 flag, 会把后面的位置参数吃掉: ${bad.join(', ')}`); + // 真实用法逐条: 开关后面的目录必须仍被认成项目目录 + for (const sw of ['--allow-stale-css', '--no-subs', '--json', '--both', '--force']) { + assert.equal(positionalDir([sw, './proj']), path.resolve('./proj'), `${sw} 后面跟目录时不得被当值吃掉`); + } + // 取值型照旧要跳过一个值 + for (const vf of ['--topic', '--ids', '--mode', '--dsf', '--at', '--transition']) { + assert.equal(positionalDir([vf, 'X', './proj']), path.resolve('./proj'), `${vf} 的值必须被跳过`); + } + assert.deepEqual(positionals(['--check', 'a.png', 'b.png']), ['a.png', 'b.png'], 'prep-image 的文件名是位置参数'); + }); + + test('init-project --upgrade-css 也要落盘收监: slides/ 是悬空链接时不得写到项目外', (t) => { + // 复查 B4: 计划里点名 init-project 与 fetch-official-images, 但两者此前都没有 safeOut —— + // CHANGELOG 却宣称"每个写入点都收监", 属于文档与代码不符(第三轮 review 抓到的)。 + const proj = mkproj(tmpdir(), { tokens: LEGACY_TOKENS }); + const outside = tmpdir(); + const slidesReal = path.join(proj, 'slides'); + fs.rmSync(slidesReal, { recursive: true, force: true }); + const made = (() => { + try { fs.symlinkSync(outside, slidesReal, 'dir'); return true; } catch { /* 试 junction */ } + const r = spawnSync('cmd', ['/c', 'mklink', '/J', slidesReal, outside], { encoding: 'utf8', windowsHide: true }); + return r.status === 0 && fs.existsSync(slidesReal); + })(); + if (!made) return t.skip('当前环境建不了符号链接/junction(收监拒绝无从观察)'); + // 让 slides 指向项目外(外面先放一份 tokens.css, 好走到"升级"分支) + fs.writeFileSync(path.join(outside, 'tokens.css'), LEGACY_TOKENS); + const r = runSkill('init-project.mjs', [proj, '--upgrade-css']); + assert.equal(r.status, 1, '项目内的目录段指向项目外时必须拒绝: ' + r.stdout + r.stderr); + assert.ok(/符号链接|越出|收监/.test(r.stderr + r.stdout), r.stderr + r.stdout); + assert.equal(fs.readFileSync(path.join(outside, 'tokens.css'), 'utf8'), LEGACY_TOKENS, '项目外的文件不得被改写'); + assert.equal(fs.existsSync(path.join(outside, 'tokens.css.bak')), false, '备份也不得写到项目外'); + }); + + test('transition: 渲染器(build-video)与闸门用同一解析 —— 裸字符串在两端同义', { skip: !FFMPEG && '无 ffmpeg' }, () => { + const p = mkproj(tmpdir(), { slides: [{ id: '01', html: '01.html', audio: '01.mp3' }] }); + fs.writeFileSync(path.join(p, 'slides', '01.html'), SLIDE); + // build-video 在 --dry-run 之前就读 timings.json, 所以最小骨架要补一份 + fs.mkdirSync(path.join(p, 'build'), { recursive: true }); + fs.writeFileSync(path.join(p, 'build', 'timings.json'), JSON.stringify({ + fps: 30, total: 3.0, tts: 3.0, slides: [{ id: '01', duration: 3.0, tts: 3.0, clauses: [] }], + }, null, 2)); + const setT = v => { + const s = JSON.parse(fs.readFileSync(path.join(p, 'script.json'), 'utf8')); + if (v === null) delete s.transition; else s.transition = v; + fs.writeFileSync(path.join(p, 'script.json'), JSON.stringify(s, null, 2)); + }; + // --dry-run 会打印解析结果, 不需要真编码; 但 build-video 的输入检查(帧序列 / preview/.png / + // 音轨)在 dry-run 之前也会做, 所以占位静帧与静音音轨都得在 + const png = path.join(p, 'preview', '01.png'); + fs.mkdirSync(path.dirname(png), { recursive: true }); + assert.equal(spawnSync(FFMPEG, ['-v', 'error', '-f', 'lavfi', '-i', 'color=c=#123456:s=1920x1080', + '-frames:v', '1', '-y', png], { windowsHide: true }).status, 0, '应能造出占位静帧'); + assert.equal(spawnSync(FFMPEG, ['-v', 'error', '-f', 'lavfi', '-i', 'anullsrc=r=32000:cl=mono', '-t', '3', + '-c:a', 'libmp3lame', '-b:a', '64k', '-y', path.join(p, 'audio', '01.mp3')], { windowsHide: true }).status, 0, '应能造出静音音轨'); + setT('xfade'); + const dry = runSkill('build-video.mjs', [p, '--dry-run']); + assert.equal(dry.status, 0, dry.stdout + dry.stderr); + assert.match(dry.stdout, /交叉溶解 0\.4s/, '裸字符串 xfade 必须被渲染器认成溶解: ' + dry.stdout); + setT({ type: 'xfade', duration: 1.2 }); + assert.match(runSkill('build-video.mjs', [p, '--dry-run']).stdout, /交叉溶解 1\.2s/); + setT(null); + assert.match(runSkill('build-video.mjs', [p, '--dry-run']).stdout, /转场: 硬切/, '缺省 = 硬切'); + setT('fade'); + const bad = runSkill('build-video.mjs', [p, '--dry-run']); + assert.equal(bad.status, 1, '非法值渲染器也要拒'); + assert.match(bad.stderr, /transition/, bad.stderr); + // 来源要点名: --transition 写错时不该说成 script.transition 写错(2026-09-18 复查抓到) + const badFlag = runSkill('build-video.mjs', [p, '--transition', 'bogus', '--dry-run']); + assert.equal(badFlag.status, 1); + assert.match(badFlag.stderr, /--transition/, `要点名 --transition: ${badFlag.stderr}`); + assert.ok(!/script\.transition/.test(badFlag.stderr), `不该甩锅给 script.json: ${badFlag.stderr}`); + }); + + test('init-project 骨架写点也收监: assets/ 指向项目外时 --force 重建不得写到项目外', (t) => { + // 与上一条同源(复查 B4): 收监是逐个写点做的, --upgrade-css 只是其中之一。骨架那次创建 + // 8 个目录 + 5 个文件, 这些路径此前都是裸 path.join —— 目标目录里预置一个指向项目外的 + // assets 链接(用户共享素材的常见做法), 生成物就会落到项目外。 + const proj = tmpdir(); + const outside = tmpdir(); + const assets = path.join(proj, 'assets'); + const made = (() => { + try { fs.symlinkSync(outside, assets, 'dir'); return true; } catch { /* 试 junction */ } + const r = spawnSync('cmd', ['/c', 'mklink', '/J', assets, outside], { encoding: 'utf8', windowsHide: true }); + return r.status === 0 && fs.existsSync(assets); + })(); + if (!made) return t.skip('当前环境建不了符号链接/junction(骨架写点收监无从观察)'); + const r = runSkill('init-project.mjs', [proj, '--force']); + assert.equal(r.status, 1, `项目内的目录段指向项目外时必须拒绝: ${r.stdout}${r.stderr}`); + assert.match(r.stderr + r.stdout, /越出|符号链接|收监/, r.stderr + r.stdout); + assert.deepEqual(fs.readdirSync(outside), [], '项目外目录里不得出现任何生成物'); + }); +}); diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/safe-paths.test.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/safe-paths.test.mjs new file mode 100644 index 00000000..b7b8e861 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/safe-paths.test.mjs @@ -0,0 +1,286 @@ +// 安全边界 · 路径收监(script.json 派生路径不得越出项目目录) +// 对应评审意见 1: Input-derived IDs and paths are not contained. +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { inside, findTool } from '../scripts/tools.mjs'; +import { runSkill, probeHelper, mkproj, tmpdir } from './helpers.mjs'; + +describe('inside() 判定', () => { + test('根内相对路径 → true; 越出 → false', () => { + const root = path.resolve('/proj'); + assert.equal(inside(root, path.resolve('/proj/a/b.txt')), true); + assert.equal(inside(root, path.resolve('/proj-a/b.txt')), false, '前缀相似的兄弟目录不算在内'); + assert.equal(inside(root, path.resolve('/victim')), false); + assert.equal(inside(root, path.resolve('/proj')), false, '根本身不算 inside(要求严格子路径)'); + }); +}); + +describe('safeId / safeRel(退出型, subprocess 探测)', () => { + test('safeId: 合法 id 原样返回', () => { + for (const id of ['01', 'slide-2', 'A_b', 'x'.repeat(64)]) { + const r = probeHelper('safeId', `[${JSON.stringify(id)}]`); + assert.equal(r.status, 0, r.stderr); + assert.ok(r.stdout.startsWith('RETURN:'), `safeId(${id}) 应返回原值`); + } + }); + test('safeId: 穿越/绝对/空/超长/换行 全部退出 1', () => { + for (const id of ['../../victim', '..\\..\\victim', '/abs', 'C:\\x', '', 'a/b', 'a\nb', 'x'.repeat(65), null, 42]) { + const r = probeHelper('safeId', `[${JSON.stringify(id)}]`); + assert.notEqual(r.status, 0, `safeId(${JSON.stringify(id)}) 必须拒绝`); + } + }); + test('safeRel: 根内相对路径返回绝对路径', () => { + const r = probeHelper('safeRel', `["/proj", "a/b.txt", {}]`); + assert.equal(r.status, 0, r.stderr); + assert.ok(r.stdout.startsWith('RETURN:'), '根内路径应放行'); + }); + test('safeRel: 绝对路径 / ../ 穿越 / 空串 拒绝', () => { + for (const rel of ['/etc/passwd', '../../victim', '', 'a/../../..']) { + const r = probeHelper('safeRel', `["/proj", ${JSON.stringify(rel)}, {}]`); + assert.notEqual(r.status, 0, `safeRel(${JSON.stringify(rel)}) 必须拒绝`); + } + }); + test('safeRel: 符号链接逃逸拒绝(建不了链接则 skip)', (t) => { + const root = tmpdir(); + const outside = tmpdir(); + fs.writeFileSync(path.join(outside, 'secret.txt'), 'x'); + const link = path.join(root, 'assets-link'); + // symlink 需要开发者模式, junction(mklink /J)不需要 —— 两条路都试, 尽量真跑而不是跳过 + const made = (() => { + try { fs.symlinkSync(outside, link, 'dir'); return true; } catch { /* 退回 junction */ } + const r = spawnSync('cmd', ['/c', 'mklink', '/J', link, outside], { encoding: 'utf8', windowsHide: true }); + return r.status === 0 && fs.existsSync(link); + })(); + if (!made) return t.skip('当前环境建不了符号链接/junction'); + const r = probeHelper('safeRel', `["${path.resolve(root).replace(/\\/g, '/')}", "assets-link/secret.txt", {}]`); + assert.notEqual(r.status, 0, '指向项目外的符号链接必须拒绝'); + }); + + test('safeRel: 悬空链接(目标已删除)必须拒绝 —— existsSync 看不出它, 旧回溯会当"不存在"跳过', (t) => { + // 复查 B3: 旧实现用 existsSync 回溯祖先 —— 悬空链接的 existsSync 是 **false**, 于是它被跳过、 + // 链接本身从未被复核, 写进去就落到项目外。现在改用 lstat 停住 + realpath 解不开即 fail closed。 + const root = tmpdir(); + const target = tmpdir(); + const link = path.join(root, 'slides-link'); + const made = (() => { + try { fs.symlinkSync(target, link, 'dir'); return true; } catch { /* 退回 junction */ } + const r = spawnSync('cmd', ['/c', 'mklink', '/J', link, target], { encoding: 'utf8', windowsHide: true }); + return r.status === 0 && fs.existsSync(link); + })(); + if (!made) return t.skip('当前环境建不了符号链接/junction(悬空链接场景无从构造)'); + fs.rmSync(target, { recursive: true, force: true }); // 目标消失 → 链接悬空 + assert.equal(fs.existsSync(link), false, '前置: 悬空链接的 existsSync 必须是 false(旧实现的盲区正在这里)'); + assert.ok(fs.lstatSync(link).isSymbolicLink(), '前置: lstat 仍能看到这个 reparse point'); + const r = probeHelper('safeRel', `["${path.resolve(root).replace(/\\/g, '/')}", "slides-link/x.css", {}]`); + assert.notEqual(r.status, 0, '悬空链接必须拒绝(解不开就无法证明它落在项目内)'); + }); + + test('safeOut 单元级: 项目内的目录段是指向项目外的链接 → 直接调用即拒绝, 且外面没有留下任何文件', (t) => { + // 复查 B4 收尾: 收监此前只有"跑某个脚本"的集成用例。这里直接调 safeOut(不经任何流水线), + // 确认拒绝发生在这一层 —— 集成用例可能因为别的校验先退出, 从而给出假绿灯。 + const root = tmpdir(); + const outside = tmpdir(); + const linked = path.join(root, 'out'); + const made = (() => { + try { fs.symlinkSync(outside, linked, 'dir'); return true; } catch { /* 退回 junction */ } + const r = spawnSync('cmd', ['/c', 'mklink', '/J', linked, outside], { encoding: 'utf8', windowsHide: true }); + return r.status === 0 && fs.existsSync(linked); + })(); + if (!made) return t.skip('当前环境建不了符号链接/junction(safeOut 拒绝无从观察)'); + const r = probeHelper('safeOut', `["${path.resolve(root).replace(/\\/g, '/')}", "out", "x.txt"]`); + assert.notEqual(r.status, 0, 'safeOut 必须自己拒绝, 而不是把判断留给调用方'); + assert.match(r.stderr, /越出项目目录|符号链接/, `要点名是链接越界, 实际: ${r.stderr.slice(0, 200)}`); + assert.equal(fs.existsSync(path.join(outside, 'x.txt')), false, '拒绝必须发生在写入之前'); + }); + + test('safeRel: 项目内链接指向项目"父目录"时, 链接下的新路径必须拒绝(1.7.5 复查②, 实弹复现过的洞)', (t) => { + // 形状: link → 项目的父目录。目标**已存在**时游走能走到完整路径, 正确拒绝; + // 目标**尚不存在**时旧回退 inside(real, realRoot) 被满足(根在父目录之内)→ 放行 → 写穿到项目外。 + const root = tmpdir(); + const parent = path.dirname(root); // tmpdir() 每个都是独立目录, root 的父就是公共层 + fs.writeFileSync(path.join(parent, 'canary.txt'), 'KEEP'); + const link = path.join(root, 'link'); + const made = (() => { + try { fs.symlinkSync(parent, link, 'dir'); return true; } catch { /* 退回 junction */ } + const r = spawnSync('cmd', ['/c', 'mklink', '/J', link, parent], { encoding: 'utf8', windowsHide: true }); + return r.status === 0 && fs.existsSync(link); + })(); + if (!made) return t.skip('当前环境建不了符号链接/junction(祖先链接形状无从构造)'); + t.after(() => fs.rmSync(path.join(parent, 'canary.txt'), { force: true })); // canary 落在共享 %TEMP% 层, 测完即清 + const mk = rel => probeHelper('safeRel', `["${path.resolve(root).replace(/\\/g, '/')}", ${JSON.stringify(rel)}, {}]`); + const rNew = mk('link/new.txt'); + assert.notEqual(rNew.status, 0, `链接指向父目录 + 新目标必须拒绝, 实际: ${(rNew.stderr || rNew.stdout).slice(-200)}`); + const rOld = mk('link/canary.txt'); + assert.notEqual(rOld.status, 0, '已存在目标穿过链接同样必须拒绝'); + assert.equal(fs.existsSync(path.join(parent, 'new.txt')), false, '拒绝必须发生在写入之前'); + assert.equal(fs.readFileSync(path.join(parent, 'canary.txt'), 'utf8'), 'KEEP', 'canary 必须完好'); + }); +}); + +describe('消费者脚本: 恶意 script.json 必须在干坏事之前退出', () => { + test('capture: id=../../canary → 退出 1 且 canary 完好(递归删除被拦)', () => { + const proj = tmpdir(); + const canary = path.join(path.dirname(proj), 'canary-' + path.basename(proj)); + fs.mkdirSync(path.join(canary, 'deep', 'deeper'), { recursive: true }); + fs.writeFileSync(path.join(canary, 'deep', 'deeper', 'keep.txt'), 'do not delete'); + mkproj(proj, { slides: [{ id: '../../' + path.basename(canary), html: 'x.html', audio: 'a.mp3' }] }); + const r = runSkill('capture.mjs', [proj]); + assert.notEqual(r.status, 0, '恶意 id 必须被拒绝'); + assert.ok(fs.existsSync(path.join(canary, 'deep', 'deeper', 'keep.txt')), 'canary 必须完好'); + }); + + test('check-slides: html=../outside.html → 退出 1', () => { + const proj = tmpdir(); + mkproj(proj, { slides: [{ id: '01', html: '../outside.html', audio: '01.mp3', clauses: [] }] }); + const r = runSkill('check-slides.mjs', [proj]); + assert.notEqual(r.status, 0); + assert.ok((r.stdout + r.stderr).includes('越出') || (r.stdout + r.stderr).includes('非法'), `应指明越界原因, 实际: ${r.stderr.slice(-300)}`); + }); + + test('check-slides: 越出项目目录 → 记为 error 并退出 1', () => { + const proj = tmpdir(); + mkproj(proj, { slides: [{ id: '01', html: '01.html', audio: '01.mp3' }] }); + fs.writeFileSync(path.join(proj, 'slides', '01.html'), + `
x
`); + const r = runSkill('check-slides.mjs', [proj]); + assert.notEqual(r.status, 0); + assert.ok(r.stdout.includes('越出项目目录'), `应报图片越界, 实际: ${r.stdout.slice(-300)}`); + }); + + test('plan-timings: audio=../../secret.mp3 → 退出 1(需 ffprobe)', { skip: !findTool('ffprobe') && '无 ffprobe, 跳过(CI 主工作流无 ffmpeg)' }, () => { + const proj = tmpdir(); + mkproj(proj, { slides: [{ id: '01', audio: '../../secret.mp3', clauses: [{ stage: 1, text: 'x' }] }] }); + const r = runSkill('plan-timings.mjs', [proj]); + assert.notEqual(r.status, 0); + }); + + test('build-video: bgm.file 为绝对路径 → 退出 1(需 ffmpeg+ffprobe)', { skip: (!findTool('ffmpeg') || !findTool('ffprobe')) && '无 ffmpeg, 跳过' }, () => { + const proj = tmpdir(); + mkproj(proj, { slides: [{ id: '01', audio: '01.mp3' }] }); + const scriptPath = path.join(proj, 'script.json'); + const script = JSON.parse(fs.readFileSync(scriptPath, 'utf8')); + script.bgm = { file: path.join(tmpdir(), 'evil-bgm.mp3') }; // 绝对路径直接逃逸 + fs.writeFileSync(scriptPath, JSON.stringify(script)); + const r = runSkill('build-video.mjs', [proj]); + assert.notEqual(r.status, 0); + assert.ok(r.stdout.includes('bgm') || r.stderr.includes('bgm'), '应点名 bgm.file'); + }); + + // ── 2026-09-18 三维审计发现的一致性缺口: 同一信任级的入参, 别处管了这里没管 ── + // 这些用例只喂 script.json + timings.json: 坏值必须在编码开始前就被拒(不依赖 ffmpeg), + // 否则就要等整条流水线跑完才报错 —— 原 width 用例没写 timings.json, 读文件就退出了, 属假绿 + const withTimings = (scriptPatch, timingsPatch = {}) => { + const proj = tmpdir(); + mkproj(proj, { slides: [{ id: '01', audio: '01.mp3' }] }); + fs.writeFileSync(path.join(proj, 'build', 'timings.json'), + JSON.stringify({ fps: 30, total: 1, slides: [{ id: '01', duration: 1 }], ...timingsPatch })); + const sp = path.join(proj, 'script.json'); + const script = JSON.parse(fs.readFileSync(sp, 'utf8')); + Object.assign(script, scriptPatch); + fs.writeFileSync(sp, JSON.stringify(script)); + return proj; + }; + + test('build-video: bgm.volume/fadeIn/fadeOut 非数值 → 退出 1(与 width 同一注入面, 此前漏管)', () => { + for (const bad of [ + { volume: "0.5,amovie='C:/x',volume=0.5" }, // 追加 filter 节点(ffmpeg filter 可读本地文件进输出音频) + { fadeIn: '1.5,volume=9' }, + { fadeOut: 'x' }, + { volume: 999 }, + ]) { + const proj = withTimings({ bgm: { file: 'assets/bgm.mp3', ...bad } }); + fs.mkdirSync(path.join(proj, 'assets'), { recursive: true }); + fs.writeFileSync(path.join(proj, 'assets', 'bgm.mp3'), 'x'); + const r = runSkill('build-video.mjs', [proj]); + assert.notEqual(r.status, 0, `bgm ${JSON.stringify(bad)} 必须被拒绝`); + assert.ok(/bgm\./.test(r.stdout + r.stderr), `要点名是 bgm 的哪个字段非法, 实际: ${(r.stdout + r.stderr).slice(-200)}`); + } + }); + + test('build-video: fps 非数值 → 退出 1(拼进 -r/-framerate; timings.fps 优先于 script.fps)', () => { + const r = runSkill('build-video.mjs', [withTimings({}, { fps: '30 -vf scale=1:1' })]); + assert.notEqual(r.status, 0); + assert.ok(/fps/.test(r.stdout + r.stderr), `要点名 fps, 实际: ${(r.stdout + r.stderr).slice(-200)}`); + }); + + test('build-video: width 非法字符串 → 退出 1(ffmpeg filter 注入面)', () => { + const r = runSkill('build-video.mjs', [withTimings({ width: '1920,drawtext=text=pwned' })]); + assert.notEqual(r.status, 0); + assert.ok(/width/.test(r.stdout + r.stderr), `要点名 width, 实际: ${(r.stdout + r.stderr).slice(-200)}`); + }); + + test('preview-page: script.width 非法字符串 → 退出 1(该值插进放映页 CSS/JS, 此前直接杀死整页脚本)', () => { + const proj = tmpdir(); + mkproj(proj, { slides: [{ id: '01', html: '01.html', audio: '01.mp3' }] }); + fs.writeFileSync(path.join(proj, 'slides', '01.html'), '

x

'); + const scriptPath = path.join(proj, 'script.json'); + for (const bad of ['1920px', '1920; } body{background:url(x)} /*']) { + const script = JSON.parse(fs.readFileSync(scriptPath, 'utf8')); + script.width = bad; + fs.writeFileSync(scriptPath, JSON.stringify(script)); + const r = runSkill('preview-page.mjs', [proj]); + assert.notEqual(r.status, 0, `width=${JSON.stringify(bad)} 必须被拒绝(与 build-video 的 clampDim 同一道门)`); + } + }); + + test('preview-page: 超大 tokens.css → 退出 1(与 check-slides/init-project 同一道门, 此前实测跑 129s)', () => { + const proj = tmpdir(); + mkproj(proj, { slides: [{ id: '01', html: '01.html', audio: '01.mp3' }] }); + fs.writeFileSync(path.join(proj, 'slides', '01.html'), '

x

'); + // 反复的开标记但无收标记 —— 受管块扫描的二次方放大输入 + fs.writeFileSync(path.join(proj, 'slides', 'tokens.css'), + ':root{--accent:#111}\n' + '/* >>> html2video:nofx rev=deadbeef >>> */\n'.repeat(60000) + 'x'.repeat(3_000_000)); + const t0 = Date.now(); + const r = runSkill('preview-page.mjs', [proj], { timeout: 90000 }); + const ms = Date.now() - t0; + assert.notEqual(r.status, 0, '超大 tokens.css 必须被上限拒绝'); + assert.ok(/tokens\.css/.test(r.stdout + r.stderr), '要点名 tokens.css'); + assert.ok(ms < 30000, `应在秒级拒绝, 实际 ${ms}ms`); + }); + + // ── 2026-09-18 实测踩坑(PITFALLS.md)固化成闸门 ── + test('check-slides 5e: 绝对定位落进字幕带(bottom < 168px@1080) → 点名警告', () => { + const proj = tmpdir(); + mkproj(proj, { slides: [{ id: '01', html: '01.html', audio: '01.mp3' }] }); + fs.writeFileSync(path.join(proj, 'slides', '01.html'), + ` +

x

图:某来源
`); + const r = runSkill('check-slides.mjs', [proj]); + assert.equal(r.status, 0, '提示级不得阻塞流水线'); + assert.match(r.stdout, /字幕带/, '要点名落在字幕带'); + assert.match(r.stdout, /bottom:96px/, '报出具体值'); + // 安全区之上的图注不该报 + mkproj(proj, { slides: [{ id: '01', html: '01.html', audio: '01.mp3' }] }); + fs.writeFileSync(path.join(proj, 'slides', '01.html'), + ` +

x

图:某来源
`); + const r2 = runSkill('check-slides.mjs', [proj]); + assert.ok(!/字幕带/.test(r2.stdout), `bottom:196px 在安全区之上, 不该报: ${r2.stdout.slice(-200)}`); + }); + + test('check-slides 5d: .fx-stagger 与 data-stage 同张 → 提示确认(PITFALLS #2)', () => { + const proj = tmpdir(); + mkproj(proj, { slides: [{ id: '01', html: '01.html', audio: '01.mp3' }] }); + fs.writeFileSync(path.join(proj, 'slides', '01.html'), + ` +
  • a
  • b
+

自己管入场

`); + const r = runSkill('check-slides.mjs', [proj]); + assert.equal(r.status, 0, '提示级不得阻塞流水线'); + assert.match(r.stdout, /fx-stagger/, '要点名 stagger 与 data-stage 共存'); + assert.match(r.stdout, /动画窗/, '要给出可自查的信号(动画窗变短)'); + }); + + test('check-slides 5: 带 data-stage 却没有 fx 类 → 警告(stagger 不再兜底)', () => { + const proj = tmpdir(); + mkproj(proj, { slides: [{ id: '01', html: '01.html', audio: '01.mp3' }] }); + fs.writeFileSync(path.join(proj, 'slides', '01.html'), + `
  • 以为 stagger 会管
`); + const r = runSkill('check-slides.mjs', [proj]); + assert.match(r.stdout, /没有 fx-\* 类/, '要警告元素会停在 opacity:0'); + assert.ok(!/可豁免/.test(r.stdout), '旧措辞"放进 .fx-stagger 容器可豁免"已不成立 —— stagger 规则带 :not([data-stage])'); + }); +}); diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/subtitles-invalidate.test.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/subtitles-invalidate.test.mjs new file mode 100644 index 00000000..02ddee51 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/subtitles-invalidate.test.mjs @@ -0,0 +1,228 @@ +// 字幕静帧必须随字幕一起作废(复查 B2): 成片是"帧序列 + 逐句字幕静帧"拼出来的, 静帧留在磁盘上; +// 删掉一句 clauses 或改用 --no-subs 重建时, 如果旧静帧没被清掉、旧清单没被核对, 成片会把**已经不存在 +// 的那句字幕**拼回来 —— 画面里出现一句没人说过的字幕, 而全链路零报错。 +// 这里用像素级判据: 末段实帧与"当前该显示的静帧"几乎一致, 与"旧的第 2 句静帧"明显不同。 +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { findTool, loadPackage, subtitleKeyframes, subtitleWindows } from '../scripts/tools.mjs'; +import { runSkill, tmpdir, FRESH_TOKENS } from './helpers.mjs'; + +const FFMPEG = findTool('ffmpeg'); +const FFPROBE = findTool('ffprobe'); + +// 两图逐像素差(YAVG): 本文件唯一的成对差异实现(两个用例共用) +const imgDiff = (a, b) => { + const o = spawnSync(FFMPEG, ['-hide_banner', '-loglevel', 'error', '-i', a, '-i', b, + '-lavfi', '[0:v][1:v]blend=all_mode=difference,signalstats,metadata=print:file=-', '-frames:v', '1', '-f', 'null', '-'], + { encoding: 'utf8', windowsHide: true }); + const m = /YAVG=([0-9.]+)/.exec((o.stdout || '') + (o.stderr || '')); + return m ? parseFloat(m[1]) : null; +}; + +test('字幕静帧随字幕作废: 删掉一句 / --no-subs 重建后, 成片不得再出现旧字幕(像素级)', async t => { + if (!FFMPEG || !FFPROBE) return t.skip('无 ffmpeg/ffprobe(主 CI 环境; 由 scoped smoke workflow 覆盖)'); + const playwright = await loadPackage('playwright'); + if (!playwright) return t.skip('无 playwright'); + try { const b = await playwright.chromium.launch({ headless: true }); await b.close(); } catch { + return t.skip('chromium 未安装(npx playwright install chromium)'); + } + + const proj = tmpdir(); + assert.equal(runSkill('init-project.mjs', [proj, '--topic', 'SubInvalidate']).status, 0); + const g = spawnSync(FFMPEG, ['-v', 'error', '-f', 'lavfi', '-i', 'anullsrc=r=32000:cl=mono', '-t', '3', + '-c:a', 'libmp3lame', '-b:a', '64k', '-y', path.join(proj, 'audio', '01.mp3')], { windowsHide: true }); + assert.equal(g.status, 0, g.stderr ?? ''); + fs.writeFileSync(path.join(proj, 'slides', '01-title.html'), + ` +

字幕作废检查

`); + + const setClauses = list => { + const p = path.join(proj, 'script.json'); + const s = JSON.parse(fs.readFileSync(p, 'utf8')); + s.slides = s.slides.filter(x => x.id === '01'); + s.slides[0].clauses = list; + fs.writeFileSync(p, JSON.stringify(s, null, 2)); + }; + const man = () => JSON.parse(fs.readFileSync(path.join(proj, 'build', 'substills', '01.json'), 'utf8')); + const subFile = k => path.join(proj, 'build', 'substills', '01', `s${k}.png`); + // 字幕条: 模板里 .kit-sub 是 bottom: 7.78% × 1080 ≈ 84px、40px 字、居中 → 取正下方居中一块, + // 比整条字幕带敏感得多(整带 1920×260 会把字幕只占的一小块平均掉) + const box = 'crop=600:140:660:890'; + const YAVG = imgDiff; + const crop = (src, out) => { spawnSync(FFMPEG, ['-y', '-hide_banner', '-loglevel', 'error', '-i', src, '-vf', box, out], { windowsHide: true }); return out; }; + // 字幕带亮度统计: YMAX=文字峰值(满亮 235 档, 截在淡变上掉到 ~130), YMIN=底板地板(叠框会下陷) + const STATS = img => { + const o = spawnSync(FFMPEG, ['-hide_banner', '-loglevel', 'error', '-i', img, + '-lavfi', `[0:v]${box},signalstats,metadata=print:file=-`, '-frames:v', '1', '-f', 'null', '-'], + { encoding: 'utf8', windowsHide: true }); + const txt = (o.stdout || '') + (o.stderr || ''); + const g = k => { const m = new RegExp(k + '=([0-9.]+)').exec(txt); return m ? parseFloat(m[1]) : null; }; + return { YMIN: g('YMIN'), YAVG: g('YAVG'), YMAX: g('YMAX') }; + }; + const YMAX = img => STATS(img).YMAX; + const YFLOOR = img => STATS(img).YMIN; + const grab = (t0, out) => { spawnSync(FFMPEG, ['-y', '-hide_banner', '-loglevel', 'error', '-ss', String(t0), '-i', path.join(proj, 'out', 'final.mp4'), '-frames:v', '1', '-vf', box, out], { windowsHide: true }); return out; }; + const dur = () => parseFloat((spawnSync(FFPROBE, ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', path.join(proj, 'out', 'final.mp4')], { encoding: 'utf8' }).stdout || '').trim()); + const work = path.join(proj, 'build', 'subcheck'); + fs.mkdirSync(work, { recursive: true }); + + // ① 两句: 出片并留下证据(第 2 句的静帧 = 旧字幕长什么样) + setClauses([{ stage: 1, text: '第一句。' }, { stage: 2, text: '第二句展开内容。' }]); + assert.equal(runSkill('plan-timings.mjs', [proj]).status, 0); + assert.equal(runSkill('capture.mjs', [proj, '--mode', 'still']).status, 0); + assert.ok(fs.existsSync(subFile(1)), '前置: 第 2 句的静帧应已生成'); + assert.equal(man().stills.length, 2, '前置: 清单里应有 2 句'); + // 拼接静帧必须满亮(2026-09-22 灰字幕回归): 静帧曾截在关键帧淡变的半山腰上, + // 与帧序列段拼出"字幕出现不到一秒就跳灰"。标定: 满亮峰值实测 235(细笔画 AA 余量), 灰 bug 实测 138。 + for (const k of [0, 1]) { + const y = YMAX(subFile(k)); + console.log(` 实测(第 ${k + 1} 句静帧字幕带 YMAX, 满亮=235 档): ${y}`); + assert.ok(y != null && y >= 200, `第 ${k + 1} 句的拼接静帧必须满不透明(字幕带峰值 ${y})`); + } + // 跨句残留防漏(2026-09-22): 同一页逐张截图, 钉过的字幕若漏进后续静帧, 会叠出深色底板补丁 —— + // 两张静帧的暗部地板必须一致(同夹具成对比较; 暗主题下叠框差会缩小, 夹具固定 theme a 才够判别力) + const floor = [YFLOOR(subFile(0)), YFLOOR(subFile(1))]; + console.log(` 实测(两句静帧字幕带暗部地板, 应一致): ${floor.join(' / ')}`); + assert.ok(Math.abs(floor[0] - floor[1]) <= 12, `上一句字幕不得漏进下一张静帧(暗部地板 ${floor.join(' / ')})`); + // 封面与静帧基底不得烙字幕(2026-09-22 修复的两条回归): 两者都是"无字幕终态图" —— 封面曾被 + // 钉死残留烙进最后一句, 基底曾被末句关键帧终值烙进最后一句。与带字幕静帧在字幕带上必须有 + // 明显差异, ≈0 即说明字幕被烙进了无字幕图。 + for (const [label, img] of [['封面', path.join(proj, 'preview', 'cover.png')], ['静帧基底', path.join(proj, 'preview', '01.png')]]) { + const d = YAVG(crop(img, path.join(work, `${label}-band.png`)), crop(subFile(1), path.join(work, 'leak-s1-band.png'))); + console.log(` 实测(${label} 与带字幕静帧的差异, ≈0=字幕被烙进): ${d}`); + assert.ok(d != null && d > 3, `${label} 不得烙进字幕(与带字幕静帧差异仅 ${d})`); + } + const oldS1 = path.join(work, 'old-s1.png'); + fs.copyFileSync(subFile(1), oldS1); + + // ② 删掉第 2 句: 重建后旧静帧必须消失, 且成片里不能再出现那句字幕 + setClauses([{ stage: 1, text: '第一句。' }]); + assert.equal(runSkill('plan-timings.mjs', [proj]).status, 0); + assert.equal(runSkill('capture.mjs', [proj, '--mode', 'still']).status, 0); + assert.equal(fs.existsSync(subFile(1)), false, '旧的第 2 句静帧必须被作废(否则会被拼回成片)'); + assert.equal(man().stills.length, 1, '清单必须只剩当前这一句'); + assert.equal(runSkill('build-video.mjs', [proj]).status, 0); + const lateFrame = grab(Math.max(0.3, dur() * 0.85), path.join(work, 'late.png')); + const dOldS1 = YAVG(lateFrame, crop(oldS1, path.join(work, 'old-s1-band.png'))); + const dCurS0 = YAVG(lateFrame, crop(subFile(0), path.join(work, 'cur-s0-band.png'))); + assert.ok(dOldS1 != null && dCurS0 != null, '应能算出字幕带差异'); + console.log(` 实测(字幕条差异, 越小越像): 当前字幕 ${dCurS0} · 被删掉的旧字幕 ${dOldS1}`); + assert.ok(dCurS0 < 2.5, `末段画面应贴合当前唯一那句字幕, 实际差异 ${dCurS0}`); + assert.ok(dOldS1 > 4.0, `末段画面不得还是被删掉的那句(与旧静帧差异只有 ${dOldS1})`); + + // ③ --no-subs 重建: 静帧与清单都要清掉, 成片不再有字幕 + const withSubs = path.join(work, 'with-subs-s0.png'); // 先留一份"有字幕"的参照(下面会把源文件清掉) + fs.copyFileSync(subFile(0), withSubs); + assert.equal(runSkill('capture.mjs', [proj, '--mode', 'still', '--no-subs']).status, 0); + assert.equal(fs.existsSync(path.join(proj, 'build', 'substills', '01.json')), false, '--no-subs 时清单必须清掉'); + assert.equal(fs.existsSync(path.join(proj, 'build', 'substills', '01')), false, '--no-subs 时静帧目录必须清掉'); + assert.equal(runSkill('build-video.mjs', [proj]).status, 0); + const lateNoSubs = grab(Math.max(0.3, dur() * 0.85), path.join(work, 'late-nosubs.png')); + const dWithSubs = YAVG(lateNoSubs, crop(withSubs, path.join(work, 'gone-s0-band.png'))); + assert.ok(dWithSubs != null, '应能算出差异'); + console.log(` 实测(--no-subs 后与"有字幕"参照的差异): ${dWithSubs}`); + assert.ok(dWithSubs > 4.0, `--no-subs 之后画面不该还有字幕(与有字幕静帧的差异只有 ${dWithSubs})`); +}); + +// 第三轮复查(1.7.1): 清单的 framesCover 必须与"当前帧序列的实际时长"对齐 —— B2 点名的第三项。 +// 时长与句序都对、但动画窗重截过(帧数变了)的清单, 会把字幕静帧从错误的时刻拼进成片。 +// 判据用整帧差异: 静帧是纯红、帧序列是纯灰, 拼没拼进画面一眼可辨(不需要字幕带裁剪)。 +test('清单帧覆盖与当前帧序列不一致 → 忽略清单(像素级: 陈旧静帧不得再进画面)', async t => { + if (!FFMPEG || !FFPROBE) return t.skip('无 ffmpeg/ffprobe(主 CI 环境; 由 scoped smoke workflow 覆盖)'); + + const proj = tmpdir(); + fs.mkdirSync(path.join(proj, 'slides'), { recursive: true }); + fs.writeFileSync(path.join(proj, 'slides', 'tokens.css'), FRESH_TOKENS); + fs.writeFileSync(path.join(proj, 'slides', '01.html'), ''); + fs.writeFileSync(path.join(proj, 'script.json'), JSON.stringify({ + topic: 't', fps: 30, width: 1920, height: 1080, + slides: [{ id: '01', html: '01.html', audio: '01.mp3', clauses: [{ stage: 1, text: '唯一一句。' }] }], + }, null, 2)); + fs.mkdirSync(path.join(proj, 'audio'), { recursive: true }); + assert.equal(spawnSync(FFMPEG, ['-v', 'error', '-f', 'lavfi', '-i', 'anullsrc=r=32000:cl=mono', '-t', '3', + '-c:a', 'libmp3lame', '-b:a', '64k', '-y', path.join(proj, 'audio', '01.mp3')], { windowsHide: true }).status, 0); + fs.mkdirSync(path.join(proj, 'build'), { recursive: true }); + fs.writeFileSync(path.join(proj, 'build', 'timings.json'), JSON.stringify({ + fps: 30, total: 3.0, tts: 3.0, slides: [{ id: '01', duration: 3.0, tts: 3.0, clauses: [{ stage: 1, text: '唯一一句。' }] }], + }, null, 2)); + // 手工帧序列: 30 帧(= 1.0s)纯灰 —— 清单却声称帧覆盖 2.0s(旧动画窗), 差 1s 必须被识破 + // (image2 输出序列默认从 f00001 起编号, 而 build-video 认 f00000 → 必须 -start_number 0) + const fdir = path.join(proj, 'build', 'frames', '01'); + fs.mkdirSync(fdir, { recursive: true }); + assert.equal(spawnSync(FFMPEG, ['-v', 'error', '-f', 'lavfi', '-i', 'color=c=0x808080:s=1920x1080:r=30', '-t', '1', + '-start_number', '0', '-y', path.join(fdir, 'f%05d.png')], { windowsHide: true }).status, 0, '应能造出 30 帧灰底序列'); + assert.ok(fs.existsSync(path.join(fdir, 'f00000.png')), '帧序列应从 f00000 开始(build-video 的输入模式)'); + const stills = path.join(proj, 'build', 'substills'); + fs.mkdirSync(path.join(stills, '01'), { recursive: true }); + assert.equal(spawnSync(FFMPEG, ['-v', 'error', '-f', 'lavfi', '-i', 'color=c=red:s=1920x1080', '-frames:v', '1', + '-y', path.join(stills, '01', 's0.png')], { windowsHide: true }).status, 0, '应能造出红色字幕静帧'); + fs.mkdirSync(path.join(proj, 'preview'), { recursive: true }); + assert.equal(spawnSync(FFMPEG, ['-v', 'error', '-f', 'lavfi', '-i', 'color=c=0x808080:s=1920x1080', '-frames:v', '1', + '-y', path.join(proj, 'preview', 'cover.png')], { windowsHide: true }).status, 0, '应能造出封面占位'); + + const diff = imgDiff; + const grab = (t0, out) => { spawnSync(FFMPEG, ['-y', '-hide_banner', '-loglevel', 'error', '-ss', String(t0), + '-i', path.join(proj, 'out', 'final.mp4'), '-frames:v', '1', out], { windowsHide: true }); return out; }; + const manPath = path.join(stills, '01.json'); + + // ① 陈旧清单: 时长对、句数对, 但 framesCover=2 ≠ 当前 1.0 → 必须点名并忽略 + fs.writeFileSync(manPath, JSON.stringify({ fps: 30, duration: 3, framesCover: 2, stills: [{ start: 2, end: 3, k: 0 }] })); + let r = runSkill('build-video.mjs', [proj]); + assert.equal(r.status, 0, r.stdout + r.stderr); + assert.match(r.stdout + r.stderr, /帧覆盖 2s ≠ 当前 1s.*忽略该清单/, '要点名帧覆盖不符并忽略: ' + r.stdout + r.stderr); + const dStale = diff(grab(2.55, path.join(proj, 'build', 'cov-stale.png')), path.join(stills, '01', 's0.png')); + console.log(` 实测(陈旧清单被忽略后, 末段与红色静帧的差异): ${dStale}`); + assert.ok(dStale != null && dStale > 4.0, `陈旧静帧不得再进画面(与红色静帧差异仅 ${dStale})`); + + // ② 对齐清单: framesCover=1 与当前帧序列一致 → 同一张静帧正常拼入(末段 = 红色) + fs.writeFileSync(manPath, JSON.stringify({ fps: 30, duration: 3, framesCover: 1, stills: [{ start: 1, end: 3, k: 0 }] })); + r = runSkill('build-video.mjs', [proj]); + assert.equal(r.status, 0, r.stdout + r.stderr); + const dOk = diff(grab(2.55, path.join(proj, 'build', 'cov-ok.png')), path.join(stills, '01', 's0.png')); + console.log(` 实测(清单对齐后, 末段与红色静帧的差异): ${dOk}`); + assert.ok(dOk != null && dOk < 2.5, `对齐清单的静帧应当拼入画面(与红色静帧差异 ${dOk})`); +}); + +// 2026-09-22 灰字幕回归(工具层, 无工具门槛)。形状契约四条, 前两条各抓过一次真回归: +// ①两端隐藏: fill both 下 finish() 后回到 opacity:0 —— 封面/静帧基底图靠它保持无字幕 +// (1.9.4 的"末句无尾帧"形状让终值停在 1, 最后一句被烙进封面与基底); +// ②无同偏移撞车: 同偏移多次声明时后者生效, 平台会被尾帧吃掉(灰字幕根因); +// ③末句零可见淡出(平台保持到片尾) / 非末句保留切换淡出; ④偏移不越 [0,100] 且单调。 +test('字幕关键帧形状: 两端隐藏契约、末句零可见淡出、无同偏移撞车(工具层)', () => { + const parse = css => (css.match(/@keyframes kit-sub-\d+\{(?:[^{}]|\{[^{}]*\})*\}/g) ?? []).map(blk => { + const groups = [...blk.matchAll(/([\d.,%]+)\{opacity:([\d.]+)\}/g)].map(m => ({ + offsets: [...new Set(m[1].split(',').map(parseFloat))], // 同一选择器列表里的重复偏移是别名(如 100.000%,100%), 不算撞车 + opacity: parseFloat(m[2]), + })); + return { blk, groups, offsets: groups.flatMap(g => g.offsets) }; + }); + + const suites = [ + { name: '单句(即末句)', css: subtitleKeyframes([{ start: 0.1 }], 3), lasts: [true] }, + { name: '两句', css: subtitleKeyframes([{ start: 0 }, { start: 1.2 }], 3), lasts: [false, true] }, + { name: '末句起点 99.8%', css: subtitleKeyframes([{ start: 0 }, { start: 2.994 }], 3), lasts: [false, true] }, + ]; + for (const { name, css, lasts } of suites) { + const blocks = parse(css); + assert.equal(blocks.length, lasts.length, `${name}: 块数应等于句数`); + for (let i = 0; i < blocks.length; i++) { + const { blk, groups, offsets } = blocks[i]; + assert.ok(offsets.length > 0 && groups.length >= 3, `应解析出关键帧组: ${blk}`); + assert.ok(offsets.every(o => o >= 0 && o <= 100), `偏移越界(CSS 关键帧选择器只认 0–100%): ${blk}`); + for (let j = 1; j < offsets.length; j++) assert.ok(offsets[j] >= offsets[j - 1], `偏移乱序: ${blk}`); + assert.equal(new Set(offsets).size, offsets.length, `同偏移声明两次(后者会吃掉前者, 灰字幕根因): ${blk}`); + assert.equal(groups[0].opacity, 0, `起点必须隐藏: ${blk}`); + assert.equal(groups.at(-1).opacity, 0, `100% 终值必须归 0(finish() 后自动隐藏的契约): ${blk}`); + assert.ok(groups.at(-1).offsets.includes(100), `尾帧应落在 100%: ${blk}`); + const plateau = groups.find(g => g.opacity === 1); + const fade = groups.at(-1).offsets[0] - plateau.offsets.at(-1); + if (lasts[i]) assert.ok(fade < 0.1, `末句不得有可见淡出(淡出窗 ${fade}%): ${blk}`); + else assert.ok(fade >= 0.1, `非末句应保留切换淡出(淡出窗 ${fade}%): ${blk}`); + } + } + // b 夹紧: 末句起点晚于 99.5% 时窗口终点不得越过 100 + assert.equal(subtitleWindows([{ start: 0 }, { start: 2.994 }], 3)[1].b, 100); +}); diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/table-kit.test.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/table-kit.test.mjs new file mode 100644 index 00000000..d84a4947 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/table-kit.test.mjs @@ -0,0 +1,68 @@ +// 表格工具箱: 原语必须真在生成的 tokens.css 里, 且可读性硬指标不能被改回去。 +// 覆盖过的真问题: 表格文字用 caption(24px) 在手机上读不清; 行高压到 68px; 涨跌色自造色。 +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { runSkill, tmpdir } from './helpers.mjs'; +import { TABLE_CSS, hasTableKit } from '../scripts/table-css.mjs'; + +const proj = tmpdir(); +const r = runSkill('init-project.mjs', [proj, '--topic', 'T']); +assert.equal(r.status, 0, r.stderr); +const css = fs.readFileSync(path.join(proj, 'slides', 'tokens.css'), 'utf8'); + +describe('表格工具箱 · 模板内容', () => { + test('四种形态的原语都在(.tbl 数据表 / .kv 规格表 / .matrix 对比矩阵 / .rank 排名表)', () => { + for (const sel of ['.tbl {', '.kv {', '.matrix {', '.rank {']) { + assert.ok(css.includes(sel), `缺 ${sel}`); + } + assert.match(css, /\.tbl tbody tr\s*\{[^}]*height:\s*72px/, '数据表行高必须 ≥72px'); + assert.match(css, /\.rank td\s*\{[^}]*height:\s*72px/, '排名表行高必须 ≥72px'); + // 高亮列必须连表头一起覆盖: 只写 td.hi 时 静默不生效(2026-09-18 排查抓到) + assert.match(css, /\.matrix th\.hi/, '矩阵高亮列要覆盖表头'); + }); + + test('主数据用正文号(视频在手机上要读得清), 表头才降到 caption', () => { + for (const sel of ['.tbl {', '.kv {', '.matrix {', '.rank {']) { + const i = css.indexOf(sel); + assert.match(css.slice(i, css.indexOf('}', i)), /font-size:\s*var\(--fs-body\)/, `${sel} 主数据必须是 --fs-body`); + } + assert.match(css, /\.tbl th\s*\{[^}]*font-size:\s*var\(--fs-caption\)/, '表头用 caption'); + }); + + test('涨跌走令牌, 高亮/合计各有语义类', () => { + assert.match(css, /\.tbl \.up\s*\{[^}]*var\(--up\)/); + assert.match(css, /\.tbl \.down\s*\{[^}]*var\(--down\)/); + assert.match(css, /\.tbl tr\.key td\s*\{/, '缺高亮行 .key'); + assert.match(css, /\.tbl tr\.sum td\s*\{/, '缺合计行 .sum'); + assert.match(css, /\.tbl \.num\s*\{[^}]*text-align:\s*right[^}]*tabular-nums/, '数值列必须右对齐 + 等宽数字'); + }); + + test('只画横线(不画竖线/斑马纹): 表格原语里不得出现 border-left/right 或 nth-child 斑马纹', () => { + const block = css.slice(css.indexOf('── 表格原语'), css.indexOf('依次入场容器')); + assert.ok(!/border-(left|right)\s*:/.test(block), '表格原语不该画竖线'); + assert.ok(!/nth-child\((odd|even)\)/.test(block), '表格原语不该有斑马纹'); + }); +}); + +describe('表格工具箱 · 老项目升级', () => { + test('--upgrade-css 会补表格原语, 且幂等', () => { + const old = tmpdir(); + fs.mkdirSync(path.join(old, 'slides'), { recursive: true }); + fs.writeFileSync(path.join(old, 'slides', 'tokens.css'), ':root { --accent: #111; }\n'); + assert.equal(hasTableKit(':root { --accent: #111; }'), false); + const r1 = runSkill('init-project.mjs', [old, '--upgrade-css']); + assert.equal(r1.status, 0, r1.stderr); + const after = fs.readFileSync(path.join(old, 'slides', 'tokens.css'), 'utf8'); + assert.match(after, /\.tbl tbody tr\s*\{/); + assert.match(after, /\.matrix th\s*\{/); + const r2 = runSkill('init-project.mjs', [old, '--upgrade-css']); + assert.equal(fs.readFileSync(path.join(old, 'slides', 'tokens.css'), 'utf8'), after, '第二次跑不得再追加'); + assert.ok(r2.stdout.includes('无需升级')); + }); + + test('TABLE_CSS 自身通过 hasTableKit 判定(防止判定与内容脱节)', () => { + assert.equal(hasTableKit(TABLE_CSS), true); + }); +}); diff --git a/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/tokens-fx.test.mjs b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/tokens-fx.test.mjs new file mode 100644 index 00000000..8bc68be5 --- /dev/null +++ b/plugins/Wzdhehe/html2video-for-mcode/skills/html2video-for-mcode/tests/tokens-fx.test.mjs @@ -0,0 +1,114 @@ +// 模板自检: 生成出来的 tokens.css 里, 每个"入场类" fx 的关键帧都必须声明 opacity。 +// 背景: [data-stage] 的基础态是 opacity:0, 入场靠 animation 的 both 填充把 opacity 抬回 1; +// 关键帧若不碰 opacity, 该元素入场后永远不可见(2026-09-18 实测踩过 fx-grow-x/y 与 fx-spotlight)。 +// 这条规则以前只写在 check-slides 的逐张检查里 —— 只有用户刚好用到那个类才报; 这里对模板本身断言。 +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { runSkill, tmpdir } from './helpers.mjs'; + +function parseFx(css) { + const classes = new Map(); // .fx-name → { anim, infinite } + for (const m of css.matchAll(/\.(fx-[a-z-]+)\s*\{([^}]*)\}/g)) { + const decl = m[2]; + const a = /animation\s*:\s*([^;}]+)/.exec(decl); + if (!a) continue; // 容器类(如 .fx-stagger)不算 + classes.set(m[1], { anim: a[1].trim().split(/\s+/)[0], infinite: /\binfinite\b/.test(a[1]) }); + } + // 关键帧体要按括号配对取(单行写法 @keyframes x { from {…} to {…} } 不能靠"行尾 }"切) + const kf = new Map(); // 关键帧名 → 是否声明了 opacity + const re = /@keyframes\s+([\w-]+)\s*\{/g; + let m; + while ((m = re.exec(css))) { + let depth = 0, end = -1; + for (let j = m.index + m[0].length - 1; j < css.length; j++) { + if (css[j] === '{') depth++; + else if (css[j] === '}' && --depth === 0) { end = j; break; } + } + if (end < 0) continue; + kf.set(m[1], /(^|[;{\s])opacity\s*:/.test(css.slice(m.index + m[0].length, end))); + re.lastIndex = end; + } + return { classes, kf }; +} + +describe('模板 tokens.css: 入场类必须能从 opacity:0 抬回来', () => { + const proj = tmpdir(); + const r = runSkill('init-project.mjs', [proj, '--topic', 'T']); + assert.equal(r.status, 0, r.stderr); + const css = fs.readFileSync(path.join(proj, 'slides', 'tokens.css'), 'utf8'); + const { classes, kf } = parseFx(css); + + test('解析到了 fx 类与关键帧(防止正则失效后测试假绿)', () => { + assert.ok(classes.size >= 10, `只解析到 ${classes.size} 个 fx 类`); + assert.ok(kf.size >= 8, `只解析到 ${kf.size} 组关键帧`); + }); + + test('非无限(入场)动画的关键帧都声明了 opacity', () => { + const bad = []; + for (const [cls, { anim, infinite }] of classes) { + if (infinite) continue; // 氛围类不承载入场, 不受基础态影响 + if (!kf.has(anim)) { bad.push(`${cls} → 找不到关键帧 ${anim}`); continue; } + if (!kf.get(anim)) bad.push(`${cls} → @keyframes ${anim} 没有 opacity(该元素会永远隐形)`); + } + assert.deepEqual(bad, []); + }); + + test('no-fx 规则含 opacity 重置(只关动画不管基础态 = 整片空白)', () => { + assert.match(css, /\.no-fx\s+\[data-stage\][^{]*\{[^}]*opacity\s*:\s*1\s*!important/); + }); + + test('.fx-stagger 入场规则全部排除 [data-stage](2026-09-18 实测: nth-child 延迟特异度更高, 会覆盖子元素自己的时刻)', () => { + // 根因: .fx-stagger > *:nth-child(n) 的特异度(0,2,0) > .fx-up 等(0,1,0), 且写在文件末尾 → + // 容器内带 data-stage 的子元素会被 stagger 的 --stagger-base 时刻接管(实测提前 3 秒冒头)。 + // 注意排除 .no-fx 前缀的重置规则 —— 它们本就该作用于所有子元素(含带 data-stage 的, 那是"关动画") + // 看整行选择器: no-fx 重置写成多选择器(.no-fx [data-stage], .no-fx .fx-stagger > * {), + // 从 .fx-stagger 处起匹配拿不到前缀, 所以按行判定 + const staggerRules = [...css.matchAll(/\.fx-stagger\s*>\s*([^{]+)\{/g)] + .filter(m => { + const lineStart = css.lastIndexOf('\n', m.index) + 1; + return !css.slice(lineStart, m.index + m[0].length).includes('.no-fx'); + }) + .map(m => m[1].trim()); + assert.ok(staggerRules.length >= 9, `应解析到 9 条入场 stagger 规则, 实际 ${staggerRules.length}: ${staggerRules.join(' | ')}`); + const bad = staggerRules.filter(sel => !sel.includes(':not([data-stage])')); + assert.deepEqual(bad, [], '每条入场 stagger 选择器都必须带 :not([data-stage])'); + }); +}); + +describe('init-project --upgrade-css: 老项目补 no-fx 规则', () => { + const stale = () => { + const proj = tmpdir(); + fs.mkdirSync(path.join(proj, 'slides'), { recursive: true }); + fs.writeFileSync(path.join(proj, 'slides', 'tokens.css'), ':root { --accent: #111; }\n'); + fs.writeFileSync(path.join(proj, 'script.json'), '{"slides":[]}'); + return proj; + }; + + test('缺规则 → 追加, 且不碰其他文件', () => { + const proj = stale(); + const r = runSkill('init-project.mjs', [proj, '--upgrade-css']); + assert.equal(r.status, 0, r.stderr); + const css = fs.readFileSync(path.join(proj, 'slides', 'tokens.css'), 'utf8'); + assert.match(css, /\.no-fx\s+\[data-stage\][^{]*\{[^}]*opacity\s*:\s*1\s*!important/); + assert.ok(css.startsWith(':root { --accent: #111; }'), '原有内容必须在前'); + assert.equal(fs.readFileSync(path.join(proj, 'script.json'), 'utf8'), '{"slides":[]}', 'script.json 不得被动'); + assert.ok(!fs.existsSync(path.join(proj, 'slides', '_template.html')), '不得生成新文件'); + }); + + test('幂等: 再跑一次不重复追加', () => { + const proj = stale(); + runSkill('init-project.mjs', [proj, '--upgrade-css']); + const once = fs.readFileSync(path.join(proj, 'slides', 'tokens.css'), 'utf8'); + const r = runSkill('init-project.mjs', [proj, '--upgrade-css']); + assert.equal(r.status, 0); + assert.equal(fs.readFileSync(path.join(proj, 'slides', 'tokens.css'), 'utf8'), once); + assert.ok(r.stdout.includes('无需升级')); + }); + + test('没有 tokens.css → 退出 1', () => { + const r = runSkill('init-project.mjs', [tmpdir(), '--upgrade-css']); + assert.equal(r.status, 1); + }); +});