582 lines
29 KiB
JavaScript
582 lines
29 KiB
JavaScript
import { createRequire } from 'node:module'
|
|
import { spawn } from 'node:child_process'
|
|
import { createServer } from 'node:net'
|
|
import { createHash } from 'node:crypto'
|
|
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { isDeepStrictEqual } from 'node:util'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
import { setTimeout as delay } from 'node:timers/promises'
|
|
|
|
const require = createRequire(import.meta.url)
|
|
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
|
|
const referencePath = path.resolve(projectRoot, process.argv[2] ?? '.tmp/rea-font-platform-report.json')
|
|
const resultPath = path.resolve(projectRoot, process.argv[3] ?? '.tmp/ycode-font-platform-report.json')
|
|
const requiredCategories = ['assistant-name', 'chinese-body', 'english-body', 'user-bubble', 'code']
|
|
|
|
function fail(message) {
|
|
throw new Error(message)
|
|
}
|
|
|
|
function categoryOf(value) {
|
|
const key = String(value ?? '').toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]/g, '')
|
|
if (/assistant.*name|agent.*name|助手名称|助手名/.test(key)) return 'assistant-name'
|
|
if (/chinese.*body|body.*chinese|zh.*body|中文正文|中文文本/.test(key)) return 'chinese-body'
|
|
if (/english.*body|body.*english|en.*body|英文正文|英文文本/.test(key)) return 'english-body'
|
|
if (/user.*bubble|bubble.*user|用户气泡|用户消息/.test(key)) return 'user-bubble'
|
|
if (/code|代码块|代码/.test(key)) return 'code'
|
|
return null
|
|
}
|
|
|
|
function normalizeFonts(fonts, label) {
|
|
if (!Array.isArray(fonts) || fonts.length === 0) fail(`${label}: platformFonts is empty; no actual glyph usage was recorded`)
|
|
return fonts.map((font) => {
|
|
const familyName = font.familyName ?? font.family ?? font.name
|
|
const postScriptName = font.postScriptName ?? font.postscriptName ?? font.postScript ?? null
|
|
const isCustomFont = font.isCustomFont ?? font.customFont ?? font.custom ?? false
|
|
const glyphCount = Number(font.glyphCount ?? font.glyphs)
|
|
if (!familyName || !Number.isInteger(glyphCount) || glyphCount < 0) {
|
|
fail(`${label}: each platform font needs familyName and an integer glyphCount`)
|
|
}
|
|
return { familyName: String(familyName), postScriptName: postScriptName == null ? null : String(postScriptName), isCustomFont: Boolean(isCustomFont), glyphCount }
|
|
}).sort(compareFonts)
|
|
}
|
|
|
|
function compareFonts(left, right) {
|
|
return `${left.familyName}\0${left.postScriptName ?? ''}\0${left.isCustomFont}\0${left.glyphCount}`
|
|
.localeCompare(`${right.familyName}\0${right.postScriptName ?? ''}\0${right.isCustomFont}\0${right.glyphCount}`)
|
|
}
|
|
|
|
function canonicalJson(value) {
|
|
if (value === null || typeof value !== 'object') return JSON.stringify(value)
|
|
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
|
|
const entries = Object.keys(value).filter((key) => value[key] !== undefined).sort()
|
|
return `{${entries.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`
|
|
}
|
|
|
|
function computedEvidenceId(evidence) {
|
|
const semantic = {
|
|
subject: evidence.subject === null ? null : {
|
|
digest: evidence.subject.digest,
|
|
format: evidence.subject.format,
|
|
architecture: evidence.subject.architecture
|
|
},
|
|
provider: evidence.provider,
|
|
analysis_profile: evidence.analysis_profile,
|
|
predicate_type: evidence.predicate_type,
|
|
operation: evidence.operation,
|
|
parameters: evidence.parameters,
|
|
raw_result: evidence.raw_result,
|
|
normalized_result: evidence.normalized_result,
|
|
confidence: evidence.confidence,
|
|
authority: evidence.authority,
|
|
environment: evidence.environment,
|
|
limitations: evidence.limitations,
|
|
...(evidence.relationship_coverage === null ? {} : { relationship_coverage: evidence.relationship_coverage }),
|
|
locations: evidence.locations,
|
|
evidence_links: evidence.evidence_links
|
|
}
|
|
return `ev_${createHash('sha256').update(canonicalJson(semantic)).digest('hex')}`
|
|
}
|
|
|
|
function normalizeReference(report) {
|
|
const kind = report.source?.kind ?? report.kind
|
|
if (!['rea-workbuddy', 'ycode-electron-self-test'].includes(kind)) {
|
|
fail(`Unsupported font report source kind: ${kind ?? 'missing'}`)
|
|
}
|
|
const samples = report.samples ?? report.fontSamples ?? report.result?.samples
|
|
if (!Array.isArray(samples)) fail('REA report needs a samples array')
|
|
const records = new Map()
|
|
const missing = new Map()
|
|
for (const sample of samples) {
|
|
const category = categoryOf(sample.category ?? sample.kind ?? sample.id ?? sample.label)
|
|
if (!category) continue
|
|
if (records.has(category)) fail(`REA report contains more than one ${category} sample`)
|
|
const text = sample.text ?? sample.content ?? sample.textContent
|
|
if (typeof text !== 'string' || text.length === 0) fail(`REA ${category} sample has no non-empty text`)
|
|
const codeContext = category === 'code' ? sample.codeContext ?? sample.context ?? 'fenced' : null
|
|
if (category === 'code' && !['inline', 'fenced'].includes(codeContext)) {
|
|
fail(`REA code codeContext must be inline or fenced, received ${String(codeContext)}`)
|
|
}
|
|
records.set(category, {
|
|
category,
|
|
text,
|
|
fontFamily: sample.fontFamily ?? sample.computedFontFamily ?? sample.computed?.fontFamily ?? sample.styles?.['font-family'] ?? null,
|
|
fontSize: sample.fontSize ?? sample.computed?.fontSize ?? sample.styles?.['font-size'] ?? null,
|
|
fontWeight: sample.fontWeight ?? sample.computed?.fontWeight ?? sample.styles?.['font-weight'] ?? null,
|
|
codeContext,
|
|
platformFonts: normalizeFonts(sample.platformFonts ?? sample.fonts, `REA ${category}`)
|
|
})
|
|
}
|
|
for (const entry of report.missing ?? report.missingSamples ?? []) {
|
|
const category = categoryOf(entry.category ?? entry.kind ?? entry.id ?? entry.label)
|
|
if (!category) continue
|
|
if (records.has(category) || missing.has(category)) fail(`REA report lists ${category} more than once`)
|
|
if (typeof entry.reason !== 'string' || !entry.reason.trim()) fail(`REA missing ${category} entry needs a reason`)
|
|
missing.set(category, { category, reason: entry.reason })
|
|
}
|
|
const undeclared = requiredCategories.filter((category) => !records.has(category) && !missing.has(category))
|
|
if (undeclared.length) fail(`REA report must explicitly sample or mark missing: ${undeclared.join(', ')}`)
|
|
|
|
const evidenceId = report.evidenceId ?? report.evidence?.id ?? report.source?.evidenceId ?? report.reference?.evidenceId
|
|
const version = report.version ?? report.workbuddyVersion ?? report.source?.version ?? report.reference?.version
|
|
if (!evidenceId || !version) fail('REA report must record WorkBuddy version and Evidence ID')
|
|
return {
|
|
kind,
|
|
evidenceId: String(evidenceId),
|
|
version: String(version),
|
|
environment: report.environment ?? report.runtime ?? report.source?.environment ?? null,
|
|
samples: requiredCategories.filter((category) => records.has(category)).map((category) => records.get(category)),
|
|
missing: requiredCategories.filter((category) => missing.has(category)).map((category) => missing.get(category))
|
|
}
|
|
}
|
|
|
|
async function validateReferenceReport(report, referencePath) {
|
|
const reference = normalizeReference(report)
|
|
if (reference.kind === 'ycode-electron-self-test') {
|
|
if (report.archive || report.evidenceSha256) fail('A local YCode self-test must not claim a REA archive')
|
|
return { ...reference, archive: null, archiveSha256: null, archiveVerified: false, versionObserved: true }
|
|
}
|
|
|
|
if (!/^ev_[a-f0-9]{64}$/.test(reference.evidenceId)) fail('REA report has an invalid Evidence ID')
|
|
if (typeof report.archive !== 'string' || !report.archive) fail('REA report is missing its archived Evidence path')
|
|
if (!/^[a-f0-9]{64}$/i.test(report.evidenceSha256 ?? '')) fail('REA report is missing a valid evidenceSha256')
|
|
const archivePath = path.resolve(projectRoot, report.archive)
|
|
const expectedArchivePath = path.resolve(projectRoot, '.tmp/rea-css-evidence', `${reference.evidenceId}.json`)
|
|
const archivePathKey = process.platform === 'win32' ? archivePath.toLowerCase() : archivePath
|
|
const expectedArchivePathKey = process.platform === 'win32' ? expectedArchivePath.toLowerCase() : expectedArchivePath
|
|
if (archivePathKey !== expectedArchivePathKey) fail('REA archive path does not match the Evidence ID archive')
|
|
|
|
const archiveBytes = await readFile(archivePath).catch(() => null)
|
|
if (!archiveBytes) fail(`REA Evidence archive not found: ${archivePath}`)
|
|
const archiveSha256 = createHash('sha256').update(archiveBytes).digest('hex')
|
|
if (archiveSha256 !== report.evidenceSha256.toLowerCase()) fail('REA archive SHA-256 does not match evidenceSha256')
|
|
|
|
let evidence
|
|
try { evidence = JSON.parse(archiveBytes.toString('utf8')) } catch { fail('REA Evidence archive is not valid JSON') }
|
|
if (evidence.evidence_id !== reference.evidenceId) fail('REA archive Evidence ID does not match the report')
|
|
if (computedEvidenceId(evidence) !== evidence.evidence_id) fail('REA archive Evidence ID does not match its semantic content')
|
|
const expectedAuthority = evidence.parameters?.visual_probe?.actions?.length ? 'controlled-replay' : 'external-service'
|
|
if (evidence.operation !== 'inspect_electron_page' || evidence.authority !== expectedAuthority || evidence.provider?.id !== 'rea-cdp-electron') {
|
|
fail('REA archive is not an authorized Electron page observation')
|
|
}
|
|
const targetPath = evidence.normalized_result?.target?.file_path ?? ''
|
|
if (!/workbuddy[\\/]resources[\\/]app\.asar[\\/]renderer[\\/]index\.html/i.test(targetPath)) {
|
|
fail('REA archive target is not the WorkBuddy renderer')
|
|
}
|
|
const visuals = evidence.normalized_result?.visuals
|
|
if (!visuals || !Array.isArray(visuals.font_samples)) fail('REA archive has no font_samples observation')
|
|
if (!isDeepStrictEqual(report.samples, visuals.font_samples)) fail('REA report samples differ from archived font_samples')
|
|
if (!isDeepStrictEqual(report.missing ?? [], visuals.font_missing ?? [])) fail('REA report missing samples differ from archived font_missing')
|
|
|
|
const userAgent = evidence.normalized_result?.browser?.user_agent ?? ''
|
|
const archiveVersion = userAgent.match(/WorkBuddy[ /]([\d.]+)/i)?.[1] ?? 'unobserved'
|
|
if (reference.version !== archiveVersion) fail(`REA report version ${reference.version} does not match archived user agent version ${archiveVersion}`)
|
|
return {
|
|
...reference,
|
|
archive: archivePath,
|
|
archiveSha256,
|
|
archiveVerified: true,
|
|
versionObserved: reference.version.toLowerCase() !== 'unobserved'
|
|
}
|
|
}
|
|
|
|
async function reservePort() {
|
|
const server = createServer()
|
|
await new Promise((resolve, reject) => server.once('error', reject).listen(0, '127.0.0.1', resolve))
|
|
const address = server.address()
|
|
if (!address || typeof address === 'string') fail('Could not reserve an Electron debugging port')
|
|
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
|
|
return address.port
|
|
}
|
|
|
|
function connectCdp(url) {
|
|
const socket = new WebSocket(url)
|
|
const pending = new Map()
|
|
let nextId = 0
|
|
const opened = new Promise((resolve, reject) => {
|
|
socket.addEventListener('open', resolve, { once: true })
|
|
socket.addEventListener('error', () => reject(new Error('Could not connect to local Electron DevTools Protocol')), { once: true })
|
|
})
|
|
socket.addEventListener('message', (event) => {
|
|
let message
|
|
try { message = JSON.parse(String(event.data)) } catch { return }
|
|
if (!message.id) return
|
|
const request = pending.get(message.id)
|
|
if (!request) return
|
|
clearTimeout(request.timer)
|
|
pending.delete(message.id)
|
|
if (message.error) request.reject(new Error(`${request.method}: ${message.error.message}`))
|
|
else request.resolve(message.result)
|
|
})
|
|
socket.addEventListener('close', () => {
|
|
for (const request of pending.values()) {
|
|
clearTimeout(request.timer)
|
|
request.reject(new Error('Local Electron DevTools connection closed'))
|
|
}
|
|
pending.clear()
|
|
})
|
|
|
|
return {
|
|
opened,
|
|
send(method, params = {}) {
|
|
const id = ++nextId
|
|
socket.send(JSON.stringify({ id, method, params }))
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
pending.delete(id)
|
|
reject(new Error(`Timed out waiting for ${method}`))
|
|
}, 15000)
|
|
pending.set(id, { method, resolve, reject, timer })
|
|
})
|
|
},
|
|
close() { socket.close() }
|
|
}
|
|
}
|
|
|
|
async function waitForDevtools(port, child) {
|
|
const deadline = Date.now() + 30000
|
|
let lastError
|
|
while (Date.now() < deadline) {
|
|
if (child.exitCode !== null) fail(`YCode Electron exited early with code ${child.exitCode}`)
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/json/version`)
|
|
if (response.ok) return await response.json()
|
|
} catch (error) { lastError = error }
|
|
await delay(250)
|
|
}
|
|
fail(`YCode Electron did not expose DevTools on port ${port}${lastError ? `: ${lastError.message}` : ''}`)
|
|
}
|
|
|
|
async function waitForPageTarget(port, child) {
|
|
const deadline = Date.now() + 30000
|
|
while (Date.now() < deadline) {
|
|
if (child.exitCode !== null) fail(`YCode Electron exited early with code ${child.exitCode}`)
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/json/list`)
|
|
if (response.ok) {
|
|
const targets = await response.json()
|
|
const target = targets.find((item) => item.type === 'page' && item.webSocketDebuggerUrl)
|
|
if (target) return target
|
|
}
|
|
} catch { /* Electron is still starting. */ }
|
|
await delay(250)
|
|
}
|
|
fail('YCode Electron did not create a renderer page target')
|
|
}
|
|
|
|
function buildProbeExpression(samples) {
|
|
const serialized = JSON.stringify(samples.map(({ category, text, codeContext }) => ({ category, text, codeContext })))
|
|
return `(() => {
|
|
const samples = ${serialized};
|
|
const root = document.createElement('section');
|
|
root.id = 'yc-font-platform-probe';
|
|
root.className = 'chat-pane cr-theme';
|
|
root.dataset.crTheme = 'light';
|
|
Object.assign(root.style, { position: 'fixed', left: '-20000px', top: '0', width: '900px', zIndex: '-1', pointerEvents: 'none' });
|
|
const add = (category, className, parent) => {
|
|
const node = document.createElement('span');
|
|
node.dataset.fontCategory = category;
|
|
node.className = className;
|
|
node.textContent = samples.find((item) => item.category === category).text;
|
|
parent.append(node);
|
|
};
|
|
for (const item of samples) {
|
|
const message = document.createElement('div');
|
|
message.className = item.category === 'user-bubble' ? 'msg--user cr-frame cr-frame--right' : 'msg--assistant cr-frame cr-frame--left';
|
|
const frame = document.createElement('div');
|
|
frame.className = 'cr-frame__content';
|
|
message.append(frame);
|
|
if (item.category === 'assistant-name') {
|
|
const agent = document.createElement('div');
|
|
agent.className = 'cr-agent';
|
|
const header = document.createElement('div');
|
|
header.className = 'msg__header cr-agent__header';
|
|
const name = document.createElement('div');
|
|
name.dataset.fontCategory = item.category;
|
|
name.className = 'msg__name cr-agent__name';
|
|
name.textContent = item.text;
|
|
header.append(name);
|
|
agent.append(header);
|
|
frame.append(agent);
|
|
} else if (item.category === 'chinese-body' || item.category === 'english-body') {
|
|
const agent = document.createElement('div');
|
|
agent.className = 'cr-agent';
|
|
const row = document.createElement('div');
|
|
row.className = 'cr-agent__body-row';
|
|
const body = document.createElement('div');
|
|
body.className = 'cr-agent__body';
|
|
const content = document.createElement('div');
|
|
content.className = 'cr-agent__content markdown-content';
|
|
const paragraph = document.createElement('p');
|
|
paragraph.dataset.fontCategory = item.category;
|
|
paragraph.className = 'cr-text-block';
|
|
paragraph.textContent = item.text;
|
|
content.append(paragraph);
|
|
body.append(content);
|
|
row.append(body);
|
|
agent.append(row);
|
|
frame.append(agent);
|
|
} else if (item.category === 'user-bubble') {
|
|
const self = document.createElement('div');
|
|
self.className = 'cr-self-message';
|
|
const content = document.createElement('div');
|
|
content.className = 'cr-self-message__content';
|
|
const bubble = document.createElement('div');
|
|
bubble.className = 'bubble cr-self-bubble';
|
|
const userContent = document.createElement('div');
|
|
userContent.className = 'cr-user-content';
|
|
const editable = document.createElement('div');
|
|
editable.className = 'yc-user-editable';
|
|
const paragraph = document.createElement('p');
|
|
const leaf = document.createElement('span');
|
|
leaf.className = 'yc-user-leaf';
|
|
add(item.category, '', leaf);
|
|
paragraph.append(leaf);
|
|
editable.append(paragraph);
|
|
userContent.append(editable);
|
|
bubble.append(userContent);
|
|
content.append(bubble);
|
|
self.append(content);
|
|
frame.append(self);
|
|
} else if (item.category === 'code' && item.codeContext === 'inline') {
|
|
const markdown = document.createElement('div');
|
|
markdown.className = 'cr-markdown markdown-content';
|
|
const list = document.createElement('ul');
|
|
const listItem = document.createElement('li');
|
|
const code = document.createElement('code');
|
|
code.dataset.fontCategory = item.category;
|
|
code.className = 'markdown-content__inline-code cr-inline-code';
|
|
code.textContent = item.text;
|
|
listItem.append(code);
|
|
list.append(listItem);
|
|
markdown.append(list);
|
|
frame.append(markdown);
|
|
} else {
|
|
const markdown = document.createElement('div');
|
|
markdown.className = 'markdown-content';
|
|
const pre = document.createElement('pre');
|
|
pre.className = 'markdown-content__pre cr-code-block__pre';
|
|
const code = document.createElement('code');
|
|
code.dataset.fontCategory = item.category;
|
|
code.className = 'language-typescript';
|
|
code.textContent = item.text;
|
|
pre.append(code);
|
|
markdown.append(pre);
|
|
frame.append(markdown);
|
|
}
|
|
root.append(message);
|
|
}
|
|
(document.querySelector('.app-shell') || document.body).append(root);
|
|
return { count: root.querySelectorAll('[data-font-category]').length, text: samples.map((item) => item.text) };
|
|
})()`
|
|
}
|
|
|
|
export async function captureYcodePlatformFonts(samples) {
|
|
const port = await reservePort()
|
|
const userDataDir = path.join(projectRoot, '.tmp', `rea-font-probe-profile-${process.pid}`)
|
|
await mkdir(userDataDir, { recursive: true })
|
|
const electronPath = process.env.YCODE_ELECTRON_PATH || require('electron')
|
|
const child = spawn(electronPath, [
|
|
`--remote-debugging-port=${port}`,
|
|
`--user-data-dir=${userDataDir}`,
|
|
'--no-first-run',
|
|
projectRoot
|
|
], { cwd: projectRoot, stdio: 'ignore', windowsHide: true })
|
|
|
|
let cdp
|
|
try {
|
|
const browserInfo = await waitForDevtools(port, child)
|
|
const target = await waitForPageTarget(port, child)
|
|
cdp = connectCdp(target.webSocketDebuggerUrl)
|
|
await cdp.opened
|
|
await cdp.send('Page.enable')
|
|
await cdp.send('Runtime.enable')
|
|
await cdp.send('DOM.enable')
|
|
await cdp.send('CSS.enable')
|
|
const ready = await cdp.send('Runtime.evaluate', {
|
|
expression: `new Promise((resolve) => { const start = Date.now(); const tick = () => { if (document.querySelector('.app-shell')) resolve(true); else if (Date.now() - start > 25000) resolve(false); else setTimeout(tick, 100); }; tick(); })`,
|
|
awaitPromise: true,
|
|
returnByValue: true
|
|
})
|
|
if (ready.result?.value !== true) fail('YCode renderer did not reach its app shell')
|
|
await cdp.send('Runtime.evaluate', { expression: 'document.fonts.ready.then(() => true)', awaitPromise: true, returnByValue: true })
|
|
const injected = await cdp.send('Runtime.evaluate', { expression: buildProbeExpression(samples), returnByValue: true })
|
|
if (injected.exceptionDetails) fail(`Could not inject font probes: ${injected.exceptionDetails.text}`)
|
|
await cdp.send('Runtime.evaluate', { expression: 'document.fonts.ready.then(() => true)', awaitPromise: true, returnByValue: true })
|
|
await cdp.send('Runtime.evaluate', {
|
|
expression: 'new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))',
|
|
awaitPromise: true,
|
|
returnByValue: true
|
|
})
|
|
|
|
const document = await cdp.send('DOM.getDocument', { depth: -1, pierce: true })
|
|
const capturedSamples = []
|
|
for (const sample of samples) {
|
|
const selector = `#yc-font-platform-probe [data-font-category="${sample.category}"]`
|
|
const { nodeId } = await cdp.send('DOM.querySelector', { nodeId: document.root.nodeId, selector })
|
|
if (!nodeId) fail(`YCode probe node not found for ${sample.category}`)
|
|
const details = await cdp.send('Runtime.evaluate', {
|
|
expression: `(() => { const node = document.querySelector(${JSON.stringify(selector)}); const style = getComputedStyle(node); const rect = node.getBoundingClientRect(); return { text: node.textContent, fontFamily: style.fontFamily, fontSize: style.fontSize, fontWeight: style.fontWeight, width: rect.width, height: rect.height }; })()`,
|
|
returnByValue: true
|
|
})
|
|
if (details.exceptionDetails) fail(`Could not read YCode ${sample.category} computed style`)
|
|
const platform = await cdp.send('CSS.getPlatformFontsForNode', { nodeId })
|
|
const value = details.result?.value
|
|
if (!value || value.text !== sample.text) fail(`YCode ${sample.category} probe text differs from the REA sample`)
|
|
capturedSamples.push({
|
|
category: sample.category,
|
|
text: value.text,
|
|
fontFamily: value.fontFamily,
|
|
fontSize: value.fontSize,
|
|
fontWeight: value.fontWeight,
|
|
...(sample.category === 'code' ? { codeContext: sample.codeContext ?? 'fenced' } : {}),
|
|
platformFonts: normalizeFonts(platform.fonts, `YCode ${sample.category}`)
|
|
})
|
|
}
|
|
const env = {
|
|
platform: process.platform,
|
|
osRelease: os.release(),
|
|
osVersion: os.version(),
|
|
arch: process.arch,
|
|
electron: require('electron/package.json').version,
|
|
chromium: browserInfo.Browser ?? null,
|
|
userAgent: browserInfo['User-Agent'] ?? null
|
|
}
|
|
return { environment: env, samples: capturedSamples }
|
|
} finally {
|
|
if (cdp) {
|
|
try { await cdp.send('Runtime.evaluate', { expression: 'window.close()' }) } catch { /* The renderer may already be gone. */ }
|
|
}
|
|
cdp?.close()
|
|
if (child.exitCode === null) {
|
|
await Promise.race([
|
|
new Promise((resolve) => child.once('exit', resolve)),
|
|
delay(3000)
|
|
])
|
|
}
|
|
if (child.exitCode === null) child.kill()
|
|
await rm(userDataDir, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const referenceBytes = await readFile(referencePath).catch(() => null)
|
|
if (!referenceBytes) fail(`REA platform-font report not found: ${referencePath}`)
|
|
const referenceReport = JSON.parse(referenceBytes.toString('utf8'))
|
|
const reference = await validateReferenceReport(referenceReport, referencePath)
|
|
const reportSha256 = createHash('sha256').update(referenceBytes).digest('hex')
|
|
const local = await captureYcodePlatformFonts(reference.samples)
|
|
|
|
const localSamples = local.samples
|
|
const comparisons = reference.samples.map((expected, index) => {
|
|
const actual = localSamples[index]
|
|
const sameFonts = JSON.stringify(expected.platformFonts) === JSON.stringify(actual.platformFonts)
|
|
const referenceSize = expected.fontSize ? Number.parseFloat(expected.fontSize) : null
|
|
const localSize = actual.fontSize ? Number.parseFloat(actual.fontSize) : null
|
|
const fontSizeMatches = referenceSize === null || (localSize !== null && Math.abs(referenceSize - localSize) <= 0.01)
|
|
const fontWeightMatches = expected.fontWeight === null || expected.fontWeight === actual.fontWeight
|
|
const mismatchReasons = []
|
|
if (expected.text !== actual.text) mismatchReasons.push('sample text differs')
|
|
if (!sameFonts) mismatchReasons.push('actual platform font records differ')
|
|
if (!fontSizeMatches) mismatchReasons.push(`font size differs (${expected.fontSize} vs ${actual.fontSize})`)
|
|
if (!fontWeightMatches) mismatchReasons.push(`font weight differs (${expected.fontWeight} vs ${actual.fontWeight})`)
|
|
if (expected.fontFamily && expected.fontFamily !== actual.fontFamily) mismatchReasons.push('computed CSS font-family stack differs')
|
|
return {
|
|
category: expected.category,
|
|
status: expected.text === actual.text && sameFonts && fontSizeMatches && fontWeightMatches ? 'matched' : 'mismatched',
|
|
mismatchReasons,
|
|
textMatches: expected.text === actual.text,
|
|
cssFontFamilyMatches: expected.fontFamily ? expected.fontFamily === actual.fontFamily : null,
|
|
fontSizeMatches,
|
|
referenceFontSize: expected.fontSize,
|
|
ycodeFontSize: actual.fontSize,
|
|
fontWeightMatches,
|
|
referenceFontWeight: expected.fontWeight,
|
|
ycodeFontWeight: actual.fontWeight,
|
|
...(expected.category === 'code' ? { codeContext: expected.codeContext } : {}),
|
|
referenceFontFamily: expected.fontFamily,
|
|
ycodeFontFamily: actual.fontFamily,
|
|
referencePlatformFonts: expected.platformFonts,
|
|
ycodePlatformFonts: actual.platformFonts,
|
|
platformFontsMatch: sameFonts
|
|
}
|
|
})
|
|
comparisons.push(...reference.missing.map((entry) => ({
|
|
category: entry.category,
|
|
status: 'missing',
|
|
reason: entry.reason,
|
|
platformFontsMatch: null
|
|
})))
|
|
const referenceEnvironment = reference.environment
|
|
const environmentMatches = !referenceEnvironment || (
|
|
(!referenceEnvironment.platform || referenceEnvironment.platform === local.environment.platform) &&
|
|
(!referenceEnvironment.osRelease || referenceEnvironment.osRelease === local.environment.osRelease)
|
|
)
|
|
const samplesComplete = reference.missing.length === 0 && reference.samples.length === requiredCategories.length
|
|
const complete = samplesComplete && reference.versionObserved
|
|
const passed = complete && environmentMatches && comparisons.every((item) => item.status === 'matched')
|
|
const status = !samplesComplete || !reference.versionObserved
|
|
? 'incomplete'
|
|
: reference.kind === 'ycode-electron-self-test'
|
|
? passed ? 'self-test-passed' : 'self-test-failed'
|
|
: passed ? 'passed' : 'failed'
|
|
const result = {
|
|
schemaVersion: 1,
|
|
reference: {
|
|
kind: reference.kind,
|
|
acceptanceScope: reference.kind === 'rea-workbuddy' ? 'workbuddy-parity' : 'local-electron-self-test-only',
|
|
originalParity: reference.kind === 'rea-workbuddy' && status === 'passed',
|
|
path: path.relative(projectRoot, referencePath).replaceAll('\\', '/'),
|
|
reportSha256,
|
|
evidenceId: reference.evidenceId,
|
|
version: reference.version,
|
|
versionObserved: reference.versionObserved,
|
|
archive: reference.archive,
|
|
evidenceSha256: reference.archiveSha256,
|
|
archiveVerified: reference.archiveVerified,
|
|
environment: referenceEnvironment
|
|
},
|
|
ycode: { environment: local.environment, samples: localSamples },
|
|
environmentMatches,
|
|
samplesComplete,
|
|
complete,
|
|
comparisons,
|
|
status,
|
|
passed
|
|
}
|
|
if (!reference.versionObserved) result.incompleteReasons = ['WorkBuddy version is unobserved in the archived REA Evidence']
|
|
else if (!samplesComplete) result.incompleteReasons = reference.missing.map((entry) => `${entry.category}: ${entry.reason}`)
|
|
await mkdir(path.dirname(resultPath), { recursive: true })
|
|
await writeFile(resultPath, `${JSON.stringify(result, null, 2)}\n`)
|
|
console.log(JSON.stringify({
|
|
status,
|
|
passed,
|
|
complete,
|
|
reference: {
|
|
kind: reference.kind,
|
|
acceptanceScope: result.reference.acceptanceScope,
|
|
originalParity: result.reference.originalParity,
|
|
evidenceId: reference.evidenceId,
|
|
version: reference.version,
|
|
reportSha256,
|
|
evidenceSha256: reference.archiveSha256,
|
|
archiveVerified: reference.archiveVerified
|
|
},
|
|
environmentMatches,
|
|
samplesComplete,
|
|
samples: comparisons.map(({ category, status: sampleStatus, reason, mismatchReasons, codeContext, cssFontFamilyMatches, fontSizeMatches, referenceFontSize, ycodeFontSize, fontWeightMatches, referenceFontWeight, ycodeFontWeight, platformFontsMatch, referencePlatformFonts, ycodePlatformFonts }) => ({ category, status: sampleStatus, reason, mismatchReasons, codeContext, cssFontFamilyMatches, fontSizeMatches, referenceFontSize, ycodeFontSize, fontWeightMatches, referenceFontWeight, ycodeFontWeight, platformFontsMatch, referencePlatformFonts, ycodePlatformFonts })),
|
|
incompleteReasons: result.incompleteReasons,
|
|
resultPath: path.relative(projectRoot, resultPath).replaceAll('\\', '/')
|
|
}, null, 2))
|
|
if (!passed) process.exitCode = complete ? 1 : 2
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
main().catch((error) => {
|
|
console.error(error instanceof Error ? error.message : String(error))
|
|
process.exitCode = 1
|
|
})
|
|
}
|