#!/usr/bin/env node // 真实 hesupipe 端到端编排(Plan 4 · Task 8,快线 §8.7 第 10 天验收标准): // 临时库全链 register(副本复用)→ import → publish(dry-run→confirm)→ build // (onStatus 分相计时)→ astro 分相探针(冻结输入重跑计时,暂存盘即清)→ 进程内 // preview(回环随机端口 + quiet)→ 断言 ①-⑤⑦⑧⑨ → docs/evidence/phase4-assertions.json。 // ⑥(双端截图)走 serve 子命令 + Playwright(浏览器域,证据入 docs/evidence/screenshots/)。 // // 运行方式(仓库根,Git Bash): // DATABASE_URL_TEST="postgres://siteforge:siteforge_dev@localhost:5434/sf_phase4_hesupipe" \ // pnpm exec tsx scripts/e2e-real.mjs chain --site hesupipe-real // DATABASE_URL_TEST="…" pnpm exec tsx scripts/e2e-real.mjs serve --build --port 4571 // 与 scripts/sf.ts / smoke-real-wxr.mjs 同机制:@sf/* 与 apps/api TS 源码经根 tsconfig // paths 由 tsx 直连(纯 node 无法解析 workspace TS 包)。 // // 纪律(Task 5 备忘):与任何并发测试/构建互斥(themeDistDir 结构性共享暂存)——执行前 // 确认无其他 spawn 在跑;全部子步骤串行。数据面钉死(Task 7 审查备忘):sitemap 从盘读、 // redirect 校验用 route-manifest 原串(exact_key 逐字节,含 ?attachment_id= 查询形态)。 // // 幂等安全:重跑 chain 时 register 快照复用/import job 复用/publish 已等跳过/build 仅 // succeeded 复用(failed 自动 #a 后缀重跑)——断言计数一律以 SQL/盘读为准,不依赖当次 // 返回值,重跑不产生假失败。 import { spawn } from 'node:child_process' import { existsSync } from 'node:fs' import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' import http from 'node:http' import { dirname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { getDb } from '../apps/api/src/db/client.js' import { createImportJob } from '../apps/api/src/modules/import/import.service.js' import { createBuildJob } from '../apps/api/src/modules/build/build.service.js' import { publishSiteContent } from '../apps/api/src/modules/publish/publish.service.js' import { registerSource } from '../apps/api/src/modules/source/register.service.js' import { createPreviewServer } from '../apps/preview-server/server.mjs' const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') const THEME_PACKAGE = '@sf/theme-pilot-minimal' const ACTOR = 'e2e-phase4@cli' // ---- 小工具 ---- function decodeEntities(s) { return s .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(Number.parseInt(h, 16))) .replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number.parseInt(d, 10))) .replace(/"/g, '"') .replace(/'/g, "'") .replace(/'/g, "'") .replace(/</g, '<') .replace(/>/g, '>') .replace(/&/g, '&') } function titleOf(html) { const m = /([\s\S]*?)<\/title>/.exec(html) return m === null ? '' : decodeEntities(m[1]).trim() } function metaDescriptionOf(html) { // SeoHead 属性序两种形态都认(属性序是实现细节,断言只认语义) for (const re of [ /<meta[^>]*name=["']description["'][^>]*content=["']([^"']*)["']/, /<meta[^>]*content=["']([^"']*)["'][^>]*name=["']description["']/, ]) { const m = re.exec(html) if (m !== null) return decodeEntities(m[1]) } return '' } function canonicalOf(html) { for (const re of [ /<link[^>]*rel=["']canonical["'][^>]*href=["']([^"']*)["']/, /<link[^>]*href=["']([^"']*)["'][^>]*rel=["']canonical["']/, ]) { const m = re.exec(html) if (m !== null) return decodeEntities(m[1]) } return '' } /** * 页面文本命中(证据级匹配):正文文本经 Astro 实体转义(& → &,源数据里 WP 侧 * 预转义的 & 会二次转义)——比较前对页面做两次实体解码(覆盖预转义源)、对预期 * 文本解码一次,再做 includes。 */ function htmlTextIncludes(body, text) { return decodeEntities(decodeEntities(body)).includes(decodeEntities(text)) } /** server.mjs asciiLocation 的脚本侧镜像:manifest 原串 → RFC 头域 ASCII 派生形 */ function asciiLocationMirror(value) { let out = '' for (const ch of value) { const code = ch.codePointAt(0) out += code >= 0x20 && code <= 0x7e ? ch : encodeURIComponent(ch) } return out } /** contentTitle 的脚本侧镜像(themes ui.ts 同语义:data.title ?? knownFields.title ?? slug) */ function contentTitle(c) { const direct = c.data.title if (typeof direct === 'string' && direct !== '') return direct const known = c.data.knownFields?.title if (typeof known === 'string' && known !== '') return known return c.slug } function contentDate(c) { const d = c.data.knownFields?.postDate return typeof d === 'string' && d !== '' ? d : undefined } /** latestEntries 镜像(ui.ts 同语义:postDate 降序,无日期者殿后,稳定序) */ function latestEntries(contents, typeKey, limit) { const of = contents.filter((c) => c.typeKey === typeKey) const dated = of.filter((c) => contentDate(c) !== undefined) const undated = of.filter((c) => contentDate(c) === undefined) const sorted = [...dated].sort((a, b) => { const da = contentDate(a) const db = contentDate(b) return da < db ? 1 : da > db ? -1 : 0 }) return [...sorted, ...undated].slice(0, limit) } async function walkHtml(dir, base = dir, acc = []) { for (const e of await readdir(dir, { withFileTypes: true })) { const full = join(dir, e.name) if (e.isDirectory()) await walkHtml(full, base, acc) else if (e.name.endsWith('.html')) acc.push({ abs: full, rel: relative(base, full).replaceAll('\\', '/') }) } return acc } /** 简易并发池(回环 keep-alive 冒烟全量用) */ async function pool(items, concurrency, fn) { const results = new Array(items.length) let next = 0 async function worker() { for (;;) { const i = next++ if (i >= items.length) return results[i] = await fn(items[i], i) } } await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker)) return results } function makeHttpClient(port) { const agent = new http.Agent({ keepAlive: true, maxSockets: 8 }) function get(rawPath) { return new Promise((resolveGet, rejectGet) => { const req = http.get({ host: '127.0.0.1', port, path: rawPath, agent }, (res) => { const chunks = [] res.on('data', (c) => chunks.push(c)) res.on('end', () => resolveGet({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks).toString('utf8') }), ) }) req.on('error', rejectGet) }) } return { get, agent } } // ---- 断言记录器(预期/实测/判定三列,双写进 JSON 与 stdout) ---- const rows = [] function record(id, expect, actual, pass, note = '') { rows.push({ id, expect, actual, pass, note }) console.log(` ${pass ? 'PASS' : 'FAIL'} ${id}${note === '' ? '' : `(${note})`}`) console.log(` 预期=${expect}`) console.log(` 实测=${actual}`) return pass } // ---- chain 子命令 ---- async function cmdChain(opts) { const db = getDb() const timings = {} const chain = {} try { // ① register(副本复用——content-addressed 快照在 storage/snapshots 已有同哈希实体) console.log('[chain] source:register …') const tReg = performance.now() const reg = await registerSource(db, { siteKey: opts.site, allowedRoots: [join(REPO_ROOT, 'storage', 'incoming')], storageRoot: join(REPO_ROOT, 'storage'), reportRoot: join(REPO_ROOT, 'artifacts', 'register'), wxrPath: join(REPO_ROOT, 'storage', 'incoming', 'hesupipe-wxr.xml'), mediaDir: join(REPO_ROOT, 'storage', 'incoming', 'hesupipe-media'), mediaMapPath: join(REPO_ROOT, 'storage', 'incoming', 'media-map-hesupipe.json'), siteDefaults: { defaultLocale: 'en', productionOrigin: 'https://hesupipe.com' }, }) timings.registerWallMs = performance.now() - tReg timings.registerServiceMs = reg.durationMs chain.register = { runId: reg.runId, siteId: reg.siteId, siteCreated: reg.siteCreated, connectionId: reg.connectionId, snapshots: reg.snapshots.map((s) => ({ kind: s.kind, sha256: s.sha256.slice(0, 12), byteSize: s.byteSize, reused: s.reused, copied: s.copied, durationMs: s.durationMs, })), } for (const s of reg.snapshots) { console.log(` snapshot ${s.kind}: ${s.reused ? '复用' : '新建'}${s.copied ? '' : '(副本已存在)'} ${s.durationMs.toFixed(1)}ms`) } // ② import(默认幂等键——干净库首跑) console.log('[chain] import …') const imp = await createImportJob(db, { siteKey: opts.site, connectionName: 'default', productsPath: '/products/', newsPath: '/news/', }) timings.importMs = imp.durationMs chain.import = { jobId: imp.jobId, status: imp.status, reused: imp.reused, reportPath: imp.reportPath } console.log(` job=${imp.jobId} status=${imp.status} reused=${imp.reused} ${imp.durationMs.toFixed(1)}ms`) // ③ publish(dry-run → confirm token → 执行) console.log('[chain] content:publish(dry-run → confirm)…') const tPubDry = performance.now() const dry = await publishSiteContent(db, { siteKey: opts.site, dryRun: true, actor: ACTOR }) timings.publishDryRunMs = performance.now() - tPubDry const tPub = performance.now() const pub = await publishSiteContent(db, { siteKey: opts.site, dryRun: false, confirmToken: dry.confirmToken, actor: ACTOR, }) timings.publishConfirmMs = performance.now() - tPub chain.publish = { dryRunCandidates: dry.candidates.length, publishedCount: pub.publishedCount, upToDateCount: pub.upToDateCount, siteConfigRevision: pub.siteConfigRevision, } console.log(` dry-run=${dry.candidates.length} confirm published=${pub.publishedCount} upToDate=${pub.upToDateCount}`) // ④ build(onStatus 分相计时;重跑命中 succeeded 复用时产物面从权威报告盘读—— // 复用返回不含 counts/artifacts,权威记录在原 job 报告) console.log('[chain] build:create …') const marks = [] const build = await createBuildJob(db, { siteKey: opts.site, onStatus: (status, jobId) => marks.push({ status, t: performance.now(), jobId }), }) const reusedBuild = build.reused let buildArtifacts = build.artifacts ?? null let buildCounts = build.counts ?? null let buildUrlRewrite = build.urlRewrite ?? null let buildIssues = build.issues ?? null let buildReportOnReuse = null if (reusedBuild) { buildReportOnReuse = JSON.parse(await readFile(build.reportPath, 'utf8')) buildArtifacts = { distPath: buildReportOnReuse.artifacts.distPath, distFileCount: buildReportOnReuse.artifacts.distFileCount, distByteSize: buildReportOnReuse.artifacts.distByteSize, distSha256: buildReportOnReuse.artifacts.distSha256, routeManifestPath: buildReportOnReuse.artifacts.routeManifestPath, sitemapEntries: buildReportOnReuse.artifacts.sitemapEntries, mediaCopied: buildReportOnReuse.artifacts.mediaCopied, } buildCounts = buildReportOnReuse.snapshot.counts buildUrlRewrite = buildReportOnReuse.urlRewrite buildIssues = buildReportOnReuse.issues } timings.buildTotalMs = reusedBuild ? buildReportOnReuse.durationMs : build.durationMs if (!reusedBuild) { const byStatus = new Map(marks.map((m) => [m.status, m.t])) timings.buildSnapshotMs = (byStatus.get('building') ?? build.durationMs) - (byStatus.get('snapshotting') ?? 0) timings.buildBuildingMs = (byStatus.get('validating') ?? build.durationMs) - (byStatus.get('building') ?? 0) timings.buildValidatingReportMs = (byStatus.get('succeeded') ?? build.durationMs) - (byStatus.get('validating') ?? 0) } chain.build = { jobId: build.jobId, status: build.status, reused: reusedBuild, inputDir: build.inputDir, reportPath: build.reportPath, counts: buildCounts, urlRewrite: buildUrlRewrite, issues: buildIssues, artifacts: buildArtifacts, marks: marks.map((m) => m.status), } console.log(` job=${build.jobId} status=${build.status} reused=${String(reusedBuild)} ${timings.buildTotalMs.toFixed(1)}ms`) if (!reusedBuild) { console.log(` 分相 snapshot=${timings.buildSnapshotMs.toFixed(0)}ms building=${timings.buildBuildingMs.toFixed(0)}ms validating+report=${timings.buildValidatingReportMs.toFixed(0)}ms`) } if (build.status !== 'succeeded' || buildArtifacts === null) { throw new Error(`构建未成功(status=${build.status})——观察项处置流程见计划 Task 8 Step 3`) } // ⑤ astro 分相探针:对冻结输入目录重跑一次 astro build 计时(暂存盘前后即清—— // 不触碰 job 权威产物;probe 仅用于耗时分解的 astro 子相) console.log('[chain] astro 计时探针(冻结输入重跑)…') const scratch = join(REPO_ROOT, 'themes', 'pilot-minimal', 'dist') await rm(scratch, { recursive: true, force: true }) timings.astroProbeMs = await new Promise((resolveProbe, rejectProbe) => { const t0 = performance.now() const child = spawn('pnpm', ['--filter', THEME_PACKAGE, 'exec', 'astro', 'build'], { cwd: REPO_ROOT, shell: true, env: { ...process.env, SF_BUILD_INPUT: build.inputDir, ASTRO_TELEMETRY_DISABLED: '1' }, stdio: ['ignore', 'ignore', 'pipe'], }) child.stderr?.on('data', () => {}) child.on('error', rejectProbe) child.on('close', (code) => { if (code === 0) resolveProbe(performance.now() - t0) else rejectProbe(new Error(`astro 探针失败(exit=${String(code)})`)) }) }) await rm(scratch, { recursive: true, force: true }) console.log(` astro probe=${timings.astroProbeMs.toFixed(0)}ms(暂存盘已清)`) // ---- 数据面(盘读) ---- const inputDir = build.inputDir const [routesJson, contentJson, taxonomiesJson, assetsJson, siteJson, manifestJson] = await Promise.all([ readFile(join(inputDir, 'routes.json'), 'utf8'), readFile(join(inputDir, 'content.json'), 'utf8'), readFile(join(inputDir, 'taxonomies.json'), 'utf8'), readFile(join(inputDir, 'assets.json'), 'utf8'), readFile(join(inputDir, 'site.json'), 'utf8'), readFile(buildArtifacts.routeManifestPath, 'utf8'), ]) const routes = JSON.parse(routesJson) const contents = JSON.parse(contentJson) const taxonomies = JSON.parse(taxonomiesJson) const assets = JSON.parse(assetsJson) const site = JSON.parse(siteJson) const manifest = JSON.parse(manifestJson) const origin = site.origin // ---- SQL 对拍(计数以库为权威,双写) ---- console.log('[assert] SQL 对拍 …') const q = async (sql) => (await db.execute(sql)).map((r) => Object.values(r)[0]) const sqlCounts = { contentEntries: Number((await q('select count(*) from content_entries'))[0]), publishedLocales: Number( (await q('select count(*) from entry_locales where published_revision_id is not null'))[0], ), mediaReady: Number((await q("select count(*) from media_assets where status='ready'"))[0]), auditPublish: Number( (await q("select count(*) from audit_logs where action='content.publish'"))[0], ), typeKey: await db.execute('select type_key, count(*) as n from content_entries group by type_key order by type_key'), routeKind: await db.execute('select route_kind, count(*) as n from route_registry group by route_kind order by route_kind'), } console.log(` entries=${sqlCounts.contentEntries} published=${sqlCounts.publishedLocales} mediaReady=${sqlCounts.mediaReady} audit(content.publish)=${sqlCounts.auditPublish}`) // ---- preview(进程内,随机端口,quiet)---- const preview = createPreviewServer({ distDir: buildArtifacts.distPath, routeManifest: manifest, host: '127.0.0.1', port: 0, quiet: true, }) const addr = await preview.start() const client = makeHttpClient(addr.port) console.log(`[preview] http://127.0.0.1:${addr.port}/ (进程内 quiet;dist=${buildArtifacts.distPath})`) // ===== 断言 ① 六类页面 HTTP 200 + 真实内容 ===== console.log('[assert] ① 六类页面 …') const htmlRoutes = routes.filter((r) => r.routeKind === 'html' && r.queryKey === null) const byContent = new Map(contents.map((c) => [c.contentId, c])) const codedRoute = htmlRoutes.find((r) => r.routePath.includes('%') && r.targetContentId !== null) const catRoute = htmlRoutes .filter((r) => r.routePath.startsWith('/product-category/')) .map((r) => { const slug = r.routePath.slice('/product-category/'.length).replace(/\/+$/, '') let term = null for (const tax of taxonomies) { if (tax.publicBasePath.replace(/\/+$/, '') !== '/product-category') continue term = tax.terms.find((t) => t.slug === slug) ?? term } return { r, term, n: term === null ? -1 : term.contentEntryIds.length } }) .sort((a, b) => b.n - a.n || (a.r.routePath < b.r.routePath ? -1 : 1))[0] const productRoute = htmlRoutes .filter((r) => r.targetContentId !== null && byContent.get(r.targetContentId)?.typeKey === 'product') .map((r) => { const c = byContent.get(r.targetContentId) return { r, c, mediaN: Object.keys(c?.media ?? {}).length } }) .sort((a, b) => b.mediaN - a.mediaN || (a.r.routePath < b.r.routePath ? -1 : 1))[0] const newsRoute = htmlRoutes .filter((r) => r.targetContentId !== null && byContent.get(r.targetContentId)?.typeKey === 'news_article') .map((r) => { const c = byContent.get(r.targetContentId) const body = typeof c?.data.bodyHtml === 'string' ? c.data.bodyHtml : '' return { r, c, hasImg: body.includes('/wp-content/uploads/'), hasBody: body !== '' } }) .sort((a, b) => (b.hasImg ? 1 : 0) - (a.hasImg ? 1 : 0) || (a.r.routePath < b.r.routePath ? -1 : 1))[0] const a1 = [] // 1) 首页 { const res = await client.get('/') const top3 = latestEntries(contents, 'news_article', 3).map(contentTitle) const nameOk = htmlTextIncludes(res.body, site.name) const newsOk = top3.every((t) => htmlTextIncludes(res.body, t)) a1.push({ page: 'home /', route: '/', http: res.status, ok: res.status === 200 && nameOk && newsOk, checks: { siteNameInBody: nameOk, latest3NewsTitles: newsOk, top3 }, }) } // 2) 编码内容页 { const res = await client.get(codedRoute.routePath) const title = contentTitle(byContent.get(codedRoute.targetContentId)) a1.push({ page: 'coded content page', route: codedRoute.routePath, http: res.status, ok: res.status === 200 && htmlTextIncludes(res.body, title), checks: { titleInBody: htmlTextIncludes(res.body, title), title }, }) } // 3) 产品分类列表 { const res = await client.get(catRoute.r.routePath) const termName = catRoute.term.name const products = catRoute.term.contentEntryIds .map((id) => byContent.get(id)) .filter((c) => c !== undefined && c.typeKey === 'product') const sampleTitles = products.slice(0, 3).map(contentTitle) a1.push({ page: 'product category list', route: catRoute.r.routePath, http: res.status, ok: res.status === 200 && htmlTextIncludes(res.body, termName) && sampleTitles.every((t) => htmlTextIncludes(res.body, t)), checks: { termNameInBody: htmlTextIncludes(res.body, termName), productTitlesInBody: sampleTitles.every((t) => htmlTextIncludes(res.body, t)), termName, termProducts: products.length }, }) } // 4) 产品详情(真实产品无结构化参数表——Task 4 字段探测既定事实;断言标题+媒体本地化引用) { const res = await client.get(productRoute.r.routePath) const c = productRoute.c const title = contentTitle(c) const mediaPaths = Object.values(c.media ?? {}) const mediaOk = mediaPaths.length === 0 ? true : mediaPaths.every((p) => res.body.includes(p)) a1.push({ page: 'product detail', route: productRoute.r.routePath, http: res.status, ok: res.status === 200 && htmlTextIncludes(res.body, title) && mediaOk, checks: { titleInBody: htmlTextIncludes(res.body, title), localizedMediaRefs: mediaOk, title, mediaRefs: mediaPaths.length, specTableRows: (res.body.match(/<table class="specifications"/g) ?? []).length }, }) } // 5) 新闻列表 /news/(V1 不分页——129 条单页全列) { const res = await client.get('/news/') const top5 = latestEntries(contents, 'news_article', 5).map(contentTitle) a1.push({ page: 'news list /news/', route: '/news/', http: res.status, ok: res.status === 200 && top5.every((t) => htmlTextIncludes(res.body, t)), checks: { latest5Titles: top5.every((t) => htmlTextIncludes(res.body, t)), top5 }, }) } // 6) 新闻详情 { const res = await client.get(newsRoute.r.routePath) const c = newsRoute.c const title = contentTitle(c) const date = contentDate(c) a1.push({ page: 'news detail', route: newsRoute.r.routePath, http: res.status, ok: res.status === 200 && htmlTextIncludes(res.body, title) && (date === undefined || res.body.includes(date)), checks: { titleInBody: htmlTextIncludes(res.body, title), dateInBody: date === undefined ? true : res.body.includes(date), title, date }, }) } for (const row of a1) { record( `①${row.page}`, 'HTTP 200 + 真实内容(标题/列表/媒体引用)', `http=${String(row.http)} checks=${JSON.stringify(row.checks)}`, row.ok, ) } // ===== 断言 ② 图片本地化抽检 10 张 ===== console.log('[assert] ② 图片本地化抽检 …') const pages = await walkHtml(buildArtifacts.distPath) const imgRefs = [] const seen = new Set() for (const p of pages) { const html = await readFile(p.abs, 'utf8') for (const m of html.matchAll(/<img[^>]*src="(\/wp-content\/uploads\/[^"]+)"/g)) { const src = m[1] if (seen.has(src)) continue seen.add(src) imgRefs.push({ src, refPage: p.rel }) if (imgRefs.length >= 10) break } if (imgRefs.length >= 10) break } const assetsByPath = new Map(assets.map((a) => [a.publicPath, a])) const a2 = [] for (const ref of imgRefs) { const distFile = join(buildArtifacts.distPath, ref.src.slice(1)) const exists = existsSync(distFile) const httpRes = await client.get(ref.src) const asset = assetsByPath.get(ref.src) a2.push({ src: ref.src, refPage: ref.refPage, rootRelative: ref.src.startsWith('/') && !ref.src.startsWith('//'), inAssetsJson: asset !== undefined, distFileExists: exists, httpStatus: httpRes.status, byteSize: asset?.byteSize ?? null, }) } record( '②图片本地化抽检', '10 张:根相对引用 + dist 文件存在 + preview 200 + assets.json 在册', JSON.stringify(a2), a2.length === 10 && a2.every((x) => x.rootRelative && x.inAssetsJson && x.distFileExists && x.httpStatus === 200), ) // ===== 断言 ③ title/description/canonical 抽检 5 页(盘读) ===== console.log('[assert] ③ SEO 三件套抽检 …') const seoPicks = [ { label: 'home', routePath: '/' }, { label: 'coded page', routePath: codedRoute.routePath }, { label: 'product category', routePath: catRoute.r.routePath }, { label: 'product detail', routePath: productRoute.r.routePath }, { label: 'news detail', routePath: newsRoute.r.routePath }, ] const a3 = [] for (const pick of seoPicks) { const file = pick.routePath === '/' ? 'index.html' : `${pick.routePath.replace(/^\//, '').replace(/\/+$/, '')}/index.html` const html = await readFile(join(buildArtifacts.distPath, file), 'utf8') const title = titleOf(html) const desc = metaDescriptionOf(html) const canonical = canonicalOf(html) const expectCanonical = `${origin}${pick.routePath}` a3.push({ page: pick.label, routePath: pick.routePath, title, titleNonEmpty: title !== '', descriptionNonEmpty: desc !== '', canonical, canonicalExact: canonical === expectCanonical, expectCanonical, }) } record( '③SEO 三件套×5页', 'title/description 非空 + canonical === origin+路径(逐页精确)', JSON.stringify(a3.map((x) => ({ page: x.page, titleNonEmpty: x.titleNonEmpty, descriptionNonEmpty: x.descriptionNonEmpty, canonicalExact: x.canonicalExact, canonical: x.canonical }))), a3.every((x) => x.titleNonEmpty && x.descriptionNonEmpty && x.canonicalExact), ) // ===== 断言 ④ 404 与 301(?attachment_id= 按原串) ===== console.log('[assert] ④ 404/301 …') const notFound = await client.get('/__e2e_missing_probe__') record( '④未命中 404', '404 + X-Robots-Tag noindex', `status=${String(notFound.status)} x-robots=${notFound.headers['x-robots-tag'] ?? '(无)'}`, notFound.status === 404 && (notFound.headers['x-robots-tag'] ?? '').includes('noindex'), ) const redirectRows = manifest.routes.filter((r) => r.kind === 'redirect') const queryRedirects = redirectRows.filter((r) => r.query_key !== null) const pickSpread = (arr, n) => arr.length <= n ? arr : Array.from({ length: n }, (_, i) => arr[Math.round((i * (arr.length - 1)) / (n - 1))]) const probeRedirects = [...pickSpread(queryRedirects, 3), ...pickSpread(redirectRows.filter((r) => r.query_key === null), 2)] const a4 = [] for (const row of probeRedirects) { const res = await client.get(row.exact_key) a4.push({ request: row.exact_key, expectLocation: row.location ?? null, status: res.status, location: res.headers.location ?? null, locationMatch: res.headers.location === asciiLocationMirror(row.location ?? ''), }) } record( '④301 抽检(?attachment_id= 原串 3 + 路径形态 2)', '逐条 301 + Location === manifest 原串的 RFC 头域 ASCII 派生形(原串含非 ASCII 时 UTF-8 百分号编码)', JSON.stringify(a4), a4.every((x) => x.status === 301 && x.locationMatch), ) // spec 复审修补回归:非 ASCII redirect 跟随到资产终端——Location 编码形请求须命中 // manifest 资产行(exact_key 已归一为同一编码线形)→ 200 + 图片 MIME(浏览器真实路径) const nonAsciiRedirects = redirectRows.filter((r) => /\P{ASCII}/u.test(r.location ?? '')) const a4c = [] for (const row of nonAsciiRedirects) { const hop = await client.get(row.exact_key) const terminalKey = asciiLocationMirror(row.location ?? '') const terminal = await client.get(terminalKey) a4c.push({ request: row.exact_key, location: terminalKey, hopStatus: hop.status, terminalStatus: terminal.status, terminalContentType: terminal.headers['content-type'] ?? null, }) } record( `④c 非 ASCII redirect → 资产终端(${String(a4c.length)} 条全查)`, '301 → 跟随 Location 编码形 → 终端 200 + 图片 MIME(exact_key 编码线形命中)', JSON.stringify(a4c), a4c.length > 0 && a4c.every((x) => x.hopStatus === 301 && x.terminalStatus === 200 && String(x.terminalContentType).startsWith('image/')), ) // ===== 断言 ⑤ sitemap/robots(盘读) ===== console.log('[assert] ⑤ sitemap/robots …') const sitemapXml = await readFile(join(buildArtifacts.distPath, 'sitemap.xml'), 'utf8') const locCount = (sitemapXml.match(/<loc>/g) ?? []).length const indexableCount = manifest.routes.filter((r) => r.kind === 'html' && r.indexable).length const robotsTxt = await readFile(join(buildArtifacts.distPath, 'robots.txt'), 'utf8') record('⑤sitemap 条目数', `= manifest indexable html 数(${String(indexableCount)})`, `loc=${String(locCount)} indexable=${String(indexableCount)}`, locCount === indexableCount) record( '⑤robots.txt', '存在 + Sitemap 行指向 origin', robotsTxt.includes(`Sitemap: ${origin}/sitemap.xml`) ? `存在(Sitemap: ${origin}/sitemap.xml)` : `异常:${JSON.stringify(robotsTxt)}`, robotsTxt.includes(`Sitemap: ${origin}/sitemap.xml`), ) // ===== 断言 ⑦ 全量冒烟(html 190 全 200 + redirect 1746 逐条 301+Location) ===== console.log('[assert] ⑦ 全量冒烟(190 html + 1746 redirect,keep-alive)…') const tSmoke = performance.now() const htmlRows = manifest.routes.filter((r) => r.kind === 'html') const htmlResults = await pool(htmlRows, 8, async (row) => { const res = await client.get(row.exact_key) return { key: row.exact_key, status: res.status, ok: res.status === 200 } }) const htmlFailures = htmlResults.filter((r) => !r.ok) const redirectResults = await pool(redirectRows, 8, async (row) => { const res = await client.get(row.exact_key) const locationMatch = res.headers.location === asciiLocationMirror(row.location ?? '') return { key: row.exact_key, status: res.status, locationMatch, expect: row.location ?? null, got: res.headers.location ?? null } }) const redirectFailures = redirectResults.filter((r) => r.status !== 301 || !r.locationMatch) timings.smokeMs = performance.now() - tSmoke const sample200 = htmlResults[0] const sampleRes = await client.get(sample200.key) record( '⑦html 全量 200', `${String(htmlRows.length)} 行全 200(manifest html 行数)`, `ok=${String(htmlResults.length - htmlFailures.length)}/${String(htmlRows.length)}${htmlFailures.length === 0 ? '' : ` 首失败=${JSON.stringify(htmlFailures.slice(0, 5))}`}`, htmlFailures.length === 0 && htmlRows.length === 190, `样例 ${sample200.key} content-type=${sampleRes.headers['content-type'] ?? '(无)'}`, ) record( '⑦redirect 全量 301+Location', `${String(redirectRows.length)} 行逐条 301 + Location === manifest 原串(非 ASCII 头域 ASCII 化派生形)`, `ok=${String(redirectResults.length - redirectFailures.length)}/${String(redirectRows.length)}${redirectFailures.length === 0 ? '' : ` 首失败=${JSON.stringify(redirectFailures.slice(0, 5))}`}`, redirectFailures.length === 0 && redirectRows.length === 1746, ) // spec 复审修补不变量:manifest 全键编码线形(纯 ASCII)——裸非 ASCII 键永不匹配 // 浏览器请求形(浏览器对裸非 ASCII 一律百分号编码后发送) const nonAsciiKeys = manifest.routes.filter((r) => /\P{ASCII}/u.test(r.exact_key)).map((r) => r.exact_key) record( '⑦manifest 键线形', `全 ${String(manifest.routes.length)} 键纯 ASCII(编码线形——裸非 ASCII 键 = 预览永不命中)`, nonAsciiKeys.length === 0 ? 'nonAsciiKeys=0' : `nonAsciiKeys=${JSON.stringify(nonAsciiKeys.slice(0, 5))}`, nonAsciiKeys.length === 0, ) // ===== 断言 ⑨ 门禁全绿(validation_runs PASS) ===== console.log('[assert] ⑨ 门禁 …') const vruns = await db.execute( `select id, status, rule_version from validation_runs where build_job_id = '${build.jobId}' order by started_at`, ) const vissues = await db.execute( `select severity, count(*) as n from validation_issues where validation_run_id in (select id from validation_runs where build_job_id = '${build.jobId}') group by severity`, ) record( '⑨门禁 validation_runs', 'job succeeded + validation_run PASS(build succeeded 即含门禁通过)', `job=${build.status} runs=${JSON.stringify(vruns)} issues=${JSON.stringify(vissues)}`, build.status === 'succeeded' && vruns.length === 1 && vruns[0].status === 'PASS', ) // ---- SQL 对拍断言(全链计数 vs 预期) ---- console.log('[assert] 计数对拍 …') const routeKindMap = Object.fromEntries(sqlCounts.routeKind.map((r) => [r.route_kind, Number(r.n)])) const typeKeyMap = Object.fromEntries(sqlCounts.typeKey.map((r) => [r.type_key, Number(r.n)])) record('计数 content_entries', '192(129 news_article + 48 product + 15 basic_page)', `total=${String(sqlCounts.contentEntries)} ${JSON.stringify(typeKeyMap)}`, sqlCounts.contentEntries === 192 && typeKeyMap.news_article === 129 && typeKeyMap.product === 48 && typeKeyMap.basic_page === 15) record('计数 published 指针', '192(显式发布后全量)', String(sqlCounts.publishedLocales), sqlCounts.publishedLocales === 192) record('计数 routes', '2772(html 190 / redirect 1746 / asset 836)', JSON.stringify(routeKindMap), routeKindMap.html === 190 && routeKindMap.redirect === 1746 && routeKindMap.asset === 836) record('计数 media ready', '836', String(sqlCounts.mediaReady), sqlCounts.mediaReady === 836) record('计数 audit content.publish', '≥1(本链 confirm 写入)', String(sqlCounts.auditPublish), sqlCounts.auditPublish >= 1) const buildReport = JSON.parse(await readFile(build.reportPath, 'utf8')) record('构建产物面', `dist 文件≈${String(190 + 836 + 2 + 1)}(html+媒体+sitemap/robots+404)`, `distFileCount=${String(buildArtifacts.distFileCount)} mediaCopied=${String(buildArtifacts.mediaCopied)} sitemapEntries=${String(buildArtifacts.sitemapEntries)} urlRewrite=${JSON.stringify(buildUrlRewrite)}`, buildArtifacts.mediaCopied === 836 && buildArtifacts.sitemapEntries === indexableCount) // ---- 汇总落盘 ---- const verdict = rows.every((r) => r.pass) ? 'PASS' : 'FAIL' const result = { verdict, generatedAt: new Date().toISOString(), env: { node: process.version, platform: process.platform, site: opts.site, origin, database: process.env.DATABASE_URL_TEST ?? '(DATABASE_URL)', }, chain, sqlCounts, timings: Object.fromEntries(Object.entries(timings).map(([k, v]) => [k, Math.round(v)])), assertions: rows, detail: { a1, a2, a3, a4, a4c, smoke: { htmlTotal: htmlRows.length, htmlFailures: htmlFailures.length, redirectTotal: redirectRows.length, redirectFailures: redirectFailures.length } }, buildReport: { distPath: buildReport.artifacts.distPath, distFileCount: buildReport.artifacts.distFileCount, distByteSize: buildReport.artifacts.distByteSize, mediaCopied: buildReport.artifacts.mediaCopied, sitemapEntries: buildReport.artifacts.sitemapEntries, validation: buildReport.validation, urlRewrite: buildReport.urlRewrite, durationMs: buildReport.durationMs, }, } await mkdir(join(REPO_ROOT, 'docs', 'evidence'), { recursive: true }) await writeFile(join(REPO_ROOT, 'docs', 'evidence', 'phase4-assertions.json'), `${JSON.stringify(result, null, 2)}\n`, 'utf8') console.log(`\n[chain] verdict=${verdict}(${String(rows.filter((r) => r.pass).length)}/${String(rows.length)} PASS)`) console.log(` 断言明细:docs/evidence/phase4-assertions.json`) console.log(` preview 样例页(serve 会话用):/ 与 ${productRoute.r.routePath} 与 ${newsRoute.r.routePath}`) await preview.stop() client.agent.destroy() if (verdict === 'FAIL') process.exitCode = 1 } finally { await db.$client.end() } } // ---- serve 子命令(截图会话:真实 CLI preview:start 同域的行为面) ---- async function cmdServe(opts) { if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(opts.build)) { throw new Error(`--build 不是合法 job id(uuid 形状):${opts.build}`) } const db = getDb() let distPath let manifestPath try { const jobs = await db.execute(`select id, status from build_jobs where id = '${opts.build}'`) if (jobs.length === 0) throw new Error(`构建 job 不存在:${opts.build}`) if (jobs[0].status !== 'succeeded') throw new Error(`仅 succeeded job 可预览(当前 ${jobs[0].status})`) const arts = await db.execute(`select kind, storage_path from build_artifacts where build_job_id = '${opts.build}'`) distPath = arts.find((a) => a.kind === 'dist')?.storage_path manifestPath = arts.find((a) => a.kind === 'manifest')?.storage_path if (distPath === undefined || manifestPath === undefined) throw new Error('产物登记不全(dist/manifest 缺席)') } finally { await db.$client.end() } const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) const preview = createPreviewServer({ distDir: distPath, routeManifest: manifest, host: '127.0.0.1', port: opts.port, quiet: false }) const addr = await preview.start() console.log(`[serve] job=${opts.build}`) console.log(` dist: ${distPath}`) console.log(` listening: http://127.0.0.1:${String(addr.port)}/ (Ctrl-C 停止)`) process.once('SIGINT', () => { void preview.stop().then(() => process.exit(0)) }) process.once('SIGTERM', () => { void preview.stop().then(() => process.exit(0)) }) await new Promise(() => {}) } // ---- 入口 ---- const argv = process.argv.slice(2) const sub = argv[0] ?? 'chain' const flag = (name, fallback) => { const i = argv.indexOf(`--${name}`) return i !== -1 && argv[i + 1] !== undefined ? argv[i + 1] : fallback } if (sub === 'chain') { await cmdChain({ site: flag('site', 'hesupipe-real') }) } else if (sub === 'serve') { await cmdServe({ build: flag('build', ''), port: Number(flag('port', '4571')) }) } else { console.error('用法:e2e-real.mjs chain [--site key] | serve --build <jobId> [--port n]') process.exitCode = 2 }