chore: establish YCode replication baseline and TODO
This commit is contained in:
commit
6dc59f89ba
1118 files changed
+196002
No files matched your search
@@ -0,0 +1,581 @@
|
||||
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
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user