Files
cms-website/tests/integration/preview-server.test.ts
T
浅念 236f3dc4d3 fix(preview+pipeline): 资产键编码归一——7 张全角名图片预览 404 修复;证据文档叙述修正
spec 复审发现(浏览器可见缺陷):manifest 资产行 exact_key 有 7 例为裸形
(文件名含全角括号/顿号/间隔号)——html/redirect 键源数据侧已编码、asset
键来自文件名本体是裸形;浏览器对裸非 ASCII 一律百分号编码后发请求,预览
按请求字节逐字匹配 → 裸键永不命中 → 两页 3 张图破图(dist 文件在盘;
生产面 nginx 先解码不受影响)。修补 pipeline.toManifestRoute:exact_key
归一为 URL 线形(可打印 ASCII 原样、其余 UTF-8 百分号编码大写 hex),
target_file 仍盘上裸名、DB/渲染快照域不变。

回归三层:build-pipeline 注入裸全角资产行(红验证);preview-server 2c
非 ASCII redirect → Location → 资产终端 200;e2e 断言 ④c(7 条全查
跟随到终端 200)+ ⑦c(manifest 2772 键全 ASCII 不变量)。spec 复审复跑
(新库全链 job e059bf41)23/23 PASS,浏览器实测 3 张原破图全部加载。

证据文档:§5.2 失败计数修正(5×500+2×mojibake=7 例)、§2 ①c 措辞对齐
断言实义(抽前 3 标题)、§5.5 修补记录、计时/产物/job 面更新至复跑值。
验证:vitest 382 + 主题 26 全绿(408);typecheck 过;lint 裸跑零告警。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 15:31:14 +08:00

487 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Plan 4 Task 7 失败测试(先于实现):路由驱动的本地预览服务器(V2 spec §12.2)。
// 夹具策略:先跑一次 createBuildJob 全链(真实构建产物 = mini 夹具站点 succeeded job),
// 产物 dist + route-manifest 复用于全部用例;manifest 是 createPreviewServer 的独立入参,
// 故 gone 行与扩展名映射探针行直接手构注入(真实数据暂无 kind=gone——§12.1 契约先行)。
// 覆盖契约(对照计划 Task 7 十条):
// 1. html 行 200 + Content-Type text/html; charset=utf-8(body = dist 产物逐字节)
// 2. redirect 行 301 + Location(/?attachment_id=201 查询形态——exact_key 含查询串)
// 3. asset 行 200 + 正确 MIME(png 真实行 content_type;jpg/webp/pdf 扩展名映射;
// 未知扩展 → application/octet-stream)
// 4. 未命中 404 + dist/404.html 自定义页(Content-Type html)
// 5. gone 行 410 + 简短 HTML 说明(手构 manifest 行)
// 6. 全响应 X-Robots-Tag: noindex, nofollow;HTML 响应基础 CSP(default-src 'self')
// 7. 绑定 127.0.0.1(server.address() 断言)+ host 外值拒绝(库级 + CLI 级 spawn)
// 8. 路径穿越拒绝:/../、%2e%2e%2f、..%5c、反斜杠 → 400/404 且不读盘外文件
// 9. 目录列表禁用(dist 子目录请求 → 404,不出文件清单)
// 10. HEAD 方法支持(200 同头无 body)
// 11. CLI:--host 外值拒绝(校验先于 DB 访问——tsx 直跑 sf.ts)
// 12. CLI happy path:真实 job 子进程全链(job 定位 → manifest 读 → 监听可服务 → 关停;
// --quiet 透传——子进程 stdout 无逐请求访问日志)
// 13. quiet 开关:quiet:true 静默逐请求日志;缺省(verbose)逐请求一行 stdout
import { execFile, spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { request } from 'node:http'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import type { RouteManifest, RouteManifestRoute } from '@sf/contracts'
import { createBuildJob } from '../../apps/api/src/modules/build/build.service.js'
import { createImportJob } from '../../apps/api/src/modules/import/import.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 { getDb } from '../../apps/api/src/db/client.js'
import { rand, withImmutableCleanup, type Db } from './helpers.js'
import { createPreviewServer, type PreviewServerHandle } from '../../apps/preview-server/server.mjs'
/** 本文件位于 <repo>/tests/integration/ → 上两级是仓库根 */
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
const FIXTURES_ROOT = fileURLToPath(new URL('../fixtures', import.meta.url))
const WXR_FIXTURE = resolve(FIXTURES_ROOT, 'wxr', 'mini.xml')
const TSX_CLI = join(REPO_ROOT, 'node_modules', 'tsx', 'dist', 'cli.mjs')
const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
function mediaBytes(seed: string): Buffer {
return Buffer.concat([PNG_SIG, Buffer.from(`media-body:${seed}`, 'latin1')])
}
function sha256of(buf: Buffer | string): string {
return createHash('sha256').update(buf).digest('hex')
}
interface Work {
work: string
storageRoot: string
allowedRoots: string[]
pngA: Buffer
}
async function makeWork(prefix: string): Promise<Work> {
const work = await mkdtemp(join(tmpdir(), `${prefix}-`))
const mediaDir = join(work, 'media')
await mkdir(join(mediaDir, '2026', '10'), { recursive: true })
const pngA = mediaBytes(`preview-a-${rand()}`)
await writeFile(join(mediaDir, '2026', '10', 'fixture-a.png'), pngA)
await writeFile(join(mediaDir, '2026', '10', 'fixture-b.png'), mediaBytes(`preview-b-${rand()}`))
return { work, storageRoot: join(work, 'storage'), allowedRoots: [FIXTURES_ROOT, work], pngA }
}
/** mini 全链(register wxr+media → import → publish):3 条 published(build-pipeline 同款) */
async function seedPublishedSite(db: Db, w: Work): Promise<{ key: string; siteId: string }> {
const key = `sfpreview-${rand()}`
const reg = await registerSource(db, {
siteKey: key,
wxrPath: WXR_FIXTURE,
mediaDir: join(w.work, 'media'),
allowedRoots: w.allowedRoots,
storageRoot: w.storageRoot,
reportRoot: join(w.work, 'artifacts', 'register'),
})
const result = await createImportJob(db, { siteKey: key, reportRoot: join(w.work, 'artifacts', 'import') })
expect(result.status).toBe('succeeded_with_issues')
const dry = await publishSiteContent(db, { siteKey: key, dryRun: true })
const run = await publishSiteContent(db, { siteKey: key, confirmToken: dry.confirmToken! })
expect(run.publishedCount).toBe(3)
return { key, siteId: reg.siteId }
}
/** 本文件写入的行清场(build-pipeline 同款反序:validation 域先行 + 构建域 + 全链) */
async function cleanupSiteRows(db: Db, siteId: string): Promise<void> {
await db.execute(`delete from validation_issues where validation_run_id in
(select id from validation_runs where site_id = '${siteId}')`)
await db.execute(`delete from validation_runs where site_id = '${siteId}'`)
await db.execute(`delete from build_artifacts where build_job_id in
(select id from build_jobs where site_id = '${siteId}')`)
await withImmutableCleanup(db, 'build_input_snapshots', 'build_input_snapshots_immutable', `site_id = '${siteId}'`)
await db.execute(`delete from build_jobs where site_id = '${siteId}'`)
await withImmutableCleanup(db, 'audit_logs', 'audit_logs_immutable', `site_id = '${siteId}'`)
await db.execute(`delete from site_config_revisions where site_id = '${siteId}'`)
await db.execute(`delete from route_registry where site_id = '${siteId}'`)
await db.execute(`delete from media_usages where site_id = '${siteId}'`)
await db.execute(`delete from media_assets where site_id = '${siteId}'`)
await db.execute(`delete from content_terms where site_id = '${siteId}'`)
await db.execute(`delete from terms where site_id = '${siteId}'`)
await db.execute(`delete from taxonomies where site_id = '${siteId}'`)
await db.execute(`delete from source_records where site_id = '${siteId}'`)
await db.execute(`delete from entry_locales where site_id = '${siteId}'`)
await withImmutableCleanup(db, 'content_revisions', 'content_revisions_immutable', `site_id = '${siteId}'`)
await db.execute(`delete from content_entries where site_id = '${siteId}'`)
await db.execute(`delete from import_jobs where site_id = '${siteId}'`)
await withImmutableCleanup(db, 'source_snapshots', 'source_snapshots_immutable', `site_id = '${siteId}'`)
await db.execute(`delete from source_connections where site_id = '${siteId}'`)
await db.execute(`delete from sites where id = '${siteId}'`)
}
/** 原样路径请求(fetch/URL 会规范化 '/../' 等点段——穿越用例必须走 node:http 裸发) */
function rawRequest(
port: number,
rawPath: string,
method: 'GET' | 'HEAD' = 'GET',
): Promise<{ status: number | undefined; headers: Record<string, string | string[] | undefined>; body: Buffer }> {
return new Promise((resolveP, rejectP) => {
const req = request({ host: '127.0.0.1', port, method, path: rawPath }, (res) => {
const chunks: Buffer[] = []
res.on('data', (c: Buffer) => chunks.push(c))
res.on('end', () => resolveP({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }))
})
req.on('error', rejectP)
req.end()
})
}
describe('预览服务器(真实构建产物 + 手构 manifest 扩展)', () => {
let db: Db | undefined
let work: Work | undefined
let siteId = ''
let distDir = ''
let manifest: RouteManifest | undefined
let preview: PreviewServerHandle | undefined
let port = 0
beforeAll(async () => {
db = getDb()
work = await makeWork('sf-preview')
const seed = await seedPublishedSite(db, work)
siteId = seed.siteId
const result = await createBuildJob(db, {
siteKey: seed.key,
artifactsRoot: join(work.work, 'artifacts'),
})
expect(result.status).toBe('succeeded')
expect(result.artifacts).toBeDefined()
distDir = result.artifacts!.distPath
manifest = JSON.parse(await readFile(result.artifacts!.routeManifestPath, 'utf8')) as RouteManifest
// 扩展名映射探针文件(真实构建产物外补——jpg/webp/pdf/未知扩展;行不带 content_type,
// 迫使服务端走扩展名→MIME 表)+ gone 行(真实数据暂无,§12.1 契约先行手构)
const probes: Array<{ file: string; route: string }> = [
{ file: 'mimeprobe/photo.jpg', route: '/mimeprobe/photo.jpg' },
{ file: 'mimeprobe/graph.webp', route: '/mimeprobe/graph.webp' },
{ file: 'mimeprobe/doc.pdf', route: '/mimeprobe/doc.pdf' },
{ file: 'mimeprobe/blob.xyz', route: '/mimeprobe/blob.xyz' },
]
for (const p of probes) {
await mkdir(dirname(join(distDir, p.file)), { recursive: true })
await writeFile(join(distDir, p.file), `probe-body:${p.route}\n`, 'utf8')
}
const probeRows: RouteManifestRoute[] = probes.map((p) => ({
exact_key: p.route,
query_policy: 'ignore',
query_key: null,
kind: 'asset',
target_file: p.file,
status: 200,
indexable: false,
}))
const goneRow: RouteManifestRoute = {
exact_key: '/gone-page/',
query_policy: 'ignore',
query_key: null,
kind: 'gone',
status: 410,
indexable: false,
}
// Task 8 实证形态:redirect location 含非 ASCII(全角括号/间隔号——真实 hesupipe
// 1746 redirect 全量校验发现原样发头 ERR_INVALID_CHAR 崩 500 / latin1 乱码)
const nonAsciiRedirectRow: RouteManifestRoute = {
exact_key: '/nonascii-redirect/',
query_policy: 'ignore',
query_key: null,
kind: 'redirect',
status: 301,
indexable: false,
location: '/wp-content/uploads/2024/10/HDPE-carat-pipe-(1)·.jpg',
}
// Task 8 spec 复审修补回归:非 ASCII 文件名资产——盘上/dist 是裸形文件名(文件系统
// 域不解码),manifest exact_key 须是编码线形(管线生成侧归一——浏览器对裸非 ASCII
// 一律百分号编码后发请求)。与上一行 redirect 构成真实链:跟随 Location 的编码形
// 请求必须命中本资产行(2c 断言全链 200)。
const nonAsciiAssetFile = 'wp-content/uploads/2024/10/HDPE-carat-pipe-(1)·.jpg'
await mkdir(dirname(join(distDir, nonAsciiAssetFile)), { recursive: true })
await writeFile(join(distDir, nonAsciiAssetFile), 'nonascii-asset-body\n', 'utf8')
const nonAsciiAssetRow: RouteManifestRoute = {
exact_key: '/wp-content/uploads/2024/10/HDPE-carat-pipe-%EF%BC%881%EF%BC%89%C2%B7.jpg',
query_policy: 'ignore',
query_key: null,
kind: 'asset',
target_file: `/${nonAsciiAssetFile}`,
status: 200,
content_type: 'image/jpeg',
indexable: false,
}
const extended: RouteManifest = {
...manifest!,
routes: [...manifest!.routes, ...probeRows, goneRow, nonAsciiRedirectRow, nonAsciiAssetRow],
}
preview = createPreviewServer({ distDir, routeManifest: extended, host: '127.0.0.1', port: 0 })
const addr = await preview.start()
expect(addr.address).toBe('127.0.0.1')
port = addr.port
}, 300_000)
afterAll(async () => {
await preview?.stop()
if (db !== undefined) {
try {
if (siteId !== '') await cleanupSiteRows(db, siteId)
} finally {
await (db as { $client: { end: () => Promise<void> } }).$client.end()
}
}
if (work !== undefined) await rm(work.work, { recursive: true, force: true })
})
async function get(p: string): Promise<{ status: number; headers: Headers; text: string; bytes: Buffer }> {
const res = await fetch(new URL(p, `http://127.0.0.1:${port}/`))
const ab = await res.arrayBuffer()
return {
status: res.status,
headers: res.headers,
text: new TextDecoder().decode(ab),
bytes: Buffer.from(ab),
}
}
it('1. html 行 200 + Content-Type text/html; charset=utf-8(body 逐字节 = dist 产物)', async () => {
const res = await get('/')
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toBe('text/html; charset=utf-8')
expect(res.bytes).toStrictEqual(await readFile(join(distDir, 'index.html')))
})
it('2. redirect 行 301 + Location(?attachment_id=201 查询形态命中 query_key 行)', async () => {
const res = await fetch(new URL('/?attachment_id=201', `http://127.0.0.1:${port}/`), {
redirect: 'manual',
})
expect(res.status).toBe(301)
expect(res.headers.get('location')).toBe('/wp-content/uploads/2026/10/fixture-a.png')
})
it('2b. redirect location 含非 ASCII:Location 头按 UTF-8 百分号编码发(不崩 500/不乱码);迁移页内嵌原串', async () => {
const res = await fetch(new URL('/nonascii-redirect/', `http://127.0.0.1:${port}/`), {
redirect: 'manual',
})
expect(res.status).toBe(301)
// 全角( U+FF08 → %EF%BC%88 / )U+FF09 → %EF%BC%89 / · U+00B7 → %C2%B7
expect(res.headers.get('location')).toBe(
'/wp-content/uploads/2024/10/HDPE-carat-pipe-%EF%BC%881%EF%BC%89%C2%B7.jpg',
)
const text = await res.text()
expect(text).toContain('/wp-content/uploads/2024/10/HDPE-carat-pipe-(1)·.jpg')
})
it('2c. 非 ASCII redirect → Location 编码形 → 资产终端 200(spec 复审修补回归:exact_key 编码线形命中)', async () => {
const res = await fetch(new URL('/nonascii-redirect/', `http://127.0.0.1:${port}/`), {
redirect: 'manual',
})
expect(res.status).toBe(301)
const location = res.headers.get('location')
expect(location).toBe('/wp-content/uploads/2024/10/HDPE-carat-pipe-%EF%BC%881%EF%BC%89%C2%B7.jpg')
// 跟随 Location(浏览器/重定向客户端的真实路径——发编码形请求)→ 命中资产行 → 200
const terminal = await get(location!)
expect(terminal.status).toBe(200)
expect(terminal.headers.get('content-type')).toBe('image/jpeg')
expect(terminal.bytes).toStrictEqual(Buffer.from('nonascii-asset-body\n', 'utf8'))
// 裸形直接请求(未编码客户端形态):URL 解析器编码后同键命中——不可 404
const rawForm = await get('/wp-content/uploads/2024/10/HDPE-carat-pipe-(1)·.jpg')
expect(rawForm.status).toBe(200)
expect(rawForm.bytes).toStrictEqual(Buffer.from('nonascii-asset-body\n', 'utf8'))
})
it('3. asset 行 200 + MIME:png 真实行 content_type;jpg/webp/pdf 扩展名映射;未知扩展 octet-stream', async () => {
const png = await get('/wp-content/uploads/2026/10/fixture-a.png')
expect(png.status).toBe(200)
expect(png.headers.get('content-type')).toBe('image/png')
expect(sha256of(png.bytes)).toBe(sha256of(work!.pngA))
const jpg = await get('/mimeprobe/photo.jpg')
expect(jpg.status).toBe(200)
expect(jpg.headers.get('content-type')).toBe('image/jpeg')
const webp = await get('/mimeprobe/graph.webp')
expect(webp.status).toBe(200)
expect(webp.headers.get('content-type')).toBe('image/webp')
const pdf = await get('/mimeprobe/doc.pdf')
expect(pdf.status).toBe(200)
expect(pdf.headers.get('content-type')).toBe('application/pdf')
const xyz = await get('/mimeprobe/blob.xyz')
expect(xyz.status).toBe(200)
expect(xyz.headers.get('content-type')).toBe('application/octet-stream')
})
it('4. 未命中 404 + dist/404.html 自定义页(Content-Type html)', async () => {
const res = await get('/definitely-not-a-route/')
expect(res.status).toBe(404)
expect(res.headers.get('content-type')).toBe('text/html; charset=utf-8')
expect(res.text).toStrictEqual(await readFile(join(distDir, '404.html'), 'utf8'))
})
it('5. gone 行 410 + 简短 HTML 说明(手构 manifest 行)', async () => {
const res = await get('/gone-page/')
expect(res.status).toBe(410)
expect(res.headers.get('content-type')?.startsWith('text/html')).toBe(true)
expect(res.text).toContain('410')
expect(res.text.length).toBeLessThan(1024)
})
it('6. 全响应 X-Robots-Tag: noindex, nofollow;HTML 响应 CSP(style-src 放开内联——Astro scoped 样式;asset 无 CSP)', async () => {
for (const p of ['/', '/definitely-not-a-route/', '/gone-page/', '/wp-content/uploads/2026/10/fixture-a.png']) {
const res = await get(p)
expect(res.headers.get('x-robots-tag'), p).toBe('noindex, nofollow')
}
expect((await get('/')).headers.get('content-security-policy')).toBe(
"default-src 'self'; style-src 'self' 'unsafe-inline'",
)
expect((await get('/definitely-not-a-route/')).headers.get('content-security-policy')).toBe(
"default-src 'self'; style-src 'self' 'unsafe-inline'",
)
expect((await get('/wp-content/uploads/2026/10/fixture-a.png')).headers.get('content-security-policy')).toBeNull()
})
it('7. 绑定 127.0.0.1(server.address 断言)+ host 外值拒绝(库级同步拒绝)', () => {
expect(port).toBeGreaterThan(0)
expect(
() => createPreviewServer({ distDir, routeManifest: manifest!, host: '0.0.0.0', port: 0 }),
).toThrow(/127\.0\.0\.1|localhost|回环/)
// localhost 是另一合法回环值——构造成功(不 start,无监听副作用)
expect(
createPreviewServer({ distDir, routeManifest: manifest!, host: 'localhost', port: 0 }),
).toBeTypeOf('object')
})
it('8. 路径穿越拒绝:/../、%2e%2e%2f、..%5c、反斜杠 → 400/404,不读盘外', async () => {
const attacks = [
'/../',
'/..',
'/%2e%2e%2f',
'/%2E%2E%2F',
'/..%5c',
'/%2e%2e%5c',
'/\\',
'/\\..\\..',
'/wp-content/uploads/2016/../../../route-manifest.json',
'/%2e%2e/route-manifest.json',
]
for (const a of attacks) {
const res = await rawRequest(port, a)
expect([400, 404], `攻击路径 ${a} 必须被拒绝(不读盘外)`).toContain(res.status)
// 不读盘外:响应体绝不包含 route-manifest.json 的内容标记(dist 上一级的文件)
expect(res.body.toString('utf8')).not.toContain('generated_from_route_snapshot_hash')
}
})
it('9. 目录列表禁用:dist 子目录请求 → 404 且不出文件清单', async () => {
for (const d of ['/wp-content/uploads/2026/10/', '/wp-content/', '/mimeprobe/']) {
const res = await get(d)
expect(res.status, d).toBe(404)
// 不是目录清单:响应是 404 页(自定义页在场时逐字节等于 404.html),不含目录内文件名
expect(res.text).not.toContain('fixture-a.png')
expect(res.text).not.toContain('fixture-b.png')
}
})
it('10. HEAD 方法支持:200 同头无 body', async () => {
const res = await rawRequest(port, '/', 'HEAD')
expect(res.status).toBe(200)
expect(res.headers['content-type']).toBe('text/html; charset=utf-8')
expect(res.headers['x-robots-tag']).toBe('noindex, nofollow')
expect(res.body.byteLength).toBe(0)
})
it('11. CLI host 外值拒绝(tsx 直跑 sf.ts:校验先于 DB 访问,stderr 单行拒绝)', { timeout: 60_000 }, async () => {
const outcome = await new Promise<{ err: Error | null; stderr: string }>((resolveP) => {
execFile(
process.execPath,
[
TSX_CLI,
join(REPO_ROOT, 'scripts', 'sf.ts'),
'preview:start',
'--build',
'00000000-0000-0000-0000-000000000000',
'--host',
'0.0.0.0',
],
{ cwd: REPO_ROOT },
(err, _stdout, stderr) => resolveP({ err, stderr: String(stderr) }),
)
})
// 非零退出(拒绝)+ 单行错误信息带外值与回环纪律
expect(outcome.err).toBeInstanceOf(Error)
expect(outcome.stderr).toContain('--host')
expect(outcome.stderr).toContain('0.0.0.0')
expect(outcome.stderr).toMatch(/127\.0\.0\.1|localhost|回环/)
})
it('12. CLI happy path:preview:start 真实 job → 监听可服务 → 关停(子进程全链冒烟)', { timeout: 90_000 }, async () => {
expect(manifest).toBeDefined()
const jobId = manifest!.build_job_id
const child = spawn(
process.execPath,
[TSX_CLI, join(REPO_ROOT, 'scripts', 'sf.ts'), 'preview:start', '--build', jobId, '--port', '0', '--quiet'],
{ cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'pipe'] },
)
let stdout = ''
child.stdout.on('data', (c: Buffer) => {
stdout += c.toString('utf8')
})
child.stderr.on('data', () => {}) // 排水:避免 stderr 管道塞满吊住子进程
try {
// 轮询等 listening 行(port=0 实际端口从中解析)
let cliPort = 0
const deadline = Date.now() + 60_000
while (Date.now() < deadline) {
const m = /listening: http:\/\/127\.0\.0\.1:(\d+)\//.exec(stdout)
if (m !== null) {
cliPort = Number(m[1])
break
}
await new Promise((r) => setTimeout(r, 250))
}
expect(cliPort, `CLI 未输出 listening 行(stdout=${stdout})`).toBeGreaterThan(0)
// 真实 http 服务可用:首页 200 + noindex 头(DB 已断池——纯 dist+manifest 服务)
const res = await fetch(`http://127.0.0.1:${cliPort}/`)
expect(res.status).toBe(200)
expect(res.headers.get('x-robots-tag')).toBe('noindex, nofollow')
expect(await res.text()).toStrictEqual(await readFile(join(distDir, 'index.html'), 'utf8'))
// --quiet 透传:服务过请求后子进程 stdout 仍无逐请求访问日志行
await new Promise((r) => setTimeout(r, 500))
expect(stdout).not.toMatch(/\[preview\] (GET|HEAD)/)
} finally {
child.kill()
await new Promise<void>((resolveP) => {
child.once('exit', () => resolveP())
setTimeout(() => {
child.kill('SIGKILL')
resolveP()
}, 10_000).unref()
})
}
})
it('13. quiet 开关:quiet:true 静默逐请求日志;缺省 verbose 逐请求一行', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
try {
const previewLines = (): number => logSpy.mock.calls.filter((c) => String(c[0]).includes('[preview] ')).length
// quiet 实例:请求零访问日志
const quietServer = createPreviewServer({ distDir, routeManifest: manifest!, port: 0, quiet: true })
const addr = await quietServer.start()
try {
expect((await fetch(`http://127.0.0.1:${addr.port}/`)).status).toBe(200)
expect(previewLines()).toBe(0)
} finally {
await quietServer.stop()
}
// 缺省 verbose 对照(beforeAll 主服务):请求恰一行访问日志
const before = previewLines()
await get('/')
expect(previewLines()).toBe(before + 1)
} finally {
logSpy.mockRestore()
}
})
})