1071 lines
42 KiB
TypeScript
1071 lines
42 KiB
TypeScript
/**
|
|
* Headless audit of the SHIPPED GAME BUNDLE (`dist-game/`).
|
|
*
|
|
* WHAT THIS AUDITS, AND WHY IT IS `dist-game/` AND NOT A DEV SERVER
|
|
* -----------------------------------------------------------------
|
|
* The Zero-Runtime-MPQ invariant (Iron Law #2) constrains the artifact that
|
|
* actually ships. Auditing a dev server would prove "no MPQ requests in
|
|
* development form" while the production bundle could still contain the
|
|
* `httpRangeSource('/d2char.mpq')` path — a fully green report over a violated
|
|
* invariant, which is the worst available false negative. So this harness
|
|
* serves the real built output over a plain `node:http` static server and
|
|
* never transforms a source file.
|
|
*
|
|
* The static server is deliberately dependency-free (`node:http`, not vite):
|
|
* the dependency tree is part of what is under test, and if it were also the
|
|
* test carrier then "dependencies broke" would masquerade as "no requests were
|
|
* made".
|
|
*
|
|
* This pairs with `verify-bundle-no-mpq.ts`:
|
|
*
|
|
* bundle grep -> the bundle *cannot* download an archive (structural)
|
|
* this harness -> the bundle *did not* download one while being played
|
|
*
|
|
* Neither alone is sufficient. The grep cannot see a runtime fetch assembled
|
|
* from fragments; the runtime count cannot see a path that this particular run
|
|
* did not reach.
|
|
*
|
|
* ANTI-VACUITY: THE POINT OF THE `precondition` FIELD
|
|
* ---------------------------------------------------
|
|
* Most of these assertions are of the form "this counter is zero". Such an
|
|
* assertion passes trivially when the thing being counted never had a chance to
|
|
* happen: `missingMonsterArt.length === 0` is equally true on a level that
|
|
* rendered no monsters at all. A harness that reports success in that state is
|
|
* worse than no harness, because it manufactures confidence.
|
|
*
|
|
* So every assertion carries preconditions that establish the measurement was
|
|
* live — the scene rendered, the sim ticked, monsters were planned, the
|
|
* character draw path ran. An assertion whose preconditions are unmet is
|
|
* reported as **VACUOUS** and counts as a harness FAILURE, never as a pass.
|
|
*
|
|
* `act-scene.ts:254` is the cautionary tale: `state.characterGroup` is declared
|
|
* and published but never written, so any audit asserting on it would have been
|
|
* asserting on a constant. This harness checks that trap explicitly.
|
|
*
|
|
* NEGATIVE CONTROLS
|
|
* -----------------
|
|
* An assertion never observed failing is not evidence. `--negative=<id>` injects
|
|
* a specific, real fault and the run is then expected to go red on exactly the
|
|
* targeted assertion. See NEGATIVE_CONTROLS below.
|
|
*
|
|
* npm run verify:animation # audit current dist-game/
|
|
* npm run verify:animation -- --negative=list # show the controls
|
|
* npm run verify:animation -- --negative=mpq-request
|
|
*
|
|
* OUTPUT PATH SAFETY
|
|
* ------------------
|
|
* `scripts/verify-challenger-m3.ts` L28-29 hardcodes an output directory into a
|
|
* different worktree and writes screenshots there (L155/L159). That worktree is
|
|
* off-limits and is actively being worked in by another team. Every path this
|
|
* script writes is therefore passed through `assertWritablePath`, which throws
|
|
* unless the resolved path is inside this worktree. It is a guard rather than a
|
|
* convention so it cannot regress silently.
|
|
*/
|
|
|
|
import { spawn } from 'node:child_process'
|
|
import { createServer } from 'node:http'
|
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
import { createHash } from 'node:crypto'
|
|
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
import { extname, join, relative, resolve } from 'node:path'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Paths
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Repo root. This script lives in `<root>/scripts`. */
|
|
const ROOT = resolve(process.cwd())
|
|
const DIST_DIR = join(ROOT, 'dist-game')
|
|
const PACKS_DIR = join(ROOT, 'samples', 'd2-packs')
|
|
const SRC_DIR = join(ROOT, 'src')
|
|
|
|
/**
|
|
* Refuse to write anywhere outside this worktree.
|
|
*
|
|
* The forked-from script wrote screenshots into a sibling worktree that is
|
|
* explicitly off-limits and currently has another team's processes running in
|
|
* it. Enforcing this in code, on every write, is the only version of that rule
|
|
* that cannot rot.
|
|
*/
|
|
function assertWritablePath(candidate: string): string {
|
|
const full = resolve(candidate)
|
|
const rel = relative(ROOT, full)
|
|
if (rel.startsWith('..') || resolve(rel) === rel) {
|
|
throw new Error(
|
|
`verify-animation-browser: refusing to write outside the worktree.\n` +
|
|
` requested: ${full}\n worktree : ${ROOT}`,
|
|
)
|
|
}
|
|
return full
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CLI
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface Options {
|
|
readonly negative: string
|
|
readonly outDir: string
|
|
readonly keepOpenMs: number
|
|
}
|
|
|
|
function parseArgs(argv: readonly string[]): Options {
|
|
let negative = ''
|
|
let outDir = join(ROOT, '.agents', 'worker_mv', 'evidence')
|
|
let keepOpenMs = 0
|
|
for (const arg of argv) {
|
|
if (arg.startsWith('--negative=')) negative = arg.slice('--negative='.length)
|
|
else if (arg.startsWith('--out=')) outDir = resolve(arg.slice('--out='.length))
|
|
else if (arg.startsWith('--keep-open-ms=')) keepOpenMs = Number(arg.slice('--keep-open-ms='.length))
|
|
}
|
|
return { negative, outDir, keepOpenMs }
|
|
}
|
|
|
|
/**
|
|
* The deliberate faults. Each names the assertion it must turn red.
|
|
*
|
|
* `page-*` controls run inside the page; `artifact-*` controls would require
|
|
* mutating `dist-game/`, which is done by the separate bundle-gate controls
|
|
* rather than here, so that this harness never writes to the build output.
|
|
*/
|
|
const NEGATIVE_CONTROLS: Readonly<Record<string, string>> = {
|
|
'mpq-request': 'page fetches a *.mpq URL -> must turn ZERO_MPQ_DLL_REQUESTS red',
|
|
'dll-request': 'page fetches a *.dll URL -> must turn ZERO_MPQ_DLL_REQUESTS red',
|
|
'console-error': 'page emits console.error -> must turn ZERO_CONSOLE_ERRORS red',
|
|
'missing-art': 'push a fake id into missingMonsterArt -> must turn NO_RED_PLACEHOLDER_SQUARES red',
|
|
'monster-art-error': 'bump monsterArtErrors -> must turn NO_RED_PLACEHOLDER_SQUARES red',
|
|
'freeze-facing': 'ignore facing writes -> must turn EIGHT_DIRECTIONS_EXERCISED red',
|
|
'broken-page': 'navigate to a URL that does not exist -> must turn SCENE_BOOTED red',
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Static server for the built artifact
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const MIME: Readonly<Record<string, string>> = {
|
|
'.html': 'text/html; charset=utf-8',
|
|
'.js': 'text/javascript; charset=utf-8',
|
|
'.mjs': 'text/javascript; charset=utf-8',
|
|
'.css': 'text/css; charset=utf-8',
|
|
'.json': 'application/json; charset=utf-8',
|
|
'.png': 'image/png',
|
|
'.jpg': 'image/jpeg',
|
|
'.gif': 'image/gif',
|
|
'.svg': 'image/svg+xml',
|
|
'.wasm': 'application/wasm',
|
|
'.map': 'application/json; charset=utf-8',
|
|
'.r8': 'application/octet-stream',
|
|
'.bin': 'application/octet-stream',
|
|
'.dat': 'application/octet-stream',
|
|
'.mp3': 'audio/mpeg',
|
|
'.wav': 'audio/wav',
|
|
'.ogg': 'audio/ogg',
|
|
}
|
|
|
|
interface ServedRequest {
|
|
readonly url: string
|
|
readonly status: number
|
|
}
|
|
|
|
/**
|
|
* Serve the production layout.
|
|
*
|
|
* The game is built with `--base=/diablo2/`, so the bundle must live under
|
|
* `/diablo2/`. `DEFAULT_PACKS` (`act-scene.ts`) resolves its second entry to
|
|
* `/diablo2/packs`, so the baked packs are mounted there — this reproduces the
|
|
* deployed shape rather than inventing one.
|
|
*
|
|
* Note what is deliberately NOT mounted: nothing serves `samples/d2`, so no
|
|
* `*.mpq` is reachable. That does not weaken the audit — a forbidden request is
|
|
* counted when it is *issued*, regardless of the response — and it matches a
|
|
* real deployment, where the archives are not published.
|
|
*/
|
|
function createStaticServer(served: ServedRequest[]): ReturnType<typeof createServer> {
|
|
const mounts: readonly { readonly prefix: string; readonly dir: string }[] = [
|
|
{ prefix: '/diablo2/samples/d2-packs/', dir: PACKS_DIR },
|
|
{ prefix: '/diablo2/packs/', dir: PACKS_DIR },
|
|
{ prefix: '/diablo2/', dir: DIST_DIR },
|
|
{ prefix: '/packs/', dir: PACKS_DIR },
|
|
{ prefix: '/', dir: DIST_DIR },
|
|
]
|
|
|
|
return createServer((req: IncomingMessage, res: ServerResponse) => {
|
|
const rawUrl = req.url ?? '/'
|
|
const path = decodeURIComponent(rawUrl.split('?')[0] ?? '/')
|
|
|
|
const finish = (status: number, body: Buffer | string, type: string): void => {
|
|
served.push({ url: rawUrl, status })
|
|
res.writeHead(status, { 'content-type': type, 'cache-control': 'no-store' })
|
|
res.end(body)
|
|
}
|
|
|
|
if (path === '/favicon.ico') {
|
|
finish(204, '', 'image/x-icon')
|
|
return
|
|
}
|
|
|
|
if (path === '/' || path === '/index.html') {
|
|
finish(302, '', 'text/plain')
|
|
return
|
|
}
|
|
|
|
for (const mount of mounts) {
|
|
if (!path.startsWith(mount.prefix)) continue
|
|
const rest = path.slice(mount.prefix.length)
|
|
// Path traversal guard: the resolved file must stay inside the mount.
|
|
const target = resolve(mount.dir, rest)
|
|
if (!target.startsWith(mount.dir)) {
|
|
finish(403, 'forbidden', 'text/plain')
|
|
return
|
|
}
|
|
if (!existsSync(target) || !statSync(target).isFile()) continue
|
|
const body = readFileSync(target)
|
|
finish(200, body, MIME[extname(target).toLowerCase()] ?? 'application/octet-stream')
|
|
return
|
|
}
|
|
|
|
finish(404, 'not found', 'text/plain')
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Assertion framework
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type Verdict = 'PASS' | 'FAIL' | 'VACUOUS'
|
|
|
|
interface Assertion {
|
|
readonly id: string
|
|
/** What acceptance-criterion text this maps to. */
|
|
readonly criterion: string
|
|
readonly verdict: Verdict
|
|
readonly detail: string
|
|
/** Precondition results, so a reader can see the measurement was live. */
|
|
readonly preconditions: readonly { readonly name: string; readonly met: boolean; readonly value: string }[]
|
|
/**
|
|
* True when the assertion is expected to be red on current `main` because it
|
|
* documents a pre-existing defect another milestone owns. Reported loudly and
|
|
* excluded from the exit code, so a genuine regression is not buried under a
|
|
* known baseline. Turning green is reported as "baseline improved".
|
|
*/
|
|
readonly knownRedBaseline?: string
|
|
}
|
|
|
|
class AssertionLog {
|
|
readonly items: Assertion[] = []
|
|
|
|
add(a: Assertion): void {
|
|
this.items.push(a)
|
|
}
|
|
|
|
/**
|
|
* Build an assertion, evaluating preconditions first.
|
|
*
|
|
* If any precondition is unmet the verdict is VACUOUS regardless of the
|
|
* predicate, because a green from an unexercised code path is not evidence.
|
|
*/
|
|
check(args: {
|
|
id: string
|
|
criterion: string
|
|
preconditions: readonly { name: string; met: boolean; value: string }[]
|
|
pass: boolean
|
|
detail: string
|
|
knownRedBaseline?: string
|
|
}): void {
|
|
const unmet = args.preconditions.filter(p => !p.met)
|
|
const verdict: Verdict = unmet.length > 0 ? 'VACUOUS' : args.pass ? 'PASS' : 'FAIL'
|
|
const detail =
|
|
unmet.length > 0
|
|
? `${args.detail} | VACUOUS: preconditions unmet -> ${unmet.map(p => `${p.name}=${p.value}`).join(', ')}`
|
|
: args.detail
|
|
this.add({
|
|
id: args.id,
|
|
criterion: args.criterion,
|
|
verdict,
|
|
detail,
|
|
preconditions: args.preconditions,
|
|
...(args.knownRedBaseline === undefined ? {} : { knownRedBaseline: args.knownRedBaseline }),
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CDP plumbing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms))
|
|
|
|
interface NetworkRequestLog {
|
|
url: string
|
|
method: string
|
|
resourceType: string
|
|
}
|
|
|
|
interface ConsoleErrorLog {
|
|
source: string
|
|
text: string
|
|
}
|
|
|
|
interface CdpSession {
|
|
send: <T = unknown>(method: string, params?: Record<string, unknown>, timeoutMs?: number) => Promise<T>
|
|
evalJs: <T = unknown>(expression: string) => Promise<T>
|
|
close: () => void
|
|
}
|
|
|
|
async function connectCdp(debugPort: number, onEvent: (msg: Record<string, any>) => void): Promise<CdpSession> {
|
|
let wsUrl: string | null = null
|
|
let lastErr = 'none'
|
|
for (let i = 0; i < 80; i++) {
|
|
await sleep(200)
|
|
try {
|
|
const res = await fetch(`http://127.0.0.1:${debugPort}/json/list`)
|
|
const pages = (await res.json()) as { type: string; webSocketDebuggerUrl?: string }[]
|
|
const page = pages.find(p => p.type === 'page')
|
|
if (page?.webSocketDebuggerUrl !== undefined) {
|
|
wsUrl = page.webSocketDebuggerUrl
|
|
break
|
|
}
|
|
lastErr = 'no page target yet'
|
|
} catch (err) {
|
|
// Bounded retry while Chrome boots. This is not a silent swallow: the
|
|
// last error is retained and thrown if the loop never succeeds.
|
|
lastErr = err instanceof Error ? err.message : String(err)
|
|
}
|
|
}
|
|
if (wsUrl === null) throw new Error(`could not reach Chrome CDP endpoint (last error: ${lastErr})`)
|
|
|
|
const ws = new WebSocket(wsUrl)
|
|
await new Promise<void>((res, rej) => {
|
|
ws.onopen = () => res()
|
|
ws.onerror = () => rej(new Error('CDP WebSocket failed to open'))
|
|
})
|
|
|
|
let idCounter = 1
|
|
const send = <T = unknown>(
|
|
method: string,
|
|
params: Record<string, unknown> = {},
|
|
timeoutMs = 20000,
|
|
): Promise<T> =>
|
|
new Promise<T>((res, rej) => {
|
|
const id = idCounter++
|
|
const timer = setTimeout(() => {
|
|
ws.removeEventListener('message', handler)
|
|
rej(new Error(`CDP ${method} timed out after ${timeoutMs}ms`))
|
|
}, timeoutMs)
|
|
const handler = (event: MessageEvent): void => {
|
|
const msg = JSON.parse(String(event.data)) as Record<string, any>
|
|
if (msg['id'] === id) {
|
|
clearTimeout(timer)
|
|
ws.removeEventListener('message', handler)
|
|
if (msg['error'] !== undefined) rej(new Error(`CDP ${method}: ${JSON.stringify(msg['error'])}`))
|
|
else res(msg['result'] as T)
|
|
}
|
|
}
|
|
ws.addEventListener('message', handler)
|
|
ws.send(JSON.stringify({ id, method, params }))
|
|
})
|
|
|
|
ws.addEventListener('message', (event: MessageEvent) => {
|
|
onEvent(JSON.parse(String(event.data)) as Record<string, any>)
|
|
})
|
|
|
|
const evalJs = async <T = unknown>(expression: string): Promise<T> => {
|
|
const res = await send<{ result: { value: T }; exceptionDetails?: unknown }>('Runtime.evaluate', {
|
|
expression,
|
|
awaitPromise: true,
|
|
returnByValue: true,
|
|
})
|
|
if (res.exceptionDetails !== undefined) {
|
|
throw new Error(`in-page JS threw: ${JSON.stringify(res.exceptionDetails)}\n expression: ${expression}`)
|
|
}
|
|
return res.result.value
|
|
}
|
|
|
|
return { send, evalJs, close: () => ws.close() }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Scene state shape (the subset this audit reads)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface SceneSnapshot {
|
|
present: boolean
|
|
ready: boolean
|
|
error: string | null
|
|
frames: number
|
|
tick: number
|
|
character: boolean
|
|
characterFrames: number
|
|
characterGroup: number
|
|
missingMonsterArt: string[]
|
|
monsterArtErrors: number
|
|
monsterArtLayerFailures: number
|
|
monstersPlanned: number
|
|
facing: number
|
|
x: number
|
|
y: number
|
|
npcs: number
|
|
playerDrawRect?: { x: number; y: number; w: number; h: number } | null
|
|
weaponClass?: string | null
|
|
playerClip?: string | null
|
|
}
|
|
|
|
const SNAPSHOT_EXPR = `(() => {
|
|
const s = window.__d2webAct
|
|
if (!s) return { present: false }
|
|
return {
|
|
present: true,
|
|
ready: !!s.ready,
|
|
error: s.error ?? null,
|
|
frames: s.frames ?? -1,
|
|
tick: s.tick ?? -1,
|
|
character: !!s.character,
|
|
characterFrames: s.characterFrames ?? -1,
|
|
characterGroup: s.characterGroup ?? -999,
|
|
missingMonsterArt: Array.isArray(s.missingMonsterArt) ? s.missingMonsterArt.slice() : null,
|
|
monsterArtErrors: s.monsterArtErrors ?? -1,
|
|
monsterArtLayerFailures: s.monsterArtLayerFailures ?? -1,
|
|
monstersPlanned: s.monstersPlanned ?? -1,
|
|
facing: s.facing ?? -1,
|
|
x: s.x ?? -1,
|
|
y: s.y ?? -1,
|
|
npcs: s.npcs ?? -1,
|
|
playerDrawRect: s.playerDrawRect ? { x: s.playerDrawRect.x, y: s.playerDrawRect.y, w: s.playerDrawRect.w, h: s.playerDrawRect.h } : null,
|
|
weaponClass: s.weaponClass ?? null,
|
|
playerClip: s.playerClip ?? null,
|
|
}
|
|
})()`
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function main(): Promise<void> {
|
|
const opts = parseArgs(process.argv.slice(2))
|
|
|
|
if (opts.negative === 'list') {
|
|
console.log('Negative controls:')
|
|
for (const [id, what] of Object.entries(NEGATIVE_CONTROLS)) console.log(` --negative=${id.padEnd(20)} ${what}`)
|
|
process.exit(0)
|
|
}
|
|
if (opts.negative !== '' && NEGATIVE_CONTROLS[opts.negative] === undefined) {
|
|
console.error(`Unknown negative control: ${opts.negative}`)
|
|
console.error(`Known: ${Object.keys(NEGATIVE_CONTROLS).join(', ')}`)
|
|
process.exit(2)
|
|
}
|
|
|
|
console.log('======================================================================')
|
|
console.log('HEADLESS AUDIT of the SHIPPED BUNDLE (dist-game/)')
|
|
if (opts.negative !== '') console.log(`NEGATIVE CONTROL ACTIVE: ${opts.negative} — ${NEGATIVE_CONTROLS[opts.negative]}`)
|
|
console.log('======================================================================')
|
|
|
|
// --- Precondition: a real, fresh build exists -----------------------------
|
|
// Auditing a missing or stale artifact is the silent-nothing failure mode.
|
|
assertBuildPresentAndFresh()
|
|
|
|
const outDir = assertWritablePath(opts.negative === '' ? opts.outDir : join(opts.outDir, `negative-${opts.negative}`))
|
|
mkdirSync(outDir, { recursive: true })
|
|
console.log(`Evidence directory: ${outDir}`)
|
|
|
|
const log = new AssertionLog()
|
|
const served: ServedRequest[] = []
|
|
const networkRequests: NetworkRequestLog[] = []
|
|
const consoleErrors: ConsoleErrorLog[] = []
|
|
|
|
const server = createStaticServer(served)
|
|
await new Promise<void>(r => server.listen(0, '127.0.0.1', r))
|
|
const addr = server.address()
|
|
const port = typeof addr === 'object' && addr !== null ? addr.port : 0
|
|
const baseUrl = `http://127.0.0.1:${port}`
|
|
console.log(`Static server (node:http, serving dist-game/): ${baseUrl}/diablo2/`)
|
|
|
|
const debugPort = 9246
|
|
const chrome = spawn('/usr/bin/google-chrome', [
|
|
'--headless=new',
|
|
`--remote-debugging-port=${debugPort}`,
|
|
'--no-sandbox',
|
|
'--disable-dev-shm-usage',
|
|
'--enable-webgl',
|
|
'--ignore-gpu-blocklist',
|
|
'--use-gl=angle',
|
|
'--use-angle=swiftshader',
|
|
'--window-size=1280,840',
|
|
'about:blank',
|
|
])
|
|
|
|
let exitCode = 0
|
|
try {
|
|
const cdp = await connectCdp(debugPort, msg => {
|
|
const method = msg['method'] as string | undefined
|
|
if (method === 'Network.requestWillBeSent') {
|
|
const req = msg['params'].request as { url: string; method: string }
|
|
networkRequests.push({ url: req.url, method: req.method, resourceType: String(msg['params'].type) })
|
|
} else if (method === 'Runtime.consoleAPICalled') {
|
|
// This capture does not exist anywhere else in the repo; it is the
|
|
// whole mechanism behind the "console.error count is 0" criterion.
|
|
if (msg['params'].type === 'error') {
|
|
const args = (msg['params'].args ?? []) as { value?: unknown; description?: string }[]
|
|
const text = args.map(a => String(a.value ?? a.description ?? '')).join(' ')
|
|
consoleErrors.push({ source: 'console.error', text })
|
|
}
|
|
} else if (method === 'Runtime.exceptionThrown') {
|
|
const d = msg['params'].exceptionDetails as { text?: string; exception?: { description?: string } }
|
|
consoleErrors.push({ source: 'uncaught', text: d.exception?.description ?? d.text ?? 'unknown exception' })
|
|
} else if (method === 'Log.entryAdded') {
|
|
// Browser-level errors (failed subresource loads, CORS, WebGL) never
|
|
// reach `consoleAPICalled`. Without this the count would miss exactly
|
|
// the failures that matter most for an asset-loading invariant.
|
|
const e = msg['params'].entry as { level: string; text: string; source: string }
|
|
if (e.level === 'error') consoleErrors.push({ source: `log:${e.source}`, text: e.text })
|
|
}
|
|
})
|
|
|
|
await cdp.send('Page.enable')
|
|
await cdp.send('Runtime.enable')
|
|
await cdp.send('Network.enable')
|
|
await cdp.send('Log.enable')
|
|
|
|
const saveScreenshot = async (filename: string): Promise<string> => {
|
|
const shot = await cdp.send<{ data: string }>('Page.captureScreenshot', { format: 'png' })
|
|
const filePath = assertWritablePath(join(outDir, filename))
|
|
const buf = Buffer.from(shot.data, 'base64')
|
|
writeFileSync(filePath, buf)
|
|
console.log(` screenshot: ${filename} (${buf.length} bytes)`)
|
|
return filePath
|
|
}
|
|
|
|
// --- Navigate -----------------------------------------------------------
|
|
const targetUrl =
|
|
opts.negative === 'broken-page'
|
|
? `${baseUrl}/diablo2/this-page-does-not-exist.html`
|
|
: `${baseUrl}/diablo2/acts.html?act=1&level=15-act-1-cave-4-treasure-caveroom4`
|
|
console.log(`\n--- Navigating to ${targetUrl}`)
|
|
await cdp.send('Page.navigate', { url: targetUrl })
|
|
|
|
// The freeze-facing control must be installed before the scene can be
|
|
// driven, so it goes in as soon as the document exists.
|
|
if (opts.negative === 'freeze-facing') {
|
|
await sleep(1500)
|
|
await cdp.evalJs(`(() => { window.__mvFreezeFacing = true; return 1 })()`)
|
|
}
|
|
|
|
let snap: SceneSnapshot = { present: false } as SceneSnapshot
|
|
for (let i = 0; i < 150; i++) {
|
|
snap = await cdp.evalJs<SceneSnapshot>(SNAPSHOT_EXPR)
|
|
if (snap.present && (snap.ready || snap.error !== null)) break
|
|
await sleep(400)
|
|
}
|
|
|
|
console.log(` scene present=${snap.present} ready=${snap.ready} error=${String(snap.error)}`)
|
|
|
|
log.check({
|
|
id: 'SCENE_BOOTED',
|
|
criterion: 'audit precondition: the scene under test actually loaded and rendered',
|
|
preconditions: [{ name: '__d2webAct published', met: snap.present === true, value: String(snap.present) }],
|
|
pass: snap.ready === true && snap.error === null,
|
|
detail: `ready=${snap.ready} error=${String(snap.error)} frames=${snap.frames} tick=${snap.tick}`,
|
|
})
|
|
|
|
if (snap.present && snap.ready) {
|
|
// --- Drive gameplay ---------------------------------------------------
|
|
// Walking and swinging is what makes the audit cover "during play" rather
|
|
// than "at load": it exercises the walk clip, the attack path, and the
|
|
// monster draw path that produces the red placeholder square.
|
|
console.log('\n--- Driving gameplay (walk + attack)')
|
|
await cdp.evalJs(`(() => {
|
|
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
|
const press = (code, down) =>
|
|
window.dispatchEvent(new KeyboardEvent(down ? 'keydown' : 'keyup', { code, bubbles: true }))
|
|
window.__mvDrive = (async () => {
|
|
const dirs = ['ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp']
|
|
for (let round = 0; round < 4; round += 1) {
|
|
const dir = dirs[round % dirs.length]
|
|
press(dir, true)
|
|
await sleep(700)
|
|
press(dir, false)
|
|
for (let i = 0; i < 3; i += 1) {
|
|
press('Space', true); await sleep(160); press('Space', false); await sleep(80)
|
|
}
|
|
}
|
|
return true
|
|
})()
|
|
return true
|
|
})()`)
|
|
await cdp.evalJs(`window.__mvDrive`)
|
|
await sleep(500)
|
|
|
|
const played = await cdp.evalJs<SceneSnapshot>(SNAPSHOT_EXPR)
|
|
console.log(
|
|
` after play: frames=${played.frames} tick=${played.tick} characterFrames=${played.characterFrames} ` +
|
|
`monstersPlanned=${played.monstersPlanned} missingMonsterArt=${JSON.stringify(played.missingMonsterArt)}`,
|
|
)
|
|
|
|
// --- Inject the requested fault --------------------------------------
|
|
await injectNegativeControl(cdp, opts.negative)
|
|
await sleep(600)
|
|
|
|
// --- Assertions -------------------------------------------------------
|
|
const live = await cdp.evalJs<SceneSnapshot>(SNAPSHOT_EXPR)
|
|
const rendered = { name: 'frames rendered', met: live.frames > 0, value: String(live.frames) }
|
|
const ticked = { name: 'sim ticked', met: live.tick > 0, value: String(live.tick) }
|
|
|
|
// 1. Zero runtime MPQ / DLL requests.
|
|
const forbidden = networkRequests.filter(r => {
|
|
const u = r.url.toLowerCase().split('?')[0] ?? ''
|
|
return u.endsWith('.mpq') || u.endsWith('.dll') || u.includes('/mpq/') || u.includes('/dll/')
|
|
})
|
|
log.check({
|
|
id: 'ZERO_MPQ_DLL_REQUESTS',
|
|
criterion: 'AC: 加载并游玩场景期间,对 *.mpq / *.dll 的网络请求数为 0',
|
|
preconditions: [
|
|
rendered,
|
|
ticked,
|
|
// Without this, "0 forbidden requests" could simply mean the network
|
|
// layer was never observed at all.
|
|
{ name: 'network capture live', met: networkRequests.length > 0, value: String(networkRequests.length) },
|
|
],
|
|
pass: forbidden.length === 0,
|
|
detail:
|
|
`${forbidden.length} forbidden of ${networkRequests.length} total requests` +
|
|
(forbidden.length > 0 ? ` -> ${forbidden.map(f => f.url).join(', ')}` : ''),
|
|
})
|
|
|
|
// 2. Zero console.error.
|
|
log.check({
|
|
id: 'ZERO_CONSOLE_ERRORS',
|
|
criterion: 'AC: 审计期间 console.error 计数为 0',
|
|
preconditions: [rendered, ticked],
|
|
pass: consoleErrors.length === 0,
|
|
detail:
|
|
`${consoleErrors.length} error-level messages` +
|
|
(consoleErrors.length > 0
|
|
? ` -> ${consoleErrors.slice(0, 8).map(e => `[${e.source}] ${e.text.slice(0, 200)}`).join(' || ')}`
|
|
: ''),
|
|
})
|
|
|
|
// 3. No red placeholder squares.
|
|
//
|
|
// Read from published state, not pixels: `act-scene.ts:3352` already
|
|
// records every monster id drawn as a red box. The precondition that
|
|
// monsters were actually planned is what stops this being vacuous on a
|
|
// town level, where zero red squares is true but meaningless.
|
|
const missing = live.missingMonsterArt ?? []
|
|
log.check({
|
|
id: 'NO_RED_PLACEHOLDER_SQUARES',
|
|
criterion: 'AC: 无红色占位方块',
|
|
preconditions: [
|
|
rendered,
|
|
ticked,
|
|
{ name: 'monsters planned on this level', met: live.monstersPlanned > 0, value: String(live.monstersPlanned) },
|
|
{ name: 'missingMonsterArt field exists', met: live.missingMonsterArt !== null, value: String(live.missingMonsterArt !== null) },
|
|
],
|
|
pass: missing.length === 0 && live.monsterArtErrors === 0 && live.monsterArtLayerFailures === 0,
|
|
detail:
|
|
`missingMonsterArt=${JSON.stringify(missing)} monsterArtErrors=${live.monsterArtErrors} ` +
|
|
`monsterArtLayerFailures=${live.monsterArtLayerFailures}`,
|
|
})
|
|
|
|
// 3b. The published-state trap.
|
|
//
|
|
// `state.characterGroup` is declared (L254) and initialised to -1 (L328)
|
|
// and never written. It is proof that a published field can be a
|
|
// constant, so any audit reading state must verify the fields it trusts
|
|
// are actually written. This assertion documents the trap rather than
|
|
// depending on it.
|
|
log.add({
|
|
id: 'PUBLISHED_STATE_TRAP_CHARACTERGROUP',
|
|
criterion: 'harness self-check: a published field can lie (act-scene.ts:254 characterGroup)',
|
|
verdict: live.characterGroup === -1 ? 'PASS' : 'PASS',
|
|
detail:
|
|
`characterGroup=${live.characterGroup} (init -1 at act-scene.ts:328, never written). ` +
|
|
`${live.characterGroup === -1 ? 'Confirmed still dead — no assertion may depend on it.' : 'It is now written; it became usable.'}`,
|
|
preconditions: [rendered],
|
|
})
|
|
|
|
// 4. Eight-direction foot-anchor evidence.
|
|
await captureEightDirections(cdp, log, saveScreenshot, live, opts)
|
|
|
|
// 5. Weapon-swap evidence.
|
|
await captureWeaponSwap(cdp, log, saveScreenshot, outDir)
|
|
}
|
|
|
|
// --- Report -------------------------------------------------------------
|
|
exitCode = report(log, opts, outDir, { networkRequests, consoleErrors, served })
|
|
|
|
if (opts.keepOpenMs > 0) await sleep(opts.keepOpenMs)
|
|
cdp.close()
|
|
} finally {
|
|
chrome.kill('SIGKILL')
|
|
await new Promise<void>(r => server.close(() => r()))
|
|
}
|
|
|
|
process.exit(exitCode)
|
|
}
|
|
|
|
/**
|
|
* Fail loudly when there is nothing real to audit.
|
|
*
|
|
* A missing `dist-game/` must not degrade into "audited nothing, all green",
|
|
* and a stale one silently audits code that no longer exists.
|
|
*/
|
|
function assertBuildPresentAndFresh(): void {
|
|
if (!existsSync(DIST_DIR) || !existsSync(join(DIST_DIR, 'acts.html'))) {
|
|
console.error(`\nFAIL: no built artifact at ${DIST_DIR} (acts.html missing).`)
|
|
console.error(' This harness audits the SHIPPED bundle. Build it first:')
|
|
console.error(' npm run build:game')
|
|
process.exit(1)
|
|
}
|
|
const newest = (dir: string): number => {
|
|
let best = 0
|
|
const walk = (d: string): void => {
|
|
for (const e of readdirSync(d)) {
|
|
const f = join(d, e)
|
|
const st = statSync(f)
|
|
if (st.isDirectory()) walk(f)
|
|
else best = Math.max(best, st.mtimeMs)
|
|
}
|
|
}
|
|
walk(dir)
|
|
return best
|
|
}
|
|
const distTime = newest(DIST_DIR)
|
|
const srcTime = newest(SRC_DIR)
|
|
if (srcTime > distTime) {
|
|
console.error('\nFAIL: dist-game/ is STALE — a file under src/ is newer than the build.')
|
|
console.error(` newest src/ : ${new Date(srcTime).toISOString()}`)
|
|
console.error(` newest dist-game/ : ${new Date(distTime).toISOString()}`)
|
|
console.error(' Auditing a stale bundle proves nothing about the current code.')
|
|
console.error(' Rebuild: npm run build:game')
|
|
process.exit(1)
|
|
}
|
|
console.log(`Artifact OK: dist-game/ built ${new Date(distTime).toISOString()} (newer than src/)`)
|
|
}
|
|
|
|
async function injectNegativeControl(cdp: CdpSession, negative: string): Promise<void> {
|
|
if (negative === '') return
|
|
console.log(`\n--- Injecting negative control: ${negative}`)
|
|
switch (negative) {
|
|
case 'mpq-request':
|
|
// A genuine network request for an archive, issued by the page. It is
|
|
// expected to 404 — the invariant is about the request being made.
|
|
await cdp.evalJs(`fetch('/diablo2/data/d2char.mpq').then(()=>1).catch(()=>1)`)
|
|
break
|
|
case 'dll-request':
|
|
await cdp.evalJs(`fetch('/diablo2/data/D2Common.dll').then(()=>1).catch(()=>1)`)
|
|
break
|
|
case 'console-error':
|
|
await cdp.evalJs(`(() => { console.error('MV negative control: synthetic console.error'); return 1 })()`)
|
|
break
|
|
case 'missing-art':
|
|
await cdp.evalJs(
|
|
`(() => { window.__d2webAct.missingMonsterArt.push('mv-synthetic-missing-monster'); return 1 })()`,
|
|
)
|
|
break
|
|
case 'monster-art-error':
|
|
await cdp.evalJs(`(() => { window.__d2webAct.monsterArtErrors += 1; return 1 })()`)
|
|
break
|
|
case 'freeze-facing':
|
|
case 'broken-page':
|
|
// Handled at their own point in the flow.
|
|
break
|
|
default:
|
|
throw new Error(`unhandled negative control ${negative}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Drive the player through all eight facings and capture one screenshot each.
|
|
*
|
|
* WHY THE MACHINE ASSERTION IS NOT "the screenshots differ".
|
|
* The background has animated tiles and independently moving monsters, so any
|
|
* two full-frame captures differ even if the player never turned. An assertion
|
|
* on image difference would therefore pass unconditionally — vacuous in the
|
|
* most dangerous way, since it *looks* like pixel evidence.
|
|
*
|
|
* What is actually checkable is the mechanism: that all eight distinct facings
|
|
* were really reached, and that the character draw path was live at each one
|
|
* (`characterFrames` strictly increasing). The screenshots are then human
|
|
* review material for anchor drift, which is a judgement a script cannot make
|
|
* without a per-frame draw rect the runtime does not yet publish.
|
|
*/
|
|
async function captureEightDirections(
|
|
cdp: CdpSession,
|
|
log: AssertionLog,
|
|
saveScreenshot: (name: string) => Promise<string>,
|
|
before: SceneSnapshot,
|
|
opts: Options,
|
|
): Promise<void> {
|
|
console.log('\n--- Capturing 8 facings')
|
|
const observed: number[] = []
|
|
const charFrames: number[] = []
|
|
const playerDrawRects: Array<{ x: number; y: number; w: number; h: number } | null> = []
|
|
const playerPositions: Array<{ x: number; y: number }> = []
|
|
|
|
for (let dir = 0; dir < 8; dir++) {
|
|
await cdp.evalJs(`(() => {
|
|
if (window.__mvFreezeFacing) return 1
|
|
const e = window.__d2webEngine
|
|
if (!e) throw new Error('__d2webEngine is not published; cannot set facing')
|
|
e.world.player.facing = ${dir}
|
|
return 1
|
|
})()`)
|
|
// Two sim ticks at 25 Hz plus a render.
|
|
await sleep(160)
|
|
const s = await cdp.evalJs<SceneSnapshot>(SNAPSHOT_EXPR)
|
|
observed.push(s.facing)
|
|
charFrames.push(s.characterFrames)
|
|
playerDrawRects.push(s.playerDrawRect ?? null)
|
|
playerPositions.push({ x: s.x, y: s.y })
|
|
await saveScreenshot(`anchor_facing_${dir}.png`)
|
|
}
|
|
|
|
const uniqueFacings = new Set(observed)
|
|
const charPathLive = charFrames.every((v, i) => i === 0 || v > (charFrames[i - 1] ?? -1))
|
|
|
|
console.log(` facings observed: ${JSON.stringify(observed)} (unique=${uniqueFacings.size})`)
|
|
console.log(` characterFrames : ${JSON.stringify(charFrames)} (strictly increasing=${charPathLive})`)
|
|
|
|
log.check({
|
|
id: 'EIGHT_DIRECTIONS_EXERCISED',
|
|
criterion: 'AC: 8 个方向下角色脚底位置稳定(锚点正确),有截图证据 — mechanism half',
|
|
preconditions: [
|
|
{ name: '__d2webEngine published', met: true, value: 'act-scene.ts:2677' },
|
|
{ name: 'character art loaded (not the marker box)', met: before.character === true, value: String(before.character) },
|
|
],
|
|
pass: uniqueFacings.size === 8 && charPathLive,
|
|
detail:
|
|
`observed facings=${JSON.stringify(observed)} unique=${uniqueFacings.size}/8; ` +
|
|
`characterFrames=${JSON.stringify(charFrames)} strictlyIncreasing=${charPathLive}; ` +
|
|
`8 screenshots written (anchor_facing_0..7.png)`,
|
|
})
|
|
|
|
const allRectsPublished = playerDrawRects.length === 8 && playerDrawRects.every(r => r !== null)
|
|
let maxFootDelta = -1
|
|
if (allRectsPublished) {
|
|
const footDeltas = playerDrawRects.map((r, i) => {
|
|
const py = playerPositions[i]!.y
|
|
return Math.abs((r!.y + r!.h) - py)
|
|
})
|
|
maxFootDelta = Math.max(...footDeltas)
|
|
}
|
|
|
|
log.check({
|
|
id: 'FOOT_ANCHOR_NUMERIC_INVARIANT',
|
|
criterion: 'AC: 脚底位置稳定(无漂移)— numeric half',
|
|
preconditions: [
|
|
{ name: 'state.playerDrawRect published', met: allRectsPublished, value: allRectsPublished ? 'published' : 'missing' },
|
|
],
|
|
pass: allRectsPublished && playerDrawRects.every((r, i) => {
|
|
const p = playerPositions[i]!
|
|
return p.x >= r!.x && p.x <= r!.x + r!.w && Math.abs((r!.y + r!.h) - p.y) <= 15
|
|
}),
|
|
detail: allRectsPublished
|
|
? `playerDrawRect published across all 8 facings (${playerDrawRects.map(r => `${r!.w}x${r!.h}`).join(', ')}); ` +
|
|
`max |(y+h) - player.y| = ${maxFootDelta}px; feet ground on tile anchor`
|
|
: 'playerDrawRect missing on some facings',
|
|
})
|
|
|
|
if (opts.negative === 'freeze-facing') {
|
|
console.log(' (freeze-facing control active: facing writes were suppressed in-page)')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Capture evidence for "swapping weapon class changes the attack animation".
|
|
*
|
|
* On current `main` the player COF is hardcoded to `hth` (tier-3 char load
|
|
* passes the literal `'hth'`, `act-scene.ts:2388-2389`), and no weapon class
|
|
* reaches the clip choice at all, so this is a known-red baseline that M3 owns.
|
|
* The harness still captures the before/after so the change is reviewable, and
|
|
* records the equipped codes structurally rather than claiming a screenshot
|
|
* proves something it does not.
|
|
*/
|
|
async function captureWeaponSwap(
|
|
cdp: CdpSession,
|
|
log: AssertionLog,
|
|
saveScreenshot: (name: string) => Promise<string>,
|
|
outDir: string,
|
|
): Promise<void> {
|
|
console.log('\n--- Weapon-swap evidence')
|
|
|
|
const readWeapon = `(() => {
|
|
const hud = window.__d2webHudInstance
|
|
const inv = hud && hud.inventory
|
|
const eq = inv && inv.equipped
|
|
const w1 = eq && eq.weapon1
|
|
return {
|
|
hudPresent: !!hud,
|
|
inventoryPresent: !!inv,
|
|
weapon1: w1 ? { code: w1.code, name: w1.name } : null,
|
|
// The runtime does not publish a resolved weapon class or the COF clip in
|
|
// use. Recorded as null so the gap is visible in the artifact rather than
|
|
// implied by omission.
|
|
resolvedWeaponClass: (window.__d2webAct && window.__d2webAct.weaponClass) ?? null,
|
|
activeClip: (window.__d2webAct && window.__d2webAct.playerClip) ?? null,
|
|
}
|
|
})()`
|
|
|
|
interface WeaponEvidence {
|
|
hudPresent: boolean
|
|
inventoryPresent: boolean
|
|
weapon1: { code: string; name: string } | null
|
|
resolvedWeaponClass: string | null
|
|
activeClip: string | null
|
|
}
|
|
|
|
const before = await cdp.evalJs<WeaponEvidence>(readWeapon)
|
|
await saveScreenshot('weapon_before.png')
|
|
|
|
// Swap to a two-handed weapon so the weapon class genuinely differs. `2hs`
|
|
// vs `hth` is the coarsest possible distinction, which is the right one for a
|
|
// first regression signal.
|
|
const swapped = await cdp.evalJs<boolean>(`(() => {
|
|
const inv = window.__d2webHudInstance && window.__d2webHudInstance.inventory
|
|
if (!inv) return false
|
|
inv.equipped.weapon1 = {
|
|
id: 'mv-swap-2hs', code: '7wa', invFile: 'invgpa', name: 'Colossus Blade',
|
|
quality: 'normal', invWidth: 2, invHeight: 4, allowedSlots: ['weapon1'],
|
|
}
|
|
return true
|
|
})()`)
|
|
await sleep(600)
|
|
const after = await cdp.evalJs<WeaponEvidence>(readWeapon)
|
|
await saveScreenshot('weapon_after.png')
|
|
|
|
const evidencePath = assertWritablePath(join(outDir, 'weapon-swap-evidence.json'))
|
|
writeFileSync(evidencePath, JSON.stringify({ swapped, before, after }, null, 2))
|
|
console.log(` weapon evidence: ${relative(ROOT, evidencePath)}`)
|
|
console.log(` before=${JSON.stringify(before)}`)
|
|
console.log(` after =${JSON.stringify(after)}`)
|
|
|
|
log.check({
|
|
id: 'WEAPON_SWAP_EVIDENCE_CAPTURED',
|
|
criterion: 'AC: 换装不同武器类后…有截图或日志证据 — capture half',
|
|
preconditions: [{ name: 'HUD inventory reachable', met: before.inventoryPresent, value: String(before.inventoryPresent) }],
|
|
pass: swapped && before.weapon1 !== null && after.weapon1 !== null && before.weapon1.code !== after.weapon1.code,
|
|
detail: `equipped weapon1 ${before.weapon1?.code ?? 'none'} -> ${after.weapon1?.code ?? 'none'}; screenshots + JSON written`,
|
|
})
|
|
|
|
log.check({
|
|
id: 'WEAPON_SWAP_CHANGES_ANIMATION',
|
|
criterion: 'AC: 换装不同武器类后,攻击动作随之变化',
|
|
preconditions: [
|
|
{
|
|
name: 'runtime publishes the resolved weapon class / active clip',
|
|
met: after.resolvedWeaponClass !== null || after.activeClip !== null,
|
|
value: `weaponClass=${String(after.resolvedWeaponClass)} activeClip=${String(after.activeClip)}`,
|
|
},
|
|
],
|
|
pass: before.resolvedWeaponClass !== after.resolvedWeaponClass || before.activeClip !== after.activeClip,
|
|
detail: `weaponClass: ${String(before.resolvedWeaponClass)} -> ${String(after.resolvedWeaponClass)}; activeClip=${String(after.activeClip)}`,
|
|
})
|
|
}
|
|
|
|
function report(
|
|
log: AssertionLog,
|
|
opts: Options,
|
|
outDir: string,
|
|
raw: { networkRequests: NetworkRequestLog[]; consoleErrors: ConsoleErrorLog[]; served: ServedRequest[] },
|
|
): number {
|
|
console.log('\n======================================================================')
|
|
console.log('RESULTS')
|
|
console.log('======================================================================')
|
|
|
|
let hardFail = 0
|
|
let vacuous = 0
|
|
let knownRed = 0
|
|
let baselineImproved = 0
|
|
|
|
for (const a of log.items) {
|
|
const isKnownRed = a.knownRedBaseline !== undefined
|
|
let tag: string
|
|
if (a.verdict === 'PASS') {
|
|
if (isKnownRed) {
|
|
tag = 'BASELINE-IMPROVED'
|
|
baselineImproved++
|
|
} else tag = 'PASS'
|
|
} else if (a.verdict === 'VACUOUS') {
|
|
if (isKnownRed) {
|
|
tag = 'KNOWN-RED'
|
|
knownRed++
|
|
} else {
|
|
tag = 'VACUOUS(=FAIL)'
|
|
vacuous++
|
|
}
|
|
} else {
|
|
if (isKnownRed) {
|
|
tag = 'KNOWN-RED'
|
|
knownRed++
|
|
} else {
|
|
tag = 'FAIL'
|
|
hardFail++
|
|
}
|
|
}
|
|
console.log(`\n[${tag}] ${a.id}`)
|
|
console.log(` criterion: ${a.criterion}`)
|
|
console.log(` detail : ${a.detail}`)
|
|
for (const p of a.preconditions) {
|
|
console.log(` precond : ${p.met ? 'met' : 'UNMET'} — ${p.name} = ${p.value}`)
|
|
}
|
|
if (a.knownRedBaseline !== undefined) console.log(` baseline : ${a.knownRedBaseline}`)
|
|
}
|
|
|
|
const summaryPath = assertWritablePath(join(outDir, 'audit-report.json'))
|
|
writeFileSync(
|
|
summaryPath,
|
|
JSON.stringify(
|
|
{
|
|
negativeControl: opts.negative === '' ? null : opts.negative,
|
|
generated: new Date().toISOString(),
|
|
assertions: log.items,
|
|
totals: { hardFail, vacuous, knownRed, baselineImproved },
|
|
networkRequestCount: raw.networkRequests.length,
|
|
forbiddenRequests: raw.networkRequests
|
|
.filter(r => {
|
|
const u = r.url.toLowerCase().split('?')[0] ?? ''
|
|
return u.endsWith('.mpq') || u.endsWith('.dll')
|
|
})
|
|
.map(r => r.url),
|
|
consoleErrors: raw.consoleErrors,
|
|
serverRequestCount: raw.served.length,
|
|
server404s: raw.served.filter(s => s.status === 404).map(s => s.url),
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
)
|
|
|
|
console.log('\n----------------------------------------------------------------------')
|
|
console.log(
|
|
`SUMMARY: ${log.items.length} assertions | FAIL=${hardFail} VACUOUS=${vacuous} ` +
|
|
`KNOWN-RED=${knownRed} BASELINE-IMPROVED=${baselineImproved}`,
|
|
)
|
|
console.log(`Report: ${relative(ROOT, summaryPath)}`)
|
|
|
|
if (baselineImproved > 0) {
|
|
console.log('\nNOTE: a known-red baseline turned green. Update the baseline annotation in this script.')
|
|
}
|
|
|
|
if (opts.negative !== '') {
|
|
// Under a negative control the run is SUPPOSED to be red. Report the
|
|
// inversion explicitly so a control that failed to bite is not mistaken
|
|
// for a clean run.
|
|
const red = hardFail + vacuous
|
|
console.log('\n----------------------------------------------------------------------')
|
|
if (red > 0) {
|
|
console.log(`NEGATIVE CONTROL '${opts.negative}': harness went RED as required (${red} failing assertion(s)).`)
|
|
return 0
|
|
}
|
|
console.log(`NEGATIVE CONTROL '${opts.negative}': ***HARNESS STAYED GREEN — THE CONTROL DID NOT BITE***`)
|
|
console.log('This means the assertion it targets cannot detect its own failure mode.')
|
|
return 1
|
|
}
|
|
|
|
return hardFail + vacuous > 0 ? 1 : 0
|
|
}
|
|
|
|
main().catch((err: unknown) => {
|
|
console.error('\nHARNESS ERROR:', err)
|
|
process.exit(1)
|
|
})
|