import { createHash } from "node:crypto"; import { readFile, writeFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { join } from "node:path"; import { chromium } from "playwright"; import { createServer } from "node:http"; const root = fileURLToPath(new URL(".", import.meta.url)); const manifest = JSON.parse(await readFile(join(root, "frame-manifest.json"), "utf8")); const assets = JSON.parse(await readFile(join(root, "asset-manifest.json"), "utf8")); const errors = []; const hashes = {}; for (const asset of assets.assets) { const local = await readFile(join(root, asset.path)); const source = await readFile(join(root, "../../../..", asset.source)); const localHash = createHash("sha256").update(local).digest("hex"); const sourceHash = createHash("sha256").update(source).digest("hex"); hashes[asset.path] = { source: asset.source, sha256: localHash }; if (localHash !== sourceHash) errors.push(`source hash mismatch: ${asset.path}`); } const mime = { ".html":"text/html", ".css":"text/css", ".js":"text/javascript", ".png":"image/png", ".ttf":"font/ttf" }; const server = createServer(async (request, response) => { const rawPath = decodeURIComponent((request.url || "/").split("?")[0]); const relativePath = rawPath === "/" ? "index.html" : rawPath.replace(/^\//, ""); try { const { extname } = await import("node:path"); const body = await readFile(join(root, relativePath)); response.writeHead(200, { "content-type": mime[extname(relativePath)] || "application/octet-stream" }); response.end(body); } catch { response.writeHead(404); response.end("Not found"); } }); await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); const { port } = server.address(); const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 844, height: 390 }, deviceScaleFactor: 1 }); for (const frame of manifest.frames) { await page.goto(`http://127.0.0.1:${port}/index.html?frame=${frame.id}`, { waitUntil: "networkidle" }); const state = await page.evaluate(() => { const slice = document.querySelector("#slice"); const nodes = [...document.querySelectorAll("body *")]; const overflow = nodes.filter(node => { const r = node.getBoundingClientRect(); return r.width > 0 && r.height > 0 && (r.left < -0.5 || r.top < -0.5 || r.right > 844.5 || r.bottom > 390.5); }).map(node => `${node.tagName}.${node.className}`); return { ready: document.documentElement.dataset.ready, frame: slice?.dataset.frame, overflow, actors: document.querySelectorAll(".layer-actors img").length, text: document.body.innerText }; }); if (state.ready !== "true" || state.frame !== frame.id) errors.push(`frame not ready: ${frame.id}`); if (state.overflow.length) errors.push(`viewport overflow ${frame.id}: ${state.overflow.join(", ")}`); if (state.actors < 2) errors.push(`missing actors: ${frame.id}`); if (!state.text.includes("妮娅") || !state.text.includes("赛布尔")) errors.push(`identity copy missing: ${frame.id}`); } await browser.close(); server.close(); const generation = JSON.parse(await readFile(join(root, "generation-record.json"), "utf8")); if (generation.paid_calls_used > generation.authorized_call_ceiling) errors.push("paid call ceiling exceeded"); if (generation.returned_cost_usd > generation.authorized_cost_ceiling_usd) errors.push("paid cost ceiling exceeded"); const report = { ok: errors.length === 0, viewport: manifest.viewport, frame_count: manifest.frames.length, asset_count: assets.assets.length, paid_calls_used: generation.paid_calls_used, paid_cost_usd: generation.returned_cost_usd, errors, hashes }; await writeFile(join(root, "evidence", "verification.json"), JSON.stringify(report, null, 2) + "\n"); console.log(JSON.stringify(report, null, 2)); if (errors.length) process.exitCode = 1;