|
|
@@ -0,0 +1,120 @@
|
|
|
+#!/usr/bin/env node
|
|
|
+// M5 出口 D1(AC1 的 CI 半):npm pack 产物 → 干净中文路径工作目录 → init → 建书 → next → update 全链路。
|
|
|
+// 经 `npm run e2e:install` 运行(用 npm_execpath 定位 npm,全程 args 数组不走 shell,中文路径安全)。
|
|
|
+import { execFile } from 'node:child_process'
|
|
|
+import { promisify } from 'node:util'
|
|
|
+import { promises as fs } from 'node:fs'
|
|
|
+import os from 'node:os'
|
|
|
+import path from 'node:path'
|
|
|
+import { fileURLToPath } from 'node:url'
|
|
|
+
|
|
|
+const exec = promisify(execFile)
|
|
|
+const pkgRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
|
|
|
+
|
|
|
+const npmCli = process.env.npm_execpath
|
|
|
+if (!npmCli) {
|
|
|
+ console.error('请通过 npm run e2e:install 运行(脚本需要 npm_execpath 定位 npm)')
|
|
|
+ process.exit(1)
|
|
|
+}
|
|
|
+const npm = (args, opts = {}) =>
|
|
|
+ exec(process.execPath, [npmCli, ...args], { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, ...opts })
|
|
|
+
|
|
|
+let failed = false
|
|
|
+const check = (cond, msg) => {
|
|
|
+ console.log(`${cond ? '✓' : '✗'} ${msg}`)
|
|
|
+ if (!cond) failed = true
|
|
|
+}
|
|
|
+const exists = async (p) => {
|
|
|
+ try {
|
|
|
+ await fs.access(p)
|
|
|
+ return true
|
|
|
+ } catch {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const cleanups = []
|
|
|
+try {
|
|
|
+ // 1. pack(发布产物 = npx 消费的同一份包内容)
|
|
|
+ const packDest = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-pack-'))
|
|
|
+ cleanups.push(packDest)
|
|
|
+ const packOut = await npm(['pack', '--pack-destination', packDest], { cwd: pkgRoot })
|
|
|
+ const tgzName = packOut.stdout.trim().split('\n').at(-1).trim()
|
|
|
+ const tgz = path.join(packDest, tgzName)
|
|
|
+ check(await exists(tgz), `npm pack 产物:${tgzName}`)
|
|
|
+
|
|
|
+ // 2. 装进干净沙箱
|
|
|
+ const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-sandbox-'))
|
|
|
+ cleanups.push(sandbox)
|
|
|
+ await npm(['install', tgz, '--prefix', sandbox, '--no-audit', '--no-fund'], { cwd: sandbox })
|
|
|
+ const installedBin = path.join(sandbox, 'node_modules', 'webnovel-writer', 'bin', 'webnovel-writer.js')
|
|
|
+ check(await exists(installedBin), '安装产物 bin 存在')
|
|
|
+
|
|
|
+ // 3. 中文用户名模拟路径里 init(一条命令装出工作目录)
|
|
|
+ const base = await fs.mkdtemp(path.join(os.tmpdir(), '中文用户名-'))
|
|
|
+ cleanups.push(base)
|
|
|
+ const workdir = path.join(base, '工作目录')
|
|
|
+ await fs.mkdir(workdir, { recursive: true })
|
|
|
+ const node = (bin, args) =>
|
|
|
+ exec(process.execPath, [bin, ...args], { cwd: workdir, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 })
|
|
|
+
|
|
|
+ const init = await node(installedBin, ['init', '--hosts=claude-code,codex'])
|
|
|
+ console.log('--- init 报告 ---\n' + init.stdout + '-----------------')
|
|
|
+ for (const rel of [
|
|
|
+ '.webnovel/bin/webnovel-writer.js',
|
|
|
+ '.webnovel/node_modules/js-yaml/package.json',
|
|
|
+ '.webnovel/manifest.json',
|
|
|
+ '.webnovel/books.jsonl',
|
|
|
+ '.claude/skills/webnovel-writer/SKILL.md',
|
|
|
+ '.claude/agents/事实审查.md',
|
|
|
+ '.claude/settings.json',
|
|
|
+ '.codex/agents/事实审查.toml',
|
|
|
+ 'AGENTS.md',
|
|
|
+ ]) {
|
|
|
+ check(await exists(path.join(workdir, rel)), `init 布局:${rel}`)
|
|
|
+ }
|
|
|
+
|
|
|
+ // 4. vendored bin 建第一本书(干净环境一条命令装出并建书 = AC1 链路)
|
|
|
+ const vendoredBin = path.join(workdir, '.webnovel', 'bin', 'webnovel-writer.js')
|
|
|
+ await fs.writeFile(
|
|
|
+ path.join(workdir, '建书.json'),
|
|
|
+ JSON.stringify({
|
|
|
+ book: { spec_version: '7.0', 书名: '雪落长安', 卷规模: 40, 体检周期: 50 },
|
|
|
+ 总纲: '# 总纲\n## 结局\n终成一代大家。',
|
|
|
+ 卷纲: '# 第1卷\n初入长安。',
|
|
|
+ }),
|
|
|
+ 'utf8'
|
|
|
+ )
|
|
|
+ const pb = await node(vendoredBin, ['persist-book', '--file=建书.json'])
|
|
|
+ check(pb.stdout.includes('已登记为当前书'), '建书并登记为当前书(vendored bin)')
|
|
|
+ check(await exists(path.join(workdir, '雪落长安', 'book.yaml')), '书仓库 book.yaml')
|
|
|
+ check(await exists(path.join(workdir, '雪落长安', 'AGENTS.md')), '书仓库指路 AGENTS.md')
|
|
|
+
|
|
|
+ // 5. next --json → 序6 起草第 1 章
|
|
|
+ const nx = await node(vendoredBin, ['next', '--json'])
|
|
|
+ const dto = JSON.parse(nx.stdout)
|
|
|
+ check(dto.序 === 6 && dto.dto.nextChapter === 1, `next 判定起草第 1 章(实际:序${dto.序})`)
|
|
|
+
|
|
|
+ // 6. update 幂等重跑
|
|
|
+ const up = await node(installedBin, ['update'])
|
|
|
+ check(/升级完成|安装完成/.test(up.stdout), 'update 幂等可重跑')
|
|
|
+
|
|
|
+ if (failed) {
|
|
|
+ console.error('\n安装链路 e2e 存在失败项')
|
|
|
+ process.exit(1)
|
|
|
+ }
|
|
|
+ console.log('\n安装链路 e2e 全通过:pack → 安装 → 中文路径 init → 建书 → next → update')
|
|
|
+} catch (err) {
|
|
|
+ console.error(`安装链路 e2e 异常:${err.message}`)
|
|
|
+ if (err.stdout) console.error(err.stdout)
|
|
|
+ if (err.stderr) console.error(err.stderr)
|
|
|
+ process.exit(1)
|
|
|
+} finally {
|
|
|
+ for (const d of cleanups) {
|
|
|
+ try {
|
|
|
+ await fs.rm(d, { recursive: true, force: true, maxRetries: 3 })
|
|
|
+ } catch {
|
|
|
+ // 临时目录清理尽力而为
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|