1221 lines
55 KiB
JavaScript
1221 lines
55 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
import { createHash } from 'node:crypto'
|
|
import fs from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
import { createRequire } from 'node:module'
|
|
import { spawn } from 'node:child_process'
|
|
import { pathToFileURL } from 'node:url'
|
|
import react from '@vitejs/plugin-react'
|
|
import { createServer } from 'vite'
|
|
|
|
const require = createRequire(import.meta.url)
|
|
const projectRoot = process.cwd()
|
|
const rendererRoot = path.join(projectRoot, 'src/renderer')
|
|
const runtimeRoot = path.join(projectRoot, '.tmp/rea-css-runtime/dist')
|
|
const sourceFlowEvidenceId = 'ev_fc29c47ef7b284267584efe1fcabe48345e37a0b03cf492b686faec7aa704324'
|
|
const sourceFlowCssPath = path.join(projectRoot, '.tmp/rea-css-recovered/src-CyXHA0IC.css')
|
|
const electronExecutable = require('electron')
|
|
const hash = bytes => createHash('sha256').update(bytes).digest('hex')
|
|
const captureModes = {
|
|
'offscreen-disabled-gpu': {
|
|
id: 'offscreen-disabled-gpu',
|
|
offscreen: true,
|
|
disableHardwareAcceleration: true,
|
|
disableGpuSwitch: true,
|
|
captureOptions: null,
|
|
},
|
|
'hidden-hardware': {
|
|
id: 'hidden-hardware',
|
|
offscreen: false,
|
|
disableHardwareAcceleration: false,
|
|
disableGpuSwitch: false,
|
|
captureOptions: { stayHidden: true, stayAwake: true },
|
|
},
|
|
'hidden-debugger': {
|
|
id: 'hidden-debugger',
|
|
offscreen: false,
|
|
disableHardwareAcceleration: false,
|
|
disableGpuSwitch: false,
|
|
debuggerScreenshot: true,
|
|
backgroundThrottling: false,
|
|
},
|
|
'visible-debugger': {
|
|
id: 'visible-debugger',
|
|
offscreen: false,
|
|
disableHardwareAcceleration: false,
|
|
disableGpuSwitch: false,
|
|
debuggerScreenshot: true,
|
|
backgroundThrottling: false,
|
|
showInactiveBeforeCapture: true,
|
|
closeAfterCapture: true,
|
|
},
|
|
'visible-swiftshader': {
|
|
id: 'visible-swiftshader',
|
|
offscreen: false,
|
|
disableHardwareAcceleration: true,
|
|
disableGpuSwitch: false,
|
|
debuggerScreenshot: true,
|
|
backgroundThrottling: false,
|
|
showInactiveBeforeCapture: true,
|
|
closeAfterCapture: true,
|
|
graphicsSwitches: ['--use-angle=swiftshader', '--disable-gpu-compositing'],
|
|
},
|
|
}
|
|
|
|
const electronHelperSource = String.raw`
|
|
const fs = require('node:fs')
|
|
const path = require('node:path')
|
|
const zlib = require('node:zlib')
|
|
const { app, BrowserWindow, nativeImage } = require('electron')
|
|
|
|
const jobPath = process.env.YCODE_MESSAGE_PARITY_JOB
|
|
const resultPath = process.env.YCODE_MESSAGE_PARITY_RESULT
|
|
const logPath = process.env.YCODE_MESSAGE_PARITY_LOG
|
|
const mark = value => { try { fs.appendFileSync(logPath, String(Date.now()) + ' ' + value + '\n') } catch {} }
|
|
const finish = (value, code = 0) => {
|
|
mark('finish:' + code)
|
|
try { fs.writeFileSync(resultPath, JSON.stringify(value)) } catch {}
|
|
app.exit(code)
|
|
}
|
|
const fail = error => finish({ ok: false, error: String(error && (error.stack || error.message) || error) }, 1)
|
|
const delay = ms => new Promise(resolve => setTimeout(resolve, ms))
|
|
mark('main-module-start')
|
|
|
|
async function captureGpuState(job) {
|
|
const gpuInfoDetail = 'complete'
|
|
let gpuFeatureStatus = null
|
|
try { gpuFeatureStatus = app.getGPUFeatureStatus() } catch (error) { gpuFeatureStatus = { error: String(error.message || error) } }
|
|
let gpuInfo = null
|
|
try {
|
|
gpuInfo = await Promise.race([
|
|
app.getGPUInfo(gpuInfoDetail).catch(error => ({ error: String(error.message || error) })),
|
|
delay(4000).then(() => ({ unavailable: 'timeout' })),
|
|
])
|
|
} catch (error) { gpuInfo = { error: String(error.message || error) } }
|
|
return {
|
|
mode: job.captureMode.id,
|
|
offscreen: job.captureMode.offscreen,
|
|
hardwareAccelerationDisabledByApp: job.captureMode.disableHardwareAcceleration,
|
|
disableGpuSwitchRequested: job.captureMode.disableGpuSwitch,
|
|
disableGpuSwitchPresent: app.commandLine.hasSwitch('disable-gpu'),
|
|
backgroundThrottlingDisabled: job.captureMode.backgroundThrottling === false,
|
|
showInactiveBeforeCapture: job.captureMode.showInactiveBeforeCapture === true,
|
|
windowVisibleAtCapture: false,
|
|
windowClosedAfterCapture: false,
|
|
chromiumVersion: process.versions.chrome || null,
|
|
electronVersion: process.versions.electron || null,
|
|
gpuFeatureStatus,
|
|
gpuInfoDetail,
|
|
gpuInfo,
|
|
captureMethod: job.captureMode.debuggerScreenshot
|
|
? 'webContents.debugger.sendCommand(Page.captureScreenshot, { format: png, fromSurface: true, captureBeyondViewport: false })'
|
|
: job.captureMode.captureOptions
|
|
? 'webContents.capturePage(undefined, { stayHidden: true, stayAwake: true })'
|
|
: 'webContents.capturePage()',
|
|
}
|
|
}
|
|
|
|
function crc32(bytes) {
|
|
let crc = 0xffffffff
|
|
for (const byte of bytes) {
|
|
crc ^= byte
|
|
for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0)
|
|
}
|
|
return (crc ^ 0xffffffff) >>> 0
|
|
}
|
|
|
|
function pngChunk(type, payload) {
|
|
const typeBytes = Buffer.from(type, 'ascii')
|
|
const length = Buffer.alloc(4)
|
|
length.writeUInt32BE(payload.length)
|
|
const checksum = Buffer.alloc(4)
|
|
checksum.writeUInt32BE(crc32(Buffer.concat([typeBytes, payload])))
|
|
return Buffer.concat([length, typeBytes, payload, checksum])
|
|
}
|
|
|
|
function detectBitmapChannelOrder() {
|
|
const header = Buffer.alloc(13)
|
|
header.writeUInt32BE(1, 0)
|
|
header.writeUInt32BE(1, 4)
|
|
header[8] = 8
|
|
header[9] = 6
|
|
const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
|
|
const pixel = zlib.deflateSync(Buffer.from([0, 17, 83, 201, 255]))
|
|
const png = Buffer.concat([signature, pngChunk('IHDR', header), pngChunk('IDAT', pixel), pngChunk('IEND', Buffer.alloc(0))])
|
|
const image = nativeImage.createFromBuffer(png)
|
|
if (image.isEmpty()) throw new Error('nativeImage could not decode the channel-order calibration PNG')
|
|
const bitmap = image.toBitmap({ scaleFactor: 1 })
|
|
if (bitmap.length !== 4 || bitmap[3] !== 255) throw new Error('nativeImage returned invalid channel-order calibration pixels')
|
|
if (bitmap[0] === 201 && bitmap[1] === 83 && bitmap[2] === 17) return { name: 'BGRA', red: 2, green: 1, blue: 0, alpha: 3 }
|
|
if (bitmap[0] === 17 && bitmap[1] === 83 && bitmap[2] === 201) return { name: 'RGBA', red: 0, green: 1, blue: 2, alpha: 3 }
|
|
throw new Error('nativeImage bitmap channel order did not match the calibration PNG: ' + Array.from(bitmap).join(','))
|
|
}
|
|
|
|
function analyzePixelRegion(pngBytes, region, channelOrder) {
|
|
const image = nativeImage.createFromBuffer(pngBytes)
|
|
if (image.isEmpty()) throw new Error('nativeImage could not decode a pixel-analysis PNG')
|
|
const size = image.getSize()
|
|
const { x, y, width, height } = region
|
|
if (x < 0 || y < 0 || width < 1 || height < 1 || x + width > size.width || y + height > size.height) {
|
|
throw new Error('Pixel-analysis region falls outside its PNG')
|
|
}
|
|
const bitmap = image.toBitmap({ scaleFactor: 1 })
|
|
if (bitmap.length !== size.width * size.height * 4) throw new Error('nativeImage returned an unexpected pixel-analysis bitmap size')
|
|
const colorAt = (offset) => ({
|
|
r: bitmap[offset + channelOrder.red],
|
|
g: bitmap[offset + channelOrder.green],
|
|
b: bitmap[offset + channelOrder.blue],
|
|
a: bitmap[offset + channelOrder.alpha],
|
|
})
|
|
const histogram = new Map()
|
|
const pixels = []
|
|
for (let py = y; py < y + height; py += 1) {
|
|
for (let px = x; px < x + width; px += 1) {
|
|
const color = colorAt((py * size.width + px) * 4)
|
|
if (color.a === 0) continue
|
|
const key = color.r + ',' + color.g + ',' + color.b
|
|
histogram.set(key, (histogram.get(key) ?? 0) + 1)
|
|
pixels.push(color)
|
|
}
|
|
}
|
|
const backgroundEntry = [...histogram.entries()].sort((left, right) => right[1] - left[1])[0]
|
|
const background = backgroundEntry ? backgroundEntry[0].split(',').map(Number) : [0, 0, 0]
|
|
const luminance = color => 0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b
|
|
const backgroundColor = { r: background[0], g: background[1], b: background[2] }
|
|
const backgroundLuminance = luminance(backgroundColor)
|
|
const orderCounts = { 'R>G>B': 0, 'R>B>G': 0, 'G>R>B': 0, 'G>B>R': 0, 'B>R>G': 0, 'B>G>R': 0, tied: 0 }
|
|
const chromaticEdges = new Map()
|
|
let inkPixels = 0
|
|
let edgePixels = 0
|
|
let chromaticEdgePixels = 0
|
|
let darkest = null
|
|
for (const color of pixels) {
|
|
const lightness = luminance(color)
|
|
const contrastFromBackground = backgroundLuminance - lightness
|
|
if (contrastFromBackground > 24) {
|
|
inkPixels += 1
|
|
if (!darkest || lightness < darkest.luminance) darkest = { color: { r: color.r, g: color.g, b: color.b }, luminance: lightness }
|
|
}
|
|
if (contrastFromBackground > 8 && contrastFromBackground < 210) {
|
|
edgePixels += 1
|
|
const max = Math.max(color.r, color.g, color.b)
|
|
const min = Math.min(color.r, color.g, color.b)
|
|
if (max - min >= 2) {
|
|
chromaticEdgePixels += 1
|
|
const sorted = [['R', color.r], ['G', color.g], ['B', color.b]].sort((left, right) => right[1] - left[1])
|
|
const key = sorted[0][1] === sorted[1][1] || sorted[1][1] === sorted[2][1]
|
|
? 'tied'
|
|
: sorted.map(entry => entry[0]).join('>')
|
|
orderCounts[key] += 1
|
|
const rgb = color.r + ',' + color.g + ',' + color.b
|
|
chromaticEdges.set(rgb, (chromaticEdges.get(rgb) ?? 0) + 1)
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
region,
|
|
dimensions: size,
|
|
pixelFormat: channelOrder.name,
|
|
modalBackground: { ...backgroundColor, pixels: backgroundEntry?.[1] ?? 0 },
|
|
darkestInkPixel: darkest,
|
|
inkCandidatePixels: inkPixels,
|
|
edgeCandidatePixels: edgePixels,
|
|
chromaticEdgePixels,
|
|
chromaticEdgeOrderCounts: orderCounts,
|
|
commonChromaticEdgeRgb: [...chromaticEdges.entries()]
|
|
.sort((left, right) => right[1] - left[1])
|
|
.slice(0, 12)
|
|
.map(([rgb, count]) => ({ rgb: rgb.split(',').map(Number), count })),
|
|
}
|
|
}
|
|
|
|
function analyzeReferenceAndCandidate(job, candidateBytes) {
|
|
const referenceBytes = fs.readFileSync(job.referencePng)
|
|
const channelOrder = detectBitmapChannelOrder()
|
|
const regions = Object.fromEntries(Object.entries(job.pixelRegions).map(([name, region]) => [name, {
|
|
reference: analyzePixelRegion(referenceBytes, region, channelOrder),
|
|
candidate: analyzePixelRegion(candidateBytes, region, channelOrder),
|
|
}]))
|
|
return { method: 'Electron nativeImage decoded 8-bit bitmap; channel order calibrated against known RGBA PNG', channelOrder: channelOrder.name, regions }
|
|
}
|
|
|
|
async function waitUntil(predicate, timeoutMs, label) {
|
|
const deadline = Date.now() + timeoutMs
|
|
while (Date.now() < deadline) {
|
|
if (await predicate()) return
|
|
await delay(100)
|
|
}
|
|
throw new Error('Timed out waiting for ' + label)
|
|
}
|
|
|
|
async function closeAndWait(win) {
|
|
if (!win || win.isDestroyed()) return true
|
|
const closed = new Promise(resolve => win.once('closed', () => resolve(true)))
|
|
win.close()
|
|
return Promise.race([closed, delay(2500).then(() => win.isDestroyed())])
|
|
}
|
|
|
|
function alignTargets(entries) {
|
|
const result = {}
|
|
for (const entry of entries) {
|
|
const host = document.querySelector(entry.hostSelector)
|
|
const target = host?.querySelector(entry.targetSelector)
|
|
if (!host || !target) throw new Error('Missing fixture node for ' + entry.name)
|
|
const rect = target.getBoundingClientRect()
|
|
const before = { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
|
|
const hostRect = host.getBoundingClientRect()
|
|
const beforeHost = { x: hostRect.x, y: hostRect.y, width: hostRect.width, height: hostRect.height }
|
|
const deltaY = entry.referenceBounds.y - rect.y
|
|
host.style.top = (parseFloat(host.style.top) + deltaY) + 'px'
|
|
result[entry.name] = {
|
|
referenceBounds: entry.referenceBounds,
|
|
beforeAlignmentBounds: before,
|
|
outerHostBefore: beforeHost,
|
|
outerHostTopDelta: deltaY,
|
|
anchorMethod: 'outer fixture top adjusted from measured target y; component fonts and intrinsic dimensions untouched',
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
function measureFixture() {
|
|
const rectOf = node => {
|
|
const rect = node.getBoundingClientRect()
|
|
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height, right: rect.right, bottom: rect.bottom }
|
|
}
|
|
const styleOf = node => {
|
|
const style = getComputedStyle(node)
|
|
return {
|
|
display: style.display,
|
|
position: style.position,
|
|
fontFamily: style.fontFamily,
|
|
fontSize: style.fontSize,
|
|
fontWeight: style.fontWeight,
|
|
lineHeight: style.lineHeight,
|
|
letterSpacing: style.letterSpacing,
|
|
width: style.width,
|
|
top: style.top,
|
|
right: style.right,
|
|
bottom: style.bottom,
|
|
left: style.left,
|
|
transform: style.transform,
|
|
webkitFontSmoothing: style.getPropertyValue('-webkit-font-smoothing'),
|
|
mozOsxFontSmoothing: style.getPropertyValue('-moz-osx-font-smoothing'),
|
|
textRendering: style.textRendering,
|
|
color: style.color,
|
|
backgroundColor: style.backgroundColor,
|
|
padding: style.padding,
|
|
margin: style.margin,
|
|
borderRadius: style.borderRadius,
|
|
boxSizing: style.boxSizing,
|
|
maxWidth: style.maxWidth,
|
|
overflow: style.overflow,
|
|
}
|
|
}
|
|
const describe = node => ({ text: node.textContent, bounds: rectOf(node), computedStyle: styleOf(node) })
|
|
const userHost = document.querySelector('[data-parity-fixture="user"]')
|
|
const assistantHost = document.querySelector('[data-parity-fixture="assistant"]')
|
|
const sourceFlowWrapper = document.querySelector('[data-parity-source-flow]')
|
|
const userBubble = userHost?.querySelector('.cr-self-bubble')
|
|
const userLeaf = userHost?.querySelector('.yc-user-editable p > .yc-user-leaf > span')
|
|
const paragraph = assistantHost?.querySelector('.cr-agent__content .cr-markdown > p:first-child')
|
|
if (!userHost || !assistantHost || !userBubble || !userLeaf || !paragraph) throw new Error('Fixture target nodes are not present after rendering')
|
|
return {
|
|
userBubble: {
|
|
outerHost: describe(userHost),
|
|
message: describe(userHost.querySelector('.msg--user')),
|
|
bubble: describe(userBubble),
|
|
text: describe(userLeaf),
|
|
},
|
|
firstParagraph: {
|
|
outerHost: describe(assistantHost),
|
|
message: describe(assistantHost.querySelector('.msg--assistant')),
|
|
agent: describe(assistantHost.querySelector('.cr-agent')),
|
|
paragraph: describe(paragraph),
|
|
},
|
|
viewport: { width: window.innerWidth, height: window.innerHeight, devicePixelRatio: window.devicePixelRatio },
|
|
userAgent: navigator.userAgent,
|
|
readyFonts: document.fonts.status,
|
|
sourceFlowWrapper: sourceFlowWrapper ? describe(sourceFlowWrapper) : null,
|
|
fixtureHasFixedTargetFontOrHeight: false,
|
|
}
|
|
}
|
|
|
|
try {
|
|
const job = JSON.parse(fs.readFileSync(jobPath, 'utf8'))
|
|
const dataRoot = path.join(path.dirname(jobPath), 'electron-data')
|
|
for (const name of ['userData', 'sessionData', 'logs']) {
|
|
const directory = path.join(dataRoot, name)
|
|
fs.mkdirSync(directory, { recursive: true })
|
|
try { app.setPath(name, directory) } catch {}
|
|
}
|
|
if (job.captureMode.disableHardwareAcceleration) app.disableHardwareAcceleration()
|
|
mark('app-paths-configured')
|
|
app.whenReady().then(async () => {
|
|
mark('app-ready')
|
|
const consoleErrors = []
|
|
const failedRequests = []
|
|
let win
|
|
try {
|
|
win = new BrowserWindow({
|
|
show: false,
|
|
useContentSize: true,
|
|
width: job.viewport.width,
|
|
height: job.viewport.height,
|
|
backgroundColor: '#ffffff',
|
|
webPreferences: {
|
|
offscreen: job.captureMode.offscreen,
|
|
contextIsolation: true,
|
|
...(job.captureMode.backgroundThrottling === false ? { backgroundThrottling: false } : {}),
|
|
},
|
|
})
|
|
mark('window-created')
|
|
win.webContents.on('console-message', (_event, level, message) => {
|
|
if (level === 3) consoleErrors.push(String(message))
|
|
})
|
|
win.webContents.on('did-fail-load', (_event, code, description, url) => failedRequests.push({ code, description, url }))
|
|
win.webContents.on('did-finish-load', () => mark('did-finish-load'))
|
|
win.webContents.on('dom-ready', () => mark('dom-ready'))
|
|
mark('load-url-start')
|
|
await Promise.race([
|
|
win.loadURL(job.url),
|
|
delay(30000).then(() => { throw new Error('Timed out loading the Vite fixture page') }),
|
|
])
|
|
mark('load-url-finished')
|
|
await waitUntil(() => win.webContents.executeJavaScript(
|
|
"document.documentElement.dataset.messageParityReady === 'true'",
|
|
), 45000, 'React fixture and document fonts')
|
|
mark('fixture-ready')
|
|
await win.webContents.executeJavaScript('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))')
|
|
mark('initial-render-frames')
|
|
|
|
const calibration = await win.webContents.executeJavaScript(
|
|
'(' + alignTargets.toString() + ')(' + JSON.stringify(job.anchors) + ')',
|
|
)
|
|
mark('targets-aligned')
|
|
await win.webContents.executeJavaScript('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))')
|
|
mark('aligned-render-frames')
|
|
const measurements = await win.webContents.executeJavaScript('(' + measureFixture.toString() + ')()')
|
|
mark('fixture-measured')
|
|
|
|
let windowVisibleAtCapture = false
|
|
if (job.captureMode.showInactiveBeforeCapture) {
|
|
win.showInactive()
|
|
mark('window-shown-inactive')
|
|
await waitUntil(() => win.isVisible(), 5000, 'non-activating fixture window visibility')
|
|
await delay(100)
|
|
await win.webContents.executeJavaScript('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))')
|
|
windowVisibleAtCapture = win.isVisible()
|
|
if (!windowVisibleAtCapture) throw new Error('Fixture window was not visible before debugger screenshot')
|
|
}
|
|
const gpu = await captureGpuState(job)
|
|
gpu.windowVisibleAtCapture = windowVisibleAtCapture
|
|
gpu.graphicsSwitchesRequested = job.captureMode.graphicsSwitches || []
|
|
mark('gpu-state-captured-after-window-presentation')
|
|
let candidatePng
|
|
let size
|
|
if (job.captureMode.debuggerScreenshot) {
|
|
const debuggerSession = win.webContents.debugger
|
|
if (debuggerSession.isAttached()) throw new Error('Fixture webContents debugger unexpectedly already attached')
|
|
debuggerSession.attach('1.3')
|
|
mark('debugger-attached')
|
|
try {
|
|
await debuggerSession.sendCommand('Page.enable')
|
|
mark('debugger-page-enabled')
|
|
const captured = await Promise.race([
|
|
debuggerSession.sendCommand('Page.captureScreenshot', {
|
|
format: 'png',
|
|
fromSurface: true,
|
|
captureBeyondViewport: false,
|
|
}),
|
|
delay(15000).then(() => { throw new Error('Timed out waiting for Page.captureScreenshot on the hidden fixture window') }),
|
|
])
|
|
mark('debugger-page-screenshot-captured')
|
|
candidatePng = Buffer.from(captured.data, 'base64')
|
|
} finally {
|
|
if (debuggerSession.isAttached()) debuggerSession.detach()
|
|
}
|
|
if (job.captureMode.closeAfterCapture && !win.isDestroyed()) {
|
|
gpu.windowClosedAfterCapture = await closeAndWait(win)
|
|
if (!gpu.windowClosedAfterCapture) throw new Error('Fixture window did not close after debugger screenshot')
|
|
mark('window-closed-after-capture')
|
|
}
|
|
const decoded = nativeImage.createFromBuffer(candidatePng)
|
|
if (decoded.isEmpty()) throw new Error('nativeImage could not decode the debugger Page.captureScreenshot result')
|
|
size = decoded.getSize()
|
|
mark('debugger-png-decoded')
|
|
} else {
|
|
const image = job.captureMode.captureOptions
|
|
? await win.webContents.capturePage(undefined, job.captureMode.captureOptions)
|
|
: await win.webContents.capturePage()
|
|
size = image.getSize()
|
|
candidatePng = image.toPNG()
|
|
}
|
|
if (size.width !== job.viewport.width || size.height !== job.viewport.height) {
|
|
throw new Error('Candidate PNG size is ' + size.width + 'x' + size.height + ', expected '
|
|
+ job.viewport.width + 'x' + job.viewport.height)
|
|
}
|
|
fs.writeFileSync(job.outputPng, candidatePng)
|
|
mark('capture-written')
|
|
const pixelAnalysis = analyzeReferenceAndCandidate(job, candidatePng)
|
|
finish({ ok: true, calibration, measurements, gpu, viewport: size, pixelAnalysis, consoleErrors, failedRequests })
|
|
} catch (error) {
|
|
if (job.captureMode.closeAfterCapture && win && !win.isDestroyed()) {
|
|
await closeAndWait(win)
|
|
mark('window-closed-after-failed-capture')
|
|
}
|
|
finish({ ok: false, error: String(error && (error.stack || error.message) || error), consoleErrors, failedRequests }, 1)
|
|
}
|
|
}).catch(fail)
|
|
} catch (error) {
|
|
fail(error)
|
|
}
|
|
`
|
|
|
|
function fail(message) {
|
|
throw new Error(message)
|
|
}
|
|
|
|
function readJson(file) {
|
|
return fs.readFile(file, 'utf8').then(JSON.parse)
|
|
}
|
|
|
|
function findByClass(nodes, className) {
|
|
const node = nodes.find(entry => (entry.attributes?.class ?? '').split(/\s+/).includes(className))
|
|
if (!node) fail(`REA layout is missing .${className}`)
|
|
return node
|
|
}
|
|
|
|
function textWithin(nodes, root) {
|
|
const children = new Map()
|
|
for (const node of nodes) {
|
|
const list = children.get(node.parent_index) ?? []
|
|
list.push(node)
|
|
children.set(node.parent_index, list)
|
|
}
|
|
const visit = node => node.node_name === '#text'
|
|
? node.text ?? ''
|
|
: (children.get(node.node_index) ?? []).map(visit).join('')
|
|
return visit(root)
|
|
}
|
|
|
|
function isDescendant(nodes, node, ancestorIndex) {
|
|
const byIndex = new Map(nodes.map(entry => [entry.node_index, entry]))
|
|
let parent = node.parent_index
|
|
while (parent !== undefined && parent !== null && parent >= 0) {
|
|
if (parent === ancestorIndex) return true
|
|
parent = byIndex.get(parent)?.parent_index
|
|
}
|
|
return false
|
|
}
|
|
|
|
function integerRegion(bounds, viewport) {
|
|
const [rawX, rawY, rawWidth, rawHeight] = bounds
|
|
const x = Math.floor(rawX)
|
|
const y = Math.floor(rawY)
|
|
const right = Math.ceil(rawX + rawWidth)
|
|
const bottom = Math.ceil(rawY + rawHeight)
|
|
const region = { x, y, width: right - x, height: bottom - y }
|
|
if (x < 0 || y < 0 || right > viewport.width || bottom > viewport.height || region.width < 1 || region.height < 1) {
|
|
fail(`REA target bounds do not fit in the ${viewport.width}x${viewport.height} viewport`)
|
|
}
|
|
return region
|
|
}
|
|
|
|
async function authenticateEvidence(raw) {
|
|
const moduleUrl = pathToFileURL(path.join(runtimeRoot, 'domain/evidence.js'))
|
|
let parseEvidence
|
|
try {
|
|
({ parseEvidence } = await import(moduleUrl))
|
|
} catch (error) {
|
|
fail(`Prepared REA runtime is required to authenticate the greeting archive: ${error.message}`)
|
|
}
|
|
return parseEvidence(raw)
|
|
}
|
|
|
|
async function loadReference(directory) {
|
|
const reportPath = path.join(directory, 'font-platform-report.json')
|
|
const layoutPath = path.join(directory, 'layout.json')
|
|
const screenshotPath = path.join(directory, 'workbuddy.png')
|
|
const [report, layout, screenshotBytes] = await Promise.all([
|
|
readJson(reportPath),
|
|
readJson(layoutPath),
|
|
fs.readFile(screenshotPath),
|
|
])
|
|
if (report.source?.kind !== 'rea-workbuddy') fail('Greeting font report must identify rea-workbuddy as its source')
|
|
if (!/^ev_[a-f0-9]{64}$/.test(report.evidenceId ?? '')) fail('Greeting report has an invalid Evidence ID')
|
|
if (report.evidenceId !== layout.evidenceId) fail('Greeting layout and font report Evidence IDs differ')
|
|
if (report.version === 'unobserved') fail('Greeting report does not record an observed WorkBuddy version')
|
|
if (!/^[a-f0-9]{64}$/i.test(report.evidenceSha256 ?? '')) fail('Greeting report has no valid archive SHA-256')
|
|
const archivePath = path.resolve(projectRoot, report.archive)
|
|
const expectedArchive = path.resolve(projectRoot, '.tmp/rea-css-evidence', `${report.evidenceId}.json`)
|
|
if (process.platform === 'win32'
|
|
? archivePath.toLowerCase() !== expectedArchive.toLowerCase()
|
|
: archivePath !== expectedArchive) fail('Greeting report archive path is not the archive named by its Evidence ID')
|
|
if (layout.archive && path.resolve(projectRoot, layout.archive).toLowerCase() !== expectedArchive.toLowerCase()) {
|
|
fail('Greeting layout archive does not match the font report Evidence ID')
|
|
}
|
|
const archiveBytes = await fs.readFile(archivePath)
|
|
const evidenceSha256 = hash(archiveBytes)
|
|
if (evidenceSha256 !== report.evidenceSha256.toLowerCase()) fail('Greeting archive SHA-256 does not match its font report')
|
|
const archive = JSON.parse(archiveBytes.toString('utf8'))
|
|
if (archive.evidence_id !== report.evidenceId) fail('Greeting archive Evidence ID does not match its font report')
|
|
const evidence = await authenticateEvidence(archive)
|
|
if (evidence.operation !== 'inspect_electron_page' || evidence.provider?.id !== 'rea-cdp-electron') {
|
|
fail('Greeting archive is not an authenticated REA Electron page observation')
|
|
}
|
|
const target = evidence.normalized_result?.target?.file_path ?? ''
|
|
if (!/WorkBuddy[\\/]resources[\\/]app\.asar[\\/]renderer[\\/]index\.html$/i.test(target)) {
|
|
fail('Greeting Evidence target is not the WorkBuddy renderer')
|
|
}
|
|
if (evidence.normalized_result?.browser?.user_agent?.match(/WorkBuddy[ /]([\d.]+)/i)?.[1] !== report.version) {
|
|
fail('Greeting report version does not match the archived WorkBuddy user agent')
|
|
}
|
|
const archiveVisuals = evidence.normalized_result?.visuals
|
|
if (!Array.isArray(archiveVisuals?.font_samples)) fail('Greeting archive has no font sample observation')
|
|
assert.deepEqual(report.samples, archiveVisuals.font_samples, 'font report samples differ from authenticated REA archive')
|
|
assert.deepEqual(report.missing ?? [], archiveVisuals.font_missing ?? [], 'font report missing samples differ from authenticated REA archive')
|
|
if (layout.artifact?.sha256 !== hash(screenshotBytes)) fail('Greeting PNG SHA-256 differs from its layout record')
|
|
const archivedScreenshot = archiveVisuals.artifact
|
|
if (!archivedScreenshot?.data_base64 || hash(Buffer.from(archivedScreenshot.data_base64, 'base64')) !== layout.artifact.sha256) {
|
|
fail('Greeting PNG is not the screenshot stored in authenticated REA Evidence')
|
|
}
|
|
if (layout.viewport?.width !== 1336 || layout.viewport?.height !== 901) fail('Greeting layout viewport must be 1336x901')
|
|
if (!Array.isArray(layout.message_content?.nodes)) fail('Greeting layout has no REA message_content export')
|
|
if (!isDeepEqual(report.samples, layout.font_samples)) fail('Greeting font report samples differ from layout font samples')
|
|
|
|
const nodes = layout.message_content.nodes
|
|
const userRoot = findByClass(nodes, 'cr-user-content')
|
|
const userLeaf = nodes.find(node => node.node_name === '#text' && isDescendant(nodes, node, userRoot.node_index))
|
|
if (!userLeaf) fail('REA message_content has no text leaf under .cr-user-content')
|
|
const assistantRoot = findByClass(nodes, 'cr-agent__content')
|
|
const firstParagraph = nodes.find(node => node.node_name === 'P' && isDescendant(nodes, node, assistantRoot.node_index))
|
|
if (!firstParagraph) fail('REA message_content has no assistant paragraph under .cr-agent__content')
|
|
const userText = textWithin(nodes, userRoot)
|
|
const assistantText = textWithin(nodes, firstParagraph)
|
|
const userSample = report.samples.find(sample => sample.category === 'user-bubble')
|
|
const assistantSample = report.samples.find(sample => sample.category === 'chinese-body')
|
|
if (!userSample || !assistantSample) fail('Greeting report must contain user-bubble and chinese-body samples')
|
|
if (userText !== userSample.text || assistantText !== assistantSample.text) {
|
|
fail('REA message_content text differs from the authenticated greeting font sample')
|
|
}
|
|
|
|
const documentNodes = layout.layout?.documents?.flatMap(document => document.nodes) ?? []
|
|
const userBubbleNode = findByClass(documentNodes, 'cr-self-bubble')
|
|
const agentNode = findByClass(documentNodes, 'cr-agent')
|
|
if (!Array.isArray(userBubbleNode.bounds) || !Array.isArray(agentNode.bounds)) fail('Greeting layout is missing target node bounds')
|
|
if (!Array.isArray(userSample.bounds) || !Array.isArray(assistantSample.bounds)) fail('Greeting font samples are missing target bounds')
|
|
|
|
return {
|
|
reportPath,
|
|
reportSha256: hash(await fs.readFile(reportPath)),
|
|
report,
|
|
layout,
|
|
archivePath,
|
|
evidenceSha256,
|
|
screenshotPath,
|
|
screenshotSha256: hash(screenshotBytes),
|
|
userSample,
|
|
assistantSample,
|
|
userBubbleNode,
|
|
agentNode,
|
|
userText,
|
|
assistantText,
|
|
}
|
|
}
|
|
|
|
function isDeepEqual(left, right) {
|
|
return JSON.stringify(left) === JSON.stringify(right)
|
|
}
|
|
|
|
async function readSourceFlowBasis(reference) {
|
|
if (reference.report.evidenceId !== sourceFlowEvidenceId) {
|
|
fail(`--source-flow-transform requires REA Evidence ${sourceFlowEvidenceId}`)
|
|
}
|
|
const documentNodes = reference.layout.layout?.documents?.flatMap(document => document.nodes) ?? []
|
|
const flowNode = findByClass(documentNodes, 'cr-document__virtual-flow')
|
|
const userBubble = findByClass(documentNodes, 'cr-self-bubble')
|
|
const firstParagraph = documentNodes.find(node => node.node_name === 'P' && isDeepEqual(node.bounds, reference.assistantSample.bounds))
|
|
if (!firstParagraph) fail('Source-flow REA layout has no paragraph matching the selected Chinese-body sample')
|
|
if (flowNode.styles?.transform !== 'matrix(1, 0, 0, 1, 0, 0)') {
|
|
fail('Source-flow REA ancestor transform is not the observed identity matrix')
|
|
}
|
|
if (!isDescendant(documentNodes, userBubble, flowNode.node_index)
|
|
|| !isDescendant(documentNodes, firstParagraph, flowNode.node_index)) {
|
|
fail('Source-flow REA ancestor is not shared by the user bubble and sampled paragraph')
|
|
}
|
|
const cssBytes = await fs.readFile(sourceFlowCssPath)
|
|
const cssText = cssBytes.toString('utf8')
|
|
const expectedRule = '.cr-document__virtual-flow{width:100%;position:absolute;top:0;left:0}'
|
|
if (!cssText.includes(expectedRule)) fail('Recovered REA stylesheet no longer contains the observed virtual-flow positioning rule')
|
|
return {
|
|
evidenceId: reference.report.evidenceId,
|
|
selector: '.cr-document__virtual-flow',
|
|
computedTransform: flowNode.styles.transform,
|
|
bounds: flowNode.bounds,
|
|
sharedBy: ['.cr-self-bubble', 'first sampled chinese-body paragraph'],
|
|
recoveredCssPath: path.relative(projectRoot, sourceFlowCssPath).replaceAll('\\', '/'),
|
|
recoveredCssSha256: hash(cssBytes),
|
|
recoveredCssRule: expectedRule,
|
|
}
|
|
}
|
|
|
|
function entrySource(reference, sourceFlowTransform) {
|
|
const messages = {
|
|
user: {
|
|
id: 'rea-greeting-user', role: 'user', content: reference.userText,
|
|
createdAt: 0, status: 'done',
|
|
},
|
|
assistant: {
|
|
id: 'rea-greeting-assistant', role: 'assistant', content: reference.assistantText,
|
|
parts: [{ type: 'text', text: reference.assistantText }], createdAt: 0, status: 'done',
|
|
},
|
|
}
|
|
const placement = {
|
|
x: reference.agentNode.bounds[0],
|
|
y: reference.agentNode.bounds[1],
|
|
width: reference.assistantSample.bounds[2],
|
|
}
|
|
const userPlacement = {
|
|
x: reference.agentNode.bounds[0],
|
|
y: reference.userBubbleNode.bounds[1],
|
|
width: reference.assistantSample.bounds[2],
|
|
}
|
|
const userFixture = `<div data-parity-fixture="user" style={{ position: 'absolute', left: userPosition.x, top: userPosition.y, width: userPosition.width }}><MessageBubble message={messages.user} /></div>`
|
|
const assistantFixture = `<div data-parity-fixture="assistant" style={{ position: 'absolute', left: assistantPosition.x, top: assistantPosition.y, width: assistantPosition.width }}><MessageBubble message={messages.assistant} /></div>`
|
|
const fixtureNodes = sourceFlowTransform
|
|
? `<div data-parity-source-flow style={{ width: '100%', position: 'absolute', inset: 0, transform: 'translateY(0)' }}>${userFixture}${assistantFixture}</div>`
|
|
: `${userFixture}${assistantFixture}`
|
|
return `import React, { useEffect } from 'react'
|
|
import { createRoot } from 'react-dom/client'
|
|
import MessageBubble from '@renderer/components/MessageBubble'
|
|
import '@renderer/workbuddy/typography/tokens.css'
|
|
import '@renderer/styles/tokens.css'
|
|
import '@renderer/styles/app.css'
|
|
import '@renderer/styles/sidebar.css'
|
|
import '@renderer/workbuddy/conversation/source-styles.css'
|
|
import '@renderer/workbuddy/conversation/source-adapters.css'
|
|
|
|
const messages = ${JSON.stringify(messages)}
|
|
const assistantPosition = ${JSON.stringify(placement)}
|
|
const userPosition = ${JSON.stringify(userPlacement)}
|
|
|
|
function Fixture() {
|
|
useEffect(() => {
|
|
document.body.dataset.electronDesktop = 'true'
|
|
document.body.dataset.platform = 'windows'
|
|
void document.fonts.ready.then(() => requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
document.documentElement.dataset.messageParityReady = 'true'
|
|
})))
|
|
}, [])
|
|
return <main className="message-parity-stage" data-parity-stage>
|
|
<section className="chat-pane cr-theme" data-cr-theme="light" aria-label="Isolated YCode message components">
|
|
${fixtureNodes}
|
|
</section>
|
|
</main>
|
|
}
|
|
|
|
const stageStyle = document.createElement('style')
|
|
stageStyle.textContent = '#root{width:1336px;height:901px}.message-parity-stage{position:relative;width:1336px;height:901px;overflow:hidden;background:#fff}.chat-pane.cr-theme{position:absolute;inset:0;width:100%;height:100%;overflow:hidden;background:#fff}'
|
|
document.head.append(stageStyle)
|
|
createRoot(document.getElementById('root')).render(<Fixture />)
|
|
`
|
|
}
|
|
|
|
async function runElectronHelper(job) {
|
|
const helperDirectory = await fs.mkdtemp(path.join(projectRoot, '.tmp/rea-message-parity-helper-'))
|
|
const helperPath = path.join(helperDirectory, 'main.cjs')
|
|
const packagePath = path.join(helperDirectory, 'package.json')
|
|
const jobPath = path.join(helperDirectory, 'job.json')
|
|
const resultPath = path.join(helperDirectory, 'result.json')
|
|
const logPath = path.join(helperDirectory, 'progress.log')
|
|
await fs.writeFile(helperPath, electronHelperSource, 'utf8')
|
|
await fs.writeFile(packagePath, JSON.stringify({ name: 'ycode-message-parity-capture', version: '1.0.0', main: 'main.cjs' }), 'utf8')
|
|
await fs.writeFile(jobPath, JSON.stringify(job), 'utf8')
|
|
const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => name.toUpperCase() !== 'ELECTRON_RUN_AS_NODE'))
|
|
env.YCODE_MESSAGE_PARITY_JOB = jobPath
|
|
env.YCODE_MESSAGE_PARITY_RESULT = resultPath
|
|
env.YCODE_MESSAGE_PARITY_LOG = logPath
|
|
try {
|
|
const completion = await new Promise((resolve, reject) => {
|
|
const electronArgs = ['--no-sandbox', '--disable-extensions']
|
|
if (job.captureMode.disableGpuSwitch) electronArgs.push('--disable-gpu')
|
|
electronArgs.push(...(job.captureMode.graphicsSwitches || []))
|
|
electronArgs.push(helperDirectory)
|
|
const child = spawn(electronExecutable, electronArgs, {
|
|
cwd: helperDirectory,
|
|
env,
|
|
windowsHide: true,
|
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
})
|
|
let stderr = ''
|
|
let settled = false
|
|
const timer = setTimeout(() => {
|
|
child.kill()
|
|
if (!settled) {
|
|
settled = true
|
|
let progress = ''
|
|
try { progress = require('node:fs').readFileSync(logPath, 'utf8') } catch {}
|
|
reject(new Error(`Hidden Electron message parity capture timed out${progress ? `; helper progress:\n${progress}` : '; helper did not write progress'}${stderr ? `; stderr:\n${stderr.trim()}` : ''}`))
|
|
}
|
|
}, 60000)
|
|
timer.unref()
|
|
child.stderr.on('data', chunk => { stderr += chunk.toString() })
|
|
child.once('error', error => {
|
|
clearTimeout(timer)
|
|
if (!settled) { settled = true; reject(error) }
|
|
})
|
|
child.once('close', (code, signal) => {
|
|
clearTimeout(timer)
|
|
if (settled) return
|
|
settled = true
|
|
resolve({ code, signal, stderr })
|
|
})
|
|
})
|
|
let result
|
|
try { result = JSON.parse(await fs.readFile(resultPath, 'utf8')) } catch {
|
|
throw new Error(`Hidden Electron exited without a result (code ${completion.code}${completion.stderr ? `): ${completion.stderr.trim()}` : ')'}`)
|
|
}
|
|
if (!result.ok || completion.code !== 0) throw new Error(result.error || `Hidden Electron failed (code ${completion.code})`)
|
|
if (result.consoleErrors.length || result.failedRequests.length) {
|
|
throw new Error(`Fixture reported renderer errors: ${JSON.stringify({ consoleErrors: result.consoleErrors, failedRequests: result.failedRequests })}`)
|
|
}
|
|
return result
|
|
} finally {
|
|
let cleanupError
|
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
try {
|
|
await fs.rm(helperDirectory, { recursive: true, force: true, maxRetries: 2, retryDelay: 150 })
|
|
cleanupError = undefined
|
|
break
|
|
} catch (error) {
|
|
cleanupError = error
|
|
await new Promise(resolve => setTimeout(resolve, 150))
|
|
}
|
|
}
|
|
if (cleanupError) process.stderr.write(`Temporary Electron helper cleanup failed: ${helperDirectory}: ${cleanupError.message}\n`)
|
|
}
|
|
}
|
|
|
|
async function startFixtureServer(reference, sourceFlowTransform) {
|
|
const entryPath = path.join(projectRoot, '.tmp', `rea-message-parity-entry-${process.pid}.tsx`)
|
|
const htmlPath = path.join(rendererRoot, `__rea-message-parity-${process.pid}.html`)
|
|
await Promise.all([entryPath, htmlPath].map(async file => {
|
|
try { await fs.access(file); fail(`Refusing to overwrite existing temporary fixture: ${file}`) }
|
|
catch (error) { if (error.code !== 'ENOENT') throw error }
|
|
}))
|
|
const source = entrySource(reference, sourceFlowTransform)
|
|
const entryUrl = `/@fs/${encodeURI(entryPath.replaceAll('\\', '/'))}`
|
|
const html = `<!doctype html><html class="light"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>YCode message parity fixture</title></head><body class="light agent-ui-theme"><div id="root"></div><script type="module" src="${entryUrl}"></script></body></html>`
|
|
await fs.writeFile(entryPath, source, 'utf8')
|
|
await fs.writeFile(htmlPath, html, 'utf8')
|
|
|
|
const server = await createServer({
|
|
configFile: false,
|
|
root: rendererRoot,
|
|
appType: 'mpa',
|
|
plugins: [react()],
|
|
resolve: {
|
|
alias: {
|
|
'@renderer': path.join(rendererRoot, 'src'),
|
|
'@shared': path.join(projectRoot, 'src/shared'),
|
|
},
|
|
},
|
|
server: {
|
|
host: '127.0.0.1',
|
|
port: 0,
|
|
strictPort: false,
|
|
hmr: false,
|
|
fs: { allow: [projectRoot] },
|
|
},
|
|
logLevel: 'error',
|
|
})
|
|
try {
|
|
await server.listen()
|
|
const address = server.httpServer?.address()
|
|
if (!address || typeof address === 'string') fail('Vite did not expose an HTTP address for the component fixture')
|
|
const url = `http://127.0.0.1:${address.port}/__rea-message-parity-${process.pid}.html`
|
|
const response = await fetch(url)
|
|
if (!response.ok) fail(`Vite fixture health check returned HTTP ${response.status}`)
|
|
const htmlText = await response.text()
|
|
if (!htmlText.includes(entryUrl)) fail('Vite did not serve the generated fixture HTML')
|
|
const entryResponse = await fetch(new URL(entryUrl, url))
|
|
if (!entryResponse.ok) fail(`Vite component entry transform returned HTTP ${entryResponse.status}: ${(await entryResponse.text()).slice(0, 500)}`)
|
|
return { server, entryPath, htmlPath, url }
|
|
} catch (error) {
|
|
await server.close()
|
|
await Promise.all([entryPath, htmlPath].map(file => fs.rm(file, { force: true })))
|
|
throw error
|
|
}
|
|
}
|
|
|
|
function boundsArray(box) {
|
|
return [box.x, box.y, box.width, box.height]
|
|
}
|
|
|
|
function inspectPng(bytes) {
|
|
const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
|
|
if (bytes.length < 8 || !bytes.subarray(0, 8).equals(signature)) fail('PNG metadata inspection received a non-PNG file')
|
|
const chunks = []
|
|
let offset = 8
|
|
let dimensions
|
|
const colorChunkNames = new Set(['iCCP', 'sRGB', 'gAMA', 'cHRM', 'cICP', 'mDCv', 'cLLi'])
|
|
while (offset + 12 <= bytes.length) {
|
|
const length = bytes.readUInt32BE(offset)
|
|
const type = bytes.toString('ascii', offset + 4, offset + 8)
|
|
const dataStart = offset + 8
|
|
const dataEnd = dataStart + length
|
|
const crcEnd = dataEnd + 4
|
|
if (crcEnd > bytes.length) fail(`PNG ${type} chunk exceeds its file length`)
|
|
const data = bytes.subarray(dataStart, dataEnd)
|
|
const chunk = {
|
|
type,
|
|
bytes: length,
|
|
crc32: bytes.readUInt32BE(dataEnd).toString(16).padStart(8, '0'),
|
|
}
|
|
if (type === 'IHDR' && length === 13) {
|
|
dimensions = {
|
|
width: data.readUInt32BE(0),
|
|
height: data.readUInt32BE(4),
|
|
bitDepth: data[8],
|
|
colorType: data[9],
|
|
compression: data[10],
|
|
filter: data[11],
|
|
interlace: data[12],
|
|
}
|
|
}
|
|
if (colorChunkNames.has(type)) {
|
|
chunk.dataSha256 = hash(data)
|
|
if (type === 'iCCP') {
|
|
const separator = data.indexOf(0)
|
|
chunk.profileName = separator >= 0 ? data.toString('latin1', 0, separator) : null
|
|
chunk.compressionMethod = separator >= 0 ? data[separator + 1] : null
|
|
} else if (type === 'sRGB') {
|
|
chunk.renderingIntent = data[0]
|
|
} else if (type === 'gAMA' && data.length === 4) {
|
|
chunk.gammaTimes100000 = data.readUInt32BE(0)
|
|
} else if (type === 'cHRM' && data.length === 32) {
|
|
chunk.chromaticitiesTimes100000 = Array.from({ length: 8 }, (_, index) => data.readUInt32BE(index * 4))
|
|
} else if (type === 'cICP' && data.length === 4) {
|
|
chunk.cicp = Array.from(data)
|
|
}
|
|
}
|
|
chunks.push(chunk)
|
|
offset = crcEnd
|
|
if (type === 'IEND') break
|
|
}
|
|
if (!dimensions || chunks.at(-1)?.type !== 'IEND') fail('PNG is missing a valid IHDR or IEND chunk')
|
|
const colorProfileChunks = chunks.filter(chunk => colorChunkNames.has(chunk.type))
|
|
return {
|
|
sha256: hash(bytes),
|
|
bytes: bytes.length,
|
|
dimensions,
|
|
chunkTypes: chunks.map(chunk => chunk.type),
|
|
chunks,
|
|
colorProfileChunks,
|
|
}
|
|
}
|
|
|
|
function parseArgs(args) {
|
|
let outputDirectory = '.tmp/rea-visual-greeting'
|
|
let mode = 'offscreen-disabled-gpu'
|
|
let suffix = null
|
|
let sourceFlowTransform = false
|
|
let hasOutputDirectory = false
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const value = args[index]
|
|
if (value === '--mode' || value === '--capture-mode') {
|
|
mode = args[++index]
|
|
if (!mode || !captureModes[mode]) fail(`Unsupported capture mode: ${mode ?? 'missing'}`)
|
|
} else if (value === '--suffix') {
|
|
suffix = args[++index]
|
|
if (!suffix || !/^[a-z0-9](?:[a-z0-9-]{0,47})$/i.test(suffix)) fail('Capture suffix must be 1-48 alphanumeric/hyphen characters')
|
|
} else if (value === '--source-flow-transform') {
|
|
sourceFlowTransform = true
|
|
} else if (value === '--help') {
|
|
return { help: true }
|
|
} else if (value.startsWith('--')) {
|
|
fail(`Unknown option: ${value}`)
|
|
} else if (!hasOutputDirectory) {
|
|
outputDirectory = value
|
|
hasOutputDirectory = true
|
|
} else {
|
|
fail(`Unexpected positional argument: ${value}`)
|
|
}
|
|
}
|
|
return { outputDirectory, captureMode: captureModes[mode], suffix, sourceFlowTransform }
|
|
}
|
|
|
|
async function main() {
|
|
const cli = parseArgs(process.argv.slice(2))
|
|
if (cli.help) {
|
|
console.log('Usage: node scripts/rea/capture-ycode-message-parity.mjs [output-directory] [--mode offscreen-disabled-gpu|hidden-hardware|hidden-debugger|visible-debugger|visible-swiftshader] [--suffix label] [--source-flow-transform]')
|
|
return
|
|
}
|
|
if (cli.sourceFlowTransform && cli.captureMode.id !== 'visible-debugger') {
|
|
fail('--source-flow-transform requires --mode visible-debugger')
|
|
}
|
|
const outputDirectory = path.resolve(cli.outputDirectory)
|
|
const captureMode = cli.captureMode
|
|
const reference = await loadReference(outputDirectory)
|
|
const sourceFlowBasis = cli.sourceFlowTransform ? await readSourceFlowBasis(reference) : null
|
|
const viewport = reference.layout.viewport
|
|
const userBubbleBounds = reference.userBubbleNode.bounds
|
|
const firstParagraphBounds = reference.assistantSample.bounds
|
|
const regionUser = integerRegion(userBubbleBounds, viewport)
|
|
const regionParagraph = integerRegion(firstParagraphBounds, viewport)
|
|
const archiveRel = path.relative(outputDirectory, reference.archivePath).replaceAll('\\', '/')
|
|
const referenceEvidence = { id: reference.report.evidenceId, archive: archiveRel }
|
|
const outputLabel = cli.suffix ?? (cli.sourceFlowTransform
|
|
? `${captureMode.id}-source-flow-transform`
|
|
: captureMode.id === 'offscreen-disabled-gpu' ? null : captureMode.id)
|
|
const suffix = outputLabel ? `-${outputLabel}` : ''
|
|
const outputPng = path.join(outputDirectory, `ycode-message-parity${suffix}.png`)
|
|
const layoutReportPath = path.join(outputDirectory, `ycode-message-parity${suffix}-layout.json`)
|
|
const manifestPath = path.join(outputDirectory, `ycode-message-parity${suffix}-manifest.json`)
|
|
const captureAnalysisPath = path.join(outputDirectory, `ycode-message-parity${suffix}-capture-analysis.json`)
|
|
const captureAttemptPath = path.join(outputDirectory, `ycode-message-parity${suffix}-capture-attempt.json`)
|
|
const serverHandle = await startFixtureServer(reference, cli.sourceFlowTransform)
|
|
try {
|
|
let result
|
|
try {
|
|
result = await runElectronHelper({
|
|
url: serverHandle.url,
|
|
viewport,
|
|
outputPng,
|
|
captureMode,
|
|
referencePng: reference.screenshotPath,
|
|
pixelRegions: { userBubble: regionUser, firstParagraph: regionParagraph },
|
|
anchors: [
|
|
{
|
|
name: 'user-bubble',
|
|
hostSelector: '[data-parity-fixture="user"]',
|
|
targetSelector: '.cr-self-bubble',
|
|
referenceBounds: { x: userBubbleBounds[0], y: userBubbleBounds[1], width: userBubbleBounds[2], height: userBubbleBounds[3] },
|
|
},
|
|
{
|
|
name: 'first-paragraph',
|
|
hostSelector: '[data-parity-fixture="assistant"]',
|
|
targetSelector: '.cr-agent__content .cr-markdown > p:first-child',
|
|
referenceBounds: { x: firstParagraphBounds[0], y: firstParagraphBounds[1], width: firstParagraphBounds[2], height: firstParagraphBounds[3] },
|
|
},
|
|
],
|
|
})
|
|
} catch (error) {
|
|
if (captureMode.debuggerScreenshot) {
|
|
const attempt = {
|
|
status: 'incomplete',
|
|
reason: 'No candidate PNG was produced; a zero-tolerance pixel comparison cannot be run.',
|
|
source: {
|
|
kind: 'rea-workbuddy',
|
|
version: reference.report.version,
|
|
evidenceId: reference.report.evidenceId,
|
|
archive: path.relative(projectRoot, reference.archivePath).replaceAll('\\', '/'),
|
|
archiveSha256: reference.evidenceSha256,
|
|
screenshotSha256: reference.screenshotSha256,
|
|
},
|
|
capture: {
|
|
mode: captureMode.id,
|
|
offscreen: captureMode.offscreen,
|
|
hardwareAccelerationDisabledByApp: captureMode.disableHardwareAcceleration,
|
|
disableGpuSwitchRequested: captureMode.disableGpuSwitch,
|
|
showInactiveBeforeCapture: captureMode.showInactiveBeforeCapture === true,
|
|
closeAfterCapture: captureMode.closeAfterCapture === true,
|
|
captureMethod: 'Page.captureScreenshot({ format: png, fromSurface: true, captureBeyondViewport: false }) on the owned fixture webContents debugger',
|
|
},
|
|
candidatePath: path.relative(outputDirectory, outputPng).replaceAll('\\', '/'),
|
|
error: String(error && (error.stack || error.message) || error),
|
|
requiredRegions: { userBubble: regionUser, firstParagraph: regionParagraph },
|
|
}
|
|
await fs.writeFile(captureAttemptPath, `${JSON.stringify(attempt, null, 2)}\n`)
|
|
}
|
|
throw error
|
|
}
|
|
const candidateBytes = await fs.readFile(outputPng)
|
|
const candidateSha256 = hash(candidateBytes)
|
|
const referenceBytes = await fs.readFile(reference.screenshotPath)
|
|
const referencePngInfo = inspectPng(referenceBytes)
|
|
const candidatePngInfo = inspectPng(candidateBytes)
|
|
const referenceColorChunks = JSON.stringify(referencePngInfo.colorProfileChunks)
|
|
const candidateColorChunks = JSON.stringify(candidatePngInfo.colorProfileChunks)
|
|
const captureAnalysis = {
|
|
scope: 'isolated-component-layout-calibration',
|
|
acceptance: 'not-full-app-acceptance',
|
|
source: {
|
|
kind: 'rea-workbuddy',
|
|
evidenceId: reference.report.evidenceId,
|
|
archive: path.relative(projectRoot, reference.archivePath).replaceAll('\\', '/'),
|
|
archiveSha256: reference.evidenceSha256,
|
|
screenshotSha256: reference.screenshotSha256,
|
|
},
|
|
capture: result.gpu,
|
|
png: {
|
|
reference: referencePngInfo,
|
|
candidate: candidatePngInfo,
|
|
comparison: {
|
|
sameChunkTypes: JSON.stringify(referencePngInfo.chunkTypes) === JSON.stringify(candidatePngInfo.chunkTypes),
|
|
sameColorProfileChunks: referenceColorChunks === candidateColorChunks,
|
|
},
|
|
},
|
|
nativeImagePixels: result.pixelAnalysis,
|
|
sourceFlowTransformExperiment: sourceFlowBasis ? {
|
|
applied: true,
|
|
basis: sourceFlowBasis,
|
|
ycodeWrapper: result.measurements.sourceFlowWrapper,
|
|
note: 'This applies only the observed identity transform and absolute-flow wrapper; it does not reproduce WorkBuddy virtualization or list positioning.',
|
|
} : null,
|
|
limitations: [
|
|
'Color analysis uses the integer comparison regions and Electron nativeImage decoded bitmaps; the modal region color is used as a background estimate.',
|
|
'Chromatic edge counts are diagnostic samples, not proof of LCD subpixel rendering; GPU status is recorded separately.',
|
|
'Only the declared user-bubble and first-paragraph regions are analyzed.',
|
|
],
|
|
}
|
|
const rootCommonWidth = reference.assistantSample.bounds[2]
|
|
const layoutReport = {
|
|
schemaVersion: 1,
|
|
scope: 'isolated-component-layout-calibration',
|
|
acceptance: 'not-full-app-acceptance',
|
|
source: {
|
|
kind: 'rea-workbuddy',
|
|
version: reference.report.version,
|
|
evidenceId: reference.report.evidenceId,
|
|
archive: path.relative(projectRoot, reference.archivePath).replaceAll('\\', '/'),
|
|
archiveSha256: reference.evidenceSha256,
|
|
fontReport: path.relative(projectRoot, reference.reportPath).replaceAll('\\', '/'),
|
|
fontReportSha256: reference.reportSha256,
|
|
screenshotSha256: reference.screenshotSha256,
|
|
},
|
|
fixture: {
|
|
components: ['MessageBubble', 'MarkdownContent'],
|
|
styles: [
|
|
'src/renderer/src/workbuddy/typography/tokens.css',
|
|
'src/renderer/src/styles/tokens.css',
|
|
'src/renderer/src/styles/app.css',
|
|
'src/renderer/src/styles/sidebar.css',
|
|
'src/renderer/src/workbuddy/conversation/source-styles.css',
|
|
'src/renderer/src/workbuddy/conversation/source-adapters.css',
|
|
'src/renderer/src/styles/markdown.css (imported by MarkdownContent)',
|
|
],
|
|
viewport,
|
|
environment: {
|
|
userAgent: result.measurements.userAgent,
|
|
devicePixelRatio: result.measurements.viewport.devicePixelRatio,
|
|
fontStatus: result.measurements.readyFonts,
|
|
capture: result.gpu,
|
|
},
|
|
availableWidthPx: rootCommonWidth,
|
|
positioning: {
|
|
userBubble: result.calibration['user-bubble'],
|
|
firstParagraph: result.calibration['first-paragraph'],
|
|
widthSource: 'REA assistant content sample bounds; width applied only to outer fixture host',
|
|
targetHeightOrFontOverridden: false,
|
|
sourceFlowTransformExperiment: sourceFlowBasis ? {
|
|
applied: true,
|
|
source: sourceFlowBasis,
|
|
wrapperStyles: { width: '100%', position: 'absolute', inset: '0px', transform: 'translateY(0)' },
|
|
measuredWrapper: result.measurements.sourceFlowWrapper,
|
|
targetHeightOrFontOverridden: false,
|
|
limitation: 'This models only the observed neutral ancestor transform and positioning rule, not conversation virtualization or its runtime behavior.',
|
|
} : null,
|
|
},
|
|
},
|
|
samples: {
|
|
userBubble: {
|
|
text: reference.userText,
|
|
reference: {
|
|
bubble: { bounds: userBubbleBounds, computedStyle: reference.userBubbleNode.styles },
|
|
text: { bounds: reference.userSample.bounds, computedStyle: reference.userSample.styles },
|
|
platformFonts: reference.userSample.platformFonts,
|
|
region: regionUser,
|
|
},
|
|
ycode: result.measurements.userBubble,
|
|
},
|
|
firstParagraph: {
|
|
text: reference.assistantText,
|
|
reference: {
|
|
bounds: firstParagraphBounds,
|
|
computedStyle: reference.assistantSample.styles,
|
|
platformFonts: reference.assistantSample.platformFonts,
|
|
region: regionParagraph,
|
|
},
|
|
ycode: result.measurements.firstParagraph,
|
|
},
|
|
},
|
|
candidate: {
|
|
path: path.relative(outputDirectory, outputPng).replaceAll('\\', '/'),
|
|
sha256: candidateSha256,
|
|
bytes: candidateBytes.length,
|
|
dimensions: result.viewport,
|
|
},
|
|
captureAnalysis: path.relative(outputDirectory, captureAnalysisPath).replaceAll('\\', '/'),
|
|
comparisonManifest: path.relative(outputDirectory, manifestPath).replaceAll('\\', '/'),
|
|
limitations: [
|
|
'Only the user bubble and first assistant paragraph are rendered and compared.',
|
|
'This isolates real YCode React components and local CSS; it does not assess conversation sequencing, surrounding controls, or full application parity.',
|
|
'The fixture aligns each measured target by changing only its outer container top and gives both samples the REA content width. Component height, font-family, font-size, line-height, and weight are not forced.',
|
|
'English body and code are absent from this REA greeting sample and remain unverified.',
|
|
],
|
|
}
|
|
const manifest = {
|
|
source: { kind: 'rea-workbuddy' },
|
|
requiredStates: ['user-bubble', 'first-paragraph'],
|
|
channelTolerance: 0,
|
|
pairs: [
|
|
{
|
|
name: 'user-bubble',
|
|
reference: path.relative(outputDirectory, reference.screenshotPath).replaceAll('\\', '/'),
|
|
candidate: path.relative(outputDirectory, outputPng).replaceAll('\\', '/'),
|
|
region: regionUser,
|
|
referenceEvidence,
|
|
},
|
|
{
|
|
name: 'first-paragraph',
|
|
reference: path.relative(outputDirectory, reference.screenshotPath).replaceAll('\\', '/'),
|
|
candidate: path.relative(outputDirectory, outputPng).replaceAll('\\', '/'),
|
|
region: regionParagraph,
|
|
referenceEvidence,
|
|
},
|
|
],
|
|
}
|
|
await Promise.all([
|
|
fs.writeFile(layoutReportPath, `${JSON.stringify(layoutReport, null, 2)}\n`),
|
|
fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`),
|
|
fs.writeFile(captureAnalysisPath, `${JSON.stringify(captureAnalysis, null, 2)}\n`),
|
|
])
|
|
console.log(JSON.stringify({
|
|
scope: layoutReport.scope,
|
|
acceptance: layoutReport.acceptance,
|
|
version: reference.report.version,
|
|
evidenceId: reference.report.evidenceId,
|
|
archiveSha256: reference.evidenceSha256,
|
|
candidate: { path: path.relative(projectRoot, outputPng).replaceAll('\\', '/'), sha256: candidateSha256, dimensions: result.viewport },
|
|
capture: result.gpu,
|
|
layoutReport: path.relative(projectRoot, layoutReportPath).replaceAll('\\', '/'),
|
|
manifest: path.relative(projectRoot, manifestPath).replaceAll('\\', '/'),
|
|
captureAnalysis: path.relative(projectRoot, captureAnalysisPath).replaceAll('\\', '/'),
|
|
sourceFlowTransformExperiment: sourceFlowBasis ? {
|
|
applied: true,
|
|
recoveredCssSha256: sourceFlowBasis.recoveredCssSha256,
|
|
computedTransform: sourceFlowBasis.computedTransform,
|
|
ycodeWrapperTransform: result.measurements.sourceFlowWrapper?.computedStyle?.transform,
|
|
limitation: 'Only the observed ancestor transform is modeled; this is not a virtualization recreation.',
|
|
} : null,
|
|
pngMetadataComparison: captureAnalysis.png.comparison,
|
|
nativeImagePixels: captureAnalysis.nativeImagePixels,
|
|
regions: { userBubble: regionUser, firstParagraph: regionParagraph },
|
|
ycode: {
|
|
userBubble: result.measurements.userBubble,
|
|
firstParagraph: result.measurements.firstParagraph,
|
|
},
|
|
}, null, 2))
|
|
} finally {
|
|
await serverHandle.server.close()
|
|
await Promise.all([serverHandle.entryPath, serverHandle.htmlPath].map(file => fs.rm(file, { force: true })))
|
|
}
|
|
}
|
|
|
|
main().catch(error => {
|
|
process.stderr.write(`${error.stack || error.message || error}\n`)
|
|
process.exitCode = 1
|
|
})
|