2026-10-22 13:29:30 +08:00
|
|
|
|
// 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)
|
2026-10-22 13:58:15 +08:00
|
|
|
|
// 12. CLI happy path:真实 job 子进程全链(job 定位 → manifest 读 → 监听可服务 → 关停;
|
|
|
|
|
|
// --quiet 透传——子进程 stdout 无逐请求访问日志)
|
|
|
|
|
|
// 13. quiet 开关:quiet:true 静默逐请求日志;缺省(verbose)逐请求一行 stdout
|
2026-10-22 13:29:30 +08:00
|
|
|
|
import { execFile, spawn } from 'node:child_process'
|
|
|
|
|
|
import { createHash } from 'node:crypto'
|
2026-10-22 15:55:43 +08:00
|
|
|
|
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
2026-10-22 13:29:30 +08:00
|
|
|
|
import { request } from 'node:http'
|
|
|
|
|
|
import { dirname, join, resolve } from 'node:path'
|
|
|
|
|
|
import { fileURLToPath } from 'node:url'
|
2026-10-22 13:58:15 +08:00
|
|
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
2026-10-22 13:29:30 +08:00
|
|
|
|
import type { RouteManifest, RouteManifestRoute } from '@sf/contracts'
|
|
|
|
|
|
import { createBuildJob } from '../../apps/api/src/modules/build/build.service.js'
|
|
|
|
|
|
import { getDb } from '../../apps/api/src/db/client.js'
|
2026-10-22 15:55:43 +08:00
|
|
|
|
import { cleanupSiteRows, makeWork, seedPublishedSite, type Db, type Work } from './helpers.js'
|
2026-10-22 13:29:30 +08:00
|
|
|
|
import { createPreviewServer, type PreviewServerHandle } from '../../apps/preview-server/server.mjs'
|
|
|
|
|
|
|
|
|
|
|
|
/** 本文件位于 <repo>/tests/integration/ → 上两级是仓库根 */
|
|
|
|
|
|
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
|
|
|
|
|
|
const TSX_CLI = join(REPO_ROOT, 'node_modules', 'tsx', 'dist', 'cli.mjs')
|
|
|
|
|
|
|
|
|
|
|
|
function sha256of(buf: Buffer | string): string {
|
|
|
|
|
|
return createHash('sha256').update(buf).digest('hex')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 原样路径请求(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')
|
2026-10-22 15:55:43 +08:00
|
|
|
|
const seed = await seedPublishedSite(db, work, 'sfpreview')
|
2026-10-22 13:29:30 +08:00
|
|
|
|
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,
|
|
|
|
|
|
}
|
2026-10-22 14:33:55 +08:00
|
|
|
|
// 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',
|
|
|
|
|
|
}
|
2026-10-22 15:31:14 +08:00
|
|
|
|
// 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,
|
|
|
|
|
|
}
|
2026-10-22 14:33:55 +08:00
|
|
|
|
const extended: RouteManifest = {
|
|
|
|
|
|
...manifest!,
|
2026-10-22 15:31:14 +08:00
|
|
|
|
routes: [...manifest!.routes, ...probeRows, goneRow, nonAsciiRedirectRow, nonAsciiAssetRow],
|
2026-10-22 14:33:55 +08:00
|
|
|
|
}
|
2026-10-22 13:29:30 +08:00
|
|
|
|
|
|
|
|
|
|
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')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-10-22 14:33:55 +08:00
|
|
|
|
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')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-10-22 15:31:14 +08:00
|
|
|
|
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'))
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-10-22 13:29:30 +08:00
|
|
|
|
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)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-10-22 14:33:55 +08:00
|
|
|
|
it('6. 全响应 X-Robots-Tag: noindex, nofollow;HTML 响应 CSP(style-src 放开内联——Astro scoped 样式;asset 无 CSP)', async () => {
|
2026-10-22 13:29:30 +08:00
|
|
|
|
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')
|
|
|
|
|
|
}
|
2026-10-22 14:33:55 +08:00
|
|
|
|
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'",
|
|
|
|
|
|
)
|
2026-10-22 13:29:30 +08:00
|
|
|
|
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,
|
2026-10-22 13:58:15 +08:00
|
|
|
|
[TSX_CLI, join(REPO_ROOT, 'scripts', 'sf.ts'), 'preview:start', '--build', jobId, '--port', '0', '--quiet'],
|
2026-10-22 13:29:30 +08:00
|
|
|
|
{ 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'))
|
2026-10-22 13:58:15 +08:00
|
|
|
|
// --quiet 透传:服务过请求后子进程 stdout 仍无逐请求访问日志行
|
|
|
|
|
|
await new Promise((r) => setTimeout(r, 500))
|
|
|
|
|
|
expect(stdout).not.toMatch(/\[preview\] (GET|HEAD)/)
|
2026-10-22 13:29:30 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
child.kill()
|
|
|
|
|
|
await new Promise<void>((resolveP) => {
|
|
|
|
|
|
child.once('exit', () => resolveP())
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
child.kill('SIGKILL')
|
|
|
|
|
|
resolveP()
|
|
|
|
|
|
}, 10_000).unref()
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-10-22 13:58:15 +08:00
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-10-22 13:29:30 +08:00
|
|
|
|
})
|