test: repair native acceptance entry portability T01-T03
This commit is contained in:
1 parent
9724fc70bc
commit
f3be60bf2d
6 files changed
+335
-58
No files matched your search
@@ -39,6 +39,9 @@ PowerShell 使用 `$env:YCODE_TEST_DOM_MODULE = <绝对路径>`,再运行相
|
|||||||
|
|
||||||
例如 `test:recommended-skills:ui`、`node scripts/verify-task-menu.cjs`、`node scripts/test-mode-chip-native.mjs`。这些是特定场景,不是统一全量 UI 集。先读脚本并确认临时 userData/工作目录、受控宿主、是否访问模型/网络;不得连接生产数据或原版 WorkBuddy 进行未经允许的操作。
|
例如 `test:recommended-skills:ui`、`node scripts/verify-task-menu.cjs`、`node scripts/test-mode-chip-native.mjs`。这些是特定场景,不是统一全量 UI 集。先读脚本并确认临时 userData/工作目录、受控宿主、是否访问模型/网络;不得连接生产数据或原版 WorkBuddy 进行未经允许的操作。
|
||||||
|
|
||||||
|
T01–T03 正式入口约定:模式标签通过 Node 构建夹具,再启动 CJS Electron 子入口(不要直接用 Electron 执行 `.mjs` 构建器);推荐技能 hover 必须取得真实可信鼠标事件、命中与计算样式变化,`:hover` 选择器仅作诊断;图片连续帧 QA 窗口关闭后台节流,但保留真实 rAF、布局断言和 20 秒截止。只改测试窗口,不更改产品窗口策略。`--build-only`、离线契约测试和 jsdom 通过均不等于 Windows 原生命令通过。
|
||||||
|
|
||||||
|
|
||||||
`test:recommended-skills:app`、SDK smoke、网络包验证等可能需要额外资源或有外部行为,不纳入默认离线入口。无 DISPLAY、native 模块或权限导致启动失败,应标为**环境阻塞**,不是通过。`--build-only`、SSR、jsdom、静态 CSS 断言都不等于真实布局或像素验收。
|
`test:recommended-skills:app`、SDK smoke、网络包验证等可能需要额外资源或有外部行为,不纳入默认离线入口。无 DISPLAY、native 模块或权限导致启动失败,应标为**环境阻塞**,不是通过。`--build-only`、SSR、jsdom、静态 CSS 断言都不等于真实布局或像素验收。
|
||||||
|
|
||||||
真实 dev 启动遵循 [Dev 启动 SOP](../docs/协作/Dev启动SOP.md),测试应用另用隔离数据,不能以启动测试窗口代替用户要求的 dev。
|
真实 dev 启动遵循 [Dev 启动 SOP](../docs/协作/Dev启动SOP.md),测试应用另用隔离数据,不能以启动测试窗口代替用户要求的 dev。
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/** Offline VM contracts only: no Electron, browser, layout or native input is executed. */
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import vm from 'node:vm'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
const source = fs.readFileSync(new URL('./verify-b04-image-native.cjs', import.meta.url), 'utf8')
|
||||||
|
const flush = async () => { for (let i = 0; i < 12; i++) await Promise.resolve() }
|
||||||
|
const deferred = () => { let resolve, reject; const promise = new Promise((a, b) => { resolve = a; reject = b }); return { promise, resolve, reject } }
|
||||||
|
const validReport = () => ({ samples: [{ height: 232, textLength: 35, imageHeight: 180 }], errors: [] })
|
||||||
|
function harness({ report = validReport(), holdReady = false, holdLoad = false, holdFrames = false, frameError } = {}) {
|
||||||
|
const ready = deferred(), load = deferred(), frames = deferred()
|
||||||
|
const state = { writes: [], exits: [], events: {}, timers: new Map(), windows: [], logs: [] }
|
||||||
|
let id = 0
|
||||||
|
const app = { setPath: (key, value) => { state.profile = { key, value } }, whenReady: () => ready.promise, exit: code => state.exits.push(code) }
|
||||||
|
class BrowserWindow {
|
||||||
|
constructor(options) {
|
||||||
|
state.windows.push(options); state.window = this; this.destroyed = false
|
||||||
|
this.webContents = { on: (name, callback) => { state.events[name] = callback }, executeJavaScript: async code => {
|
||||||
|
state.rendererCode = code
|
||||||
|
if (frameError) throw Error(frameError)
|
||||||
|
if (holdFrames) return frames.promise
|
||||||
|
return report
|
||||||
|
} }
|
||||||
|
}
|
||||||
|
loadFile(file) { state.loaded = file; return holdLoad ? load.promise : Promise.resolve() }
|
||||||
|
isDestroyed() { return this.destroyed }
|
||||||
|
destroy() { this.destroyed = true }
|
||||||
|
}
|
||||||
|
const modules = {
|
||||||
|
electron: { app, BrowserWindow, session: { defaultSession: { webRequest: { onBeforeRequest: callback => { state.network = callback } } } } },
|
||||||
|
'node:path': path, 'node:os': { tmpdir: () => '/mock-temp' },
|
||||||
|
'node:fs': { existsSync: () => true, mkdtempSync: prefix => prefix + 'isolated', writeFileSync: (file, body) => state.writes.push({ file, report: JSON.parse(body) }) }
|
||||||
|
}
|
||||||
|
vm.runInNewContext(source, { require: name => { assert.ok(name in modules); return modules[name] }, process: { argv: ['electron', 'probe.cjs', '/mock-fixture'] },
|
||||||
|
console: { log: value => state.logs.push(value) }, setTimeout: (callback, delay) => { state.timers.set(++id, { callback, delay }); return id }, clearTimeout: key => state.timers.delete(key) })
|
||||||
|
if (!holdReady) ready.resolve()
|
||||||
|
return { state, ready, load, frames, expire: () => { const timer = [...state.timers.values()][0]; assert.equal(timer.delay, 20000); timer.callback() } }
|
||||||
|
}
|
||||||
|
function result(h, status, pattern) {
|
||||||
|
assert.equal(h.state.writes.length, 1)
|
||||||
|
const report = h.state.writes[0].report
|
||||||
|
assert.equal(report.result, status)
|
||||||
|
assert.deepEqual(h.state.exits, [status === 'PASS' ? 0 : 1])
|
||||||
|
if (pattern) assert.match(report.error, pattern)
|
||||||
|
assert.match(report.limits, /not the installed application/)
|
||||||
|
assert.equal(h.state.timers.size, 0)
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
|
||||||
|
test('B04 mock host preserves security, frame scheduling, deadline and report', async () => {
|
||||||
|
const h = harness(); await flush()
|
||||||
|
const report = result(h, 'PASS')
|
||||||
|
assert.equal(report.phase, 'checking-native-report')
|
||||||
|
const options = h.state.windows[0]
|
||||||
|
assert.equal(options.show, true)
|
||||||
|
assert.equal(options.webPreferences.backgroundThrottling, false)
|
||||||
|
assert.equal(options.webPreferences.sandbox, true)
|
||||||
|
assert.equal(options.webPreferences.contextIsolation, true)
|
||||||
|
assert.equal(options.webPreferences.nodeIntegration, false)
|
||||||
|
assert.equal(h.state.profile.key, 'userData')
|
||||||
|
assert.match(h.state.profile.value, /ycode-b04-native-/)
|
||||||
|
assert.equal(h.state.window.destroyed, true)
|
||||||
|
for (const [url, cancel] of [['https://example.invalid/image', true], ['file:///fixture.js', false], ['data:image/png;base64,AA', false], ['blob:local', false]]) {
|
||||||
|
let actual; h.state.network({ url }, value => { actual = value.cancel }); assert.equal(actual, cancel)
|
||||||
|
}
|
||||||
|
assert.match(h.state.rendererCode, /window\.__b04Error/)
|
||||||
|
assert.match(h.state.rendererCode, /window\.__b04Report/)
|
||||||
|
})
|
||||||
|
for (const [label, report, message] of [
|
||||||
|
['zero layout', { samples: [{ height: 0, textLength: 0 }] }, /No nonzero/],
|
||||||
|
['large text', { samples: [{ height: 232, textLength: 1001 }] }, /text\/height spike/],
|
||||||
|
['large layout', { samples: [{ height: 2001, textLength: 35 }] }, /text\/height spike/],
|
||||||
|
['missing samples', {}, /some|samples/]
|
||||||
|
]) test(`B04 mock rejects ${label}`, async () => { const h = harness({ report }); await flush(); result(h, 'FAIL', message) })
|
||||||
|
test('B04 mock propagates fixture rejection', async () => { const h = harness({ frameError: 'synthetic no URI text assertion' }); await flush(); result(h, 'FAIL', /no URI text assertion/) })
|
||||||
|
for (const [option, phase] of [['holdReady', 'waiting-for-app'], ['holdLoad', 'loading-fixture'], ['holdFrames', 'waiting-for-native-frames']]) {
|
||||||
|
test(`B04 mock deadline covers ${phase}`, async () => {
|
||||||
|
const h = harness({ [option]: true }); await flush(); h.expire()
|
||||||
|
const report = result(h, 'FAIL', /timed out after 20s/); assert.equal(report.phase, phase)
|
||||||
|
if (option === 'holdReady') h.ready.resolve()
|
||||||
|
if (option === 'holdLoad') h.load.resolve()
|
||||||
|
if (option === 'holdFrames') h.frames.resolve(validReport())
|
||||||
|
await flush(); assert.equal(h.state.writes.length, 1)
|
||||||
|
if (option === 'holdReady') assert.equal(h.state.windows.length, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for (const [event, args, message] of [
|
||||||
|
['render-process-gone', [{}, { reason: 'crashed' }], /Renderer gone: crashed/],
|
||||||
|
['did-fail-load', [{}, -6, 'FILE_NOT_FOUND'], /Page load failed: -6 FILE_NOT_FOUND/]
|
||||||
|
]) test(`B04 mock records ${event} once`, async () => {
|
||||||
|
const h = harness({ holdLoad: true }); await flush(); h.state.events[event](...args); h.load.reject(Error('later rejection')); await flush(); result(h, 'FAIL', message)
|
||||||
|
})
|
||||||
|
test('B04 mock fails renderer console errors', async () => {
|
||||||
|
const h = harness({ holdFrames: true }); await flush()
|
||||||
|
h.state.events['console-message']({ level: 'error', message: 'synthetic renderer error' })
|
||||||
|
h.frames.resolve(validReport()); await flush(); result(h, 'FAIL', /synthetic renderer error/)
|
||||||
|
})
|
||||||
|
test('B04 fixture retains real rAF sampling and decode assertions (source contract only)', () => {
|
||||||
|
const fixture = fs.readFileSync(new URL('./b04-image-lifecycle-fixture.tsx', import.meta.url), 'utf8')
|
||||||
|
assert.match(fixture, /requestAnimationFrame\(\(\) => \{sample\(/)
|
||||||
|
assert.match(fixture, /await folds\(true\)/)
|
||||||
|
assert.match(fixture, /naturalWidth === 320/)
|
||||||
|
assert.match(fixture, /imageHeight > 0/)
|
||||||
|
assert.match(fixture, /no URI text/)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const scenario of ['success', 'fixture-error', 'pending']) test(`B04 renderer polling contract: ${scenario} (mock window)`, async () => {
|
||||||
|
const h = harness(); await flush()
|
||||||
|
const report = validReport(), window = {}, timers = []
|
||||||
|
if (scenario === 'success') window.__b04Report = report
|
||||||
|
if (scenario === 'fixture-error') { window.__b04Error = 'synthetic fixture assertion'; window.__b04Report = report }
|
||||||
|
const promise = vm.runInNewContext(h.state.rendererCode, { window, setTimeout: (callback, delay) => timers.push({ callback, delay }) })
|
||||||
|
if (scenario === 'fixture-error') await assert.rejects(promise, /synthetic fixture assertion/)
|
||||||
|
else {
|
||||||
|
if (scenario === 'pending') {
|
||||||
|
assert.equal(timers.length, 1); assert.equal(timers[0].delay, 25)
|
||||||
|
window.__b04Report = report; timers[0].callback()
|
||||||
|
}
|
||||||
|
assert.equal(await promise, report)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/** Electron-only CJS entry: do not rebuild fixtures or use ESM top-level await here.
|
||||||
|
* Keeps app.whenReady() out of Electron's ESM loader lifecycle (B01 T01).
|
||||||
|
*/
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const { createHash } = require('node:crypto')
|
||||||
|
if (!process.versions.electron) throw new Error('Run node scripts/test-mode-chip-native.mjs instead')
|
||||||
|
const { app, BrowserWindow } = require('electron')
|
||||||
|
const base = path.resolve('.tmp')
|
||||||
|
const output = path.resolve(process.argv.at(-1))
|
||||||
|
assert.ok(path.dirname(output) === base && path.basename(output).startsWith('mode-chip-native-'))
|
||||||
|
const file = 'src/renderer/src/workbuddy/composer/ModeChip.tsx'
|
||||||
|
const checks = [], samples = [], errors = []
|
||||||
|
let win, timer, currentPhase = 'startup', finished = false
|
||||||
|
function fail(error) {
|
||||||
|
if (finished) return
|
||||||
|
finished = true; clearTimeout(timer)
|
||||||
|
const detail = `Phase: ${currentPhase}\n${error.stack || error}`
|
||||||
|
fs.writeFileSync(path.join(output, 'failure.txt'), detail)
|
||||||
|
console.error(detail)
|
||||||
|
app.exit(1)
|
||||||
|
}
|
||||||
|
function phase(name, timeoutMs) {
|
||||||
|
currentPhase = name; clearTimeout(timer)
|
||||||
|
const detail = { phase: name, time: new Date().toISOString(), checksCompleted: checks.length }
|
||||||
|
fs.writeFileSync(path.join(output, 'phase.json'), JSON.stringify(detail, null, 2))
|
||||||
|
console.log(JSON.stringify(detail))
|
||||||
|
if (timeoutMs) timer = setTimeout(() => fail(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs)
|
||||||
|
}
|
||||||
|
process.on('uncaughtException', fail)
|
||||||
|
process.on('unhandledRejection', fail)
|
||||||
|
// Set private data paths synchronously, before Electron can become ready.
|
||||||
|
for (const dir of ['home', 'data', 'session']) fs.mkdirSync(path.join(output, dir), { recursive: true })
|
||||||
|
app.setPath('home', path.join(output, 'home'))
|
||||||
|
app.setPath('userData', path.join(output, 'data'))
|
||||||
|
app.setPath('sessionData', path.join(output, 'session'))
|
||||||
|
app.disableHardwareAcceleration()
|
||||||
|
async function run() {
|
||||||
|
try {
|
||||||
|
phase('app-ready', 15000)
|
||||||
|
await app.whenReady()
|
||||||
|
if (finished) return
|
||||||
|
phase('load-fixture', 15000)
|
||||||
|
win = new BrowserWindow({ width: 800, height: 300, show: false, webPreferences: {
|
||||||
|
contextIsolation: true, nodeIntegration: false, sandbox: true, backgroundThrottling: false
|
||||||
|
} })
|
||||||
|
win.webContents.on('console-message', event => { if (event.level === 'error' || event.level >= 3) errors.push(event.message) })
|
||||||
|
win.webContents.on('render-process-gone', (_event, details) => fail(new Error(`Renderer exited: ${JSON.stringify(details)}`)))
|
||||||
|
win.webContents.on('unresponsive', () => fail(new Error('Renderer unresponsive')))
|
||||||
|
await win.loadFile(path.join(output, 'index.html'))
|
||||||
|
if (finished) return
|
||||||
|
win.showInactive()
|
||||||
|
phase('fixture-mounted', 10000)
|
||||||
|
const read = code => {
|
||||||
|
if (finished) throw new Error('Native test already stopped')
|
||||||
|
return win.webContents.executeJavaScript(code)
|
||||||
|
}
|
||||||
|
const pause = () => new Promise(resolve => setTimeout(resolve, 80))
|
||||||
|
const check = (label, actual, expected = true) => { assert.deepEqual(actual, expected, label); checks.push(label) }
|
||||||
|
const key = async keyCode => {
|
||||||
|
win.focus(); win.webContents.focus(); await pause()
|
||||||
|
win.webContents.sendInputEvent({ type: 'keyDown', keyCode })
|
||||||
|
if (keyCode === 'Enter' || keyCode === 'Space') {
|
||||||
|
win.webContents.sendInputEvent({ type: 'char', keyCode: keyCode === 'Enter' ? '\r' : ' ' })
|
||||||
|
}
|
||||||
|
win.webContents.sendInputEvent({ type: 'keyUp', keyCode }); await pause()
|
||||||
|
}
|
||||||
|
const sample = () => read(`(()=>{const c=document.querySelector('.yc-mode-chip'),i=c.querySelector('.yc-mode-chip__mode-icon'),b=c.querySelector('button'),s=getComputedStyle(c),r=e=>{const x=e.getBoundingClientRect();return{x:x.x,y:x.y,width:x.width,height:x.height}};return{chip:r(c),icon:r(i),button:b?r(b):null,background:s.backgroundColor,radius:s.borderRadius,color:s.color,expectedColor:s.getPropertyValue('--cr-text-secondary').trim(),font:s.fontSize,weight:s.fontWeight,line:s.lineHeight,padding:s.paddingLeft,iconOpacity:getComputedStyle(i).opacity,clearOpacity:b?getComputedStyle(b).opacity:null}})()`)
|
||||||
|
await pause()
|
||||||
|
for (const theme of ['light', 'dark']) for (const mode of ['plan', 'ask']) {
|
||||||
|
if (finished) return
|
||||||
|
phase(`checks:${theme}:${mode}`, 10000)
|
||||||
|
await read(`document.documentElement.dataset.theme='${theme}'; document.querySelector('.chat-pane').dataset.crTheme='${theme}'; qa.setMode('${mode}');qa.setDisabled(false);document.querySelector('#before').focus()`)
|
||||||
|
win.webContents.sendInputEvent({ type: 'mouseMove', x: 1, y: 1 }); await pause()
|
||||||
|
const normal = await sample(); samples.push({ theme, mode, state: 'normal', ...normal })
|
||||||
|
check('transparent', normal.background, 'rgba(0, 0, 0, 0)'); check('pill', parseFloat(normal.radius) >= normal.chip.height / 2)
|
||||||
|
check('height', normal.chip.height, 32); check('font', normal.font, '13px'); check('weight', normal.weight, '500'); check('line', normal.line, '20px'); check('padding', normal.padding, '8px')
|
||||||
|
check('same slot', normal.icon, normal.button); check('icon width', normal.icon.width, 16); check('default icon', normal.iconOpacity, '1'); check('default close', normal.clearOpacity, '0')
|
||||||
|
check('secondary color', await read(`(()=>{const c=document.querySelector('.yc-mode-chip'),p=document.createElement('span');p.style.color='var(--cr-text-secondary)';c.append(p);const ok=getComputedStyle(p).color===getComputedStyle(c).color;p.remove();return ok})()`))
|
||||||
|
win.webContents.sendInputEvent({ type: 'mouseMove', x: Math.round(normal.chip.x + normal.chip.width / 2), y: Math.round(normal.chip.y + normal.chip.height / 2) }); await pause()
|
||||||
|
const hover = await sample(); samples.push({ theme, mode, state: 'hover', ...hover }); check('hover icon hidden', hover.iconOpacity, '0'); check('hover X', hover.clearOpacity, '1'); check('no hover layout shift', hover.chip, normal.chip)
|
||||||
|
win.webContents.sendInputEvent({ type: 'mouseMove', x: 1, y: 1 }); await read(`document.querySelector('.yc-mode-chip').focus()`); await pause()
|
||||||
|
const focus = await sample(); check('focus icon hidden', focus.iconOpacity, '0'); check('focus X', focus.clearOpacity, '1'); check('no focus layout shift', focus.chip, normal.chip)
|
||||||
|
await fs.promises.writeFile(path.join(output, `${theme}-${mode}-focus.png`), (await win.webContents.capturePage()).toPNG())
|
||||||
|
await key('Tab'); check('Tab reaches native clear', await read(`document.activeElement.matches('.yc-mode-chip__clear')`))
|
||||||
|
const before = await read('qa.clears'); await key(mode === 'plan' ? 'Enter' : 'Space'); check('native keyboard exit', await read('qa.clears'), before + 1); check('craft hidden', await read(`!document.querySelector('.yc-mode-chip')`))
|
||||||
|
await read(`qa.setMode('${mode}');qa.setDisabled(true)`); await pause(); check('disabled has no clear', await read(`!document.querySelector('.yc-mode-chip button')`)); check('disabled not tabbable', await read(`document.querySelector('.yc-mode-chip').tabIndex`), -1)
|
||||||
|
}
|
||||||
|
if (finished) return
|
||||||
|
phase('checks:narrow', 10000)
|
||||||
|
win.setSize(320, 300); await read(`qa.setMode('ask');qa.setDisabled(false)`); await pause(); const narrow = await sample(); check('narrow slot preserved', narrow.icon, narrow.button); check('narrow no overflow', narrow.chip.x + narrow.chip.width <= 320)
|
||||||
|
await fs.promises.writeFile(path.join(output, 'narrow.png'), (await win.webContents.capturePage()).toPNG()); check('no renderer errors', errors, [])
|
||||||
|
if (finished) return
|
||||||
|
phase('write-report', 10000)
|
||||||
|
fs.writeFileSync(path.join(output, 'report.json'), JSON.stringify({ checks, samples, errors, sourceSha256: createHash('sha256').update(fs.readFileSync(file)).digest('hex'), scope: 'Own-YCode isolated component browser test, not full app/store integration or original-app pixel acceptance' }, null, 2))
|
||||||
|
phase('complete'); finished = true; app.exit(0)
|
||||||
|
} catch (error) { fail(error) }
|
||||||
|
}
|
||||||
|
// Intentionally not awaited at module scope: Electron can finish its startup lifecycle.
|
||||||
|
void run()
|
||||||
@@ -4,11 +4,10 @@
|
|||||||
*/
|
*/
|
||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import assert from 'node:assert/strict'
|
|
||||||
import { createRequire } from 'node:module'
|
import { createRequire } from 'node:module'
|
||||||
import { spawn } from 'node:child_process'
|
import { spawn } from 'node:child_process'
|
||||||
import { createHash } from 'node:crypto'
|
const require = createRequire(import.meta.url)
|
||||||
const require = createRequire(import.meta.url), module = require('electron')
|
if (process.versions.electron) throw new Error('Launch this fixture builder with Node, not Electron')
|
||||||
const base = path.resolve('.tmp')
|
const base = path.resolve('.tmp')
|
||||||
fs.mkdirSync(base, { recursive: true })
|
fs.mkdirSync(base, { recursive: true })
|
||||||
const file = 'src/renderer/src/workbuddy/composer/ModeChip.tsx'
|
const file = 'src/renderer/src/workbuddy/composer/ModeChip.tsx'
|
||||||
@@ -27,51 +26,51 @@ function Fixture(){const [mode,setMode]=useState<any>('plan'),[disabled,setDisab
|
|||||||
await require('esbuild').build({ entryPoints: [path.join(output, 'fixture.tsx')], bundle: true, platform: 'browser', jsx: 'automatic', outfile: path.join(output, 'fixture.js'), logLevel: 'warning' })
|
await require('esbuild').build({ entryPoints: [path.join(output, 'fixture.tsx')], bundle: true, platform: 'browser', jsx: 'automatic', outfile: path.join(output, 'fixture.js'), logLevel: 'warning' })
|
||||||
fs.writeFileSync(path.join(output, 'index.html'), '<html data-theme="light"><head><meta charset="utf-8"><link rel="stylesheet" href="fixture.css"></head><body><div id="root"></div><script src="fixture.js"></script></body></html>')
|
fs.writeFileSync(path.join(output, 'index.html'), '<html data-theme="light"><head><meta charset="utf-8"><link rel="stylesheet" href="fixture.css"></head><body><div id="root"></div><script src="fixture.js"></script></body></html>')
|
||||||
}
|
}
|
||||||
if (!process.versions.electron) {
|
const output = fs.mkdtempSync(path.join(base, 'mode-chip-native-'))
|
||||||
const output = fs.mkdtempSync(path.join(base, 'mode-chip-native-')), env = { ...process.env }
|
function phase(name) {
|
||||||
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
|
const detail = { phase: name, time: new Date().toISOString(), output }
|
||||||
if (process.argv.includes('--build-only')) {
|
fs.writeFileSync(path.join(output, 'runner-phase.json'), JSON.stringify(detail, null, 2))
|
||||||
await prepareFixture(output); console.log(JSON.stringify({ output, fixtureBuilt: true, nativeExecuted: false }))
|
console.log(JSON.stringify(detail))
|
||||||
} else {
|
|
||||||
const child = spawn(module, [path.resolve('scripts/test-mode-chip-native.mjs'), output], { env, windowsHide: true, stdio: 'inherit' })
|
|
||||||
const timeout = setTimeout(() => child.kill(), 60000)
|
|
||||||
child.on('error', error => { clearTimeout(timeout); console.error(error); process.exitCode = 1 })
|
|
||||||
child.on('exit', code => { clearTimeout(timeout); process.exitCode = code ?? 1; console.log(JSON.stringify({ output, exitCode: code })) })
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const { app, BrowserWindow } = module, output = path.resolve(process.argv.at(-1))
|
|
||||||
assert.ok(path.dirname(output) === base && path.basename(output).startsWith('mode-chip-native-'))
|
|
||||||
fs.mkdirSync(path.join(output, 'data')); app.setPath('userData', path.join(output, 'data')); app.disableHardwareAcceleration()
|
|
||||||
let win; const checks = [], samples = [], errors = []
|
|
||||||
try {
|
|
||||||
await prepareFixture(output)
|
|
||||||
await app.whenReady(); win = new BrowserWindow({ width: 800, height: 300, show: false, webPreferences: { contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } })
|
|
||||||
win.webContents.on('console-message', event => { if (event.level === 'error' || event.level >= 3) errors.push(event.message) })
|
|
||||||
await win.loadFile(path.join(output, 'index.html')); win.showInactive()
|
|
||||||
const read = code => win.webContents.executeJavaScript(code), pause = () => new Promise(resolve => setTimeout(resolve, 80))
|
|
||||||
const check = (label, actual, expected = true) => { assert.deepEqual(actual, expected, label); checks.push(label) }
|
|
||||||
const key = async keyCode => { win.webContents.sendInputEvent({ type: 'keyDown', keyCode }); win.webContents.sendInputEvent({ type: 'keyUp', keyCode }); await pause() }
|
|
||||||
const sample = () => read(`(()=>{const c=document.querySelector('.yc-mode-chip'),i=c.querySelector('.yc-mode-chip__mode-icon'),b=c.querySelector('button'),s=getComputedStyle(c),r=e=>{const x=e.getBoundingClientRect();return{x:x.x,y:x.y,width:x.width,height:x.height}};return{chip:r(c),icon:r(i),button:b?r(b):null,background:s.backgroundColor,radius:s.borderRadius,color:s.color,expectedColor:s.getPropertyValue('--cr-text-secondary').trim(),font:s.fontSize,weight:s.fontWeight,line:s.lineHeight,padding:s.paddingLeft,iconOpacity:getComputedStyle(i).opacity,clearOpacity:b?getComputedStyle(b).opacity:null}})()`)
|
|
||||||
await pause()
|
|
||||||
for (const theme of ['light', 'dark']) for (const mode of ['plan', 'ask']) {
|
|
||||||
await read(`document.documentElement.dataset.theme='${theme}'; document.querySelector('.chat-pane').dataset.crTheme='${theme}'; qa.setMode('${mode}');qa.setDisabled(false);document.querySelector('#before').focus()`)
|
|
||||||
win.webContents.sendInputEvent({ type: 'mouseMove', x: 1, y: 1 }); await pause()
|
|
||||||
const normal = await sample(); samples.push({ theme, mode, state: 'normal', ...normal })
|
|
||||||
check('transparent', normal.background, 'rgba(0, 0, 0, 0)'); check('pill', parseFloat(normal.radius) >= normal.chip.height / 2)
|
|
||||||
check('height', normal.chip.height, 32); check('font', normal.font, '13px'); check('weight', normal.weight, '500'); check('line', normal.line, '20px'); check('padding', normal.padding, '8px')
|
|
||||||
check('same slot', normal.icon, normal.button); check('icon width', normal.icon.width, 16); check('default icon', normal.iconOpacity, '1'); check('default close', normal.clearOpacity, '0')
|
|
||||||
check('secondary color', await read(`(()=>{const c=document.querySelector('.yc-mode-chip'),p=document.createElement('span');p.style.color='var(--cr-text-secondary)';c.append(p);const ok=getComputedStyle(p).color===getComputedStyle(c).color;p.remove();return ok})()`))
|
|
||||||
win.webContents.sendInputEvent({ type: 'mouseMove', x: Math.round(normal.chip.x + normal.chip.width / 2), y: Math.round(normal.chip.y + normal.chip.height / 2) }); await pause()
|
|
||||||
const hover = await sample(); samples.push({ theme, mode, state: 'hover', ...hover }); check('hover icon hidden', hover.iconOpacity, '0'); check('hover X', hover.clearOpacity, '1'); check('no hover layout shift', hover.chip, normal.chip)
|
|
||||||
win.webContents.sendInputEvent({ type: 'mouseMove', x: 1, y: 1 }); await read(`document.querySelector('.yc-mode-chip').focus()`); await pause()
|
|
||||||
const focus = await sample(); check('focus icon hidden', focus.iconOpacity, '0'); check('focus X', focus.clearOpacity, '1'); check('no focus layout shift', focus.chip, normal.chip)
|
|
||||||
await fs.promises.writeFile(path.join(output, `${theme}-${mode}-focus.png`), (await win.webContents.capturePage()).toPNG())
|
|
||||||
await key('Tab'); check('Tab reaches native clear', await read(`document.activeElement.matches('.yc-mode-chip__clear')`))
|
|
||||||
const before = await read('qa.clears'); await key(mode === 'plan' ? 'Enter' : 'Space'); check('native keyboard exit', await read('qa.clears'), before + 1); check('craft hidden', await read(`!document.querySelector('.yc-mode-chip')`))
|
|
||||||
await read(`qa.setMode('${mode}');qa.setDisabled(true)`); await pause(); check('disabled has no clear', await read(`!document.querySelector('.yc-mode-chip button')`)); check('disabled not tabbable', await read(`document.querySelector('.yc-mode-chip').tabIndex`), -1)
|
|
||||||
}
|
|
||||||
win.setSize(320, 300); await read(`qa.setMode('ask');qa.setDisabled(false)`); await pause(); const narrow = await sample(); check('narrow slot preserved', narrow.icon, narrow.button); check('narrow no overflow', narrow.chip.x + narrow.chip.width <= 320)
|
|
||||||
await fs.promises.writeFile(path.join(output, 'narrow.png'), (await win.webContents.capturePage()).toPNG()); check('no renderer errors', errors, [])
|
|
||||||
fs.writeFileSync(path.join(output, 'report.json'), JSON.stringify({ checks, samples, errors, sourceSha256: createHash('sha256').update(fs.readFileSync(file)).digest('hex'), scope: 'Own-YCode isolated component browser test, not full app/store integration or original-app pixel acceptance' }, null, 2)); app.exit(0)
|
|
||||||
} catch (error) { fs.writeFileSync(path.join(output, 'failure.txt'), String(error.stack || error)); console.error(error); app.exit(1) }
|
|
||||||
}
|
}
|
||||||
|
function fail(error) {
|
||||||
|
const detail = String(error.stack || error)
|
||||||
|
fs.writeFileSync(path.join(output, 'runner-failure.txt'), detail)
|
||||||
|
console.error(detail); process.exitCode = 1
|
||||||
|
}
|
||||||
|
phase('prepare-fixture')
|
||||||
|
const buildTimeout = setTimeout(() => {
|
||||||
|
fail(new Error('Fixture preparation timed out after 30000ms'))
|
||||||
|
process.exit(1)
|
||||||
|
}, 30000)
|
||||||
|
try {
|
||||||
|
// All esbuild work finishes under Node, before starting Electron's CJS entry.
|
||||||
|
await prepareFixture(output)
|
||||||
|
clearTimeout(buildTimeout)
|
||||||
|
phase('fixture-built')
|
||||||
|
if (process.argv.includes('--build-only')) {
|
||||||
|
console.log(JSON.stringify({ output, fixtureBuilt: true, nativeExecuted: false }))
|
||||||
|
} else {
|
||||||
|
const env = { ...process.env }
|
||||||
|
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
|
||||||
|
phase('launch-electron')
|
||||||
|
const child = spawn(require('electron'), [path.resolve('scripts/test-mode-chip-native-electron.cjs'), output], { env, windowsHide: true, stdio: 'inherit' })
|
||||||
|
let timedOut = false, spawnFailed = false
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
timedOut = true
|
||||||
|
let lastPhase = 'no Electron phase recorded'
|
||||||
|
try { lastPhase = fs.readFileSync(path.join(output, 'phase.json'), 'utf8') } catch {}
|
||||||
|
fail(new Error(`Electron timed out after 60000ms; last phase: ${lastPhase}`))
|
||||||
|
// Only terminate the child created by this runner, never an existing app/dev.
|
||||||
|
child.kill('SIGKILL')
|
||||||
|
}, 60000)
|
||||||
|
child.once('error', error => { spawnFailed = true; clearTimeout(timeout); fail(error) })
|
||||||
|
child.once('close', (code, signal) => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
const passed = !timedOut && !spawnFailed && code === 0 && fs.existsSync(path.join(output, 'report.json'))
|
||||||
|
process.exitCode = passed ? 0 : 1
|
||||||
|
if (!passed && !timedOut && !spawnFailed) fail(new Error(`Electron did not complete: exitCode=${code}, signal=${signal}; see phase.json / failure.txt`))
|
||||||
|
phase(passed ? 'complete' : 'failed')
|
||||||
|
console.log(JSON.stringify({ output, exitCode: code, signal, timedOut, nativeExecuted: passed }))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) { clearTimeout(buildTimeout); fail(error) }
|
||||||
@@ -199,15 +199,52 @@ createRoot(document.getElementById('root')).render(
|
|||||||
|
|
||||||
const verifyCurrentActionStyles = async theme => {
|
const verifyCurrentActionStyles = async theme => {
|
||||||
const selector = '[data-recommendation-id=archify] .sm-use-btn'
|
const selector = '[data-recommendation-id=archify] .sm-use-btn'
|
||||||
const inspect = () => read(`(()=>{const b=document.querySelector(${JSON.stringify(selector)});const s=getComputedStyle(b);return{width:s.width,height:s.height,background:s.backgroundColor,color:s.color,outline:s.outline,focused:document.activeElement===b,hover:b.matches(':hover')}})()`)
|
// Selector :hover can be false despite a trusted mouse entry and applied CSS
|
||||||
|
// (B02 Windows evidence). Keep it diagnostic, never as the success predicate.
|
||||||
|
const inspect = (point = null) => read(`(()=>{
|
||||||
|
const b=document.querySelector(${JSON.stringify(selector)}),p=${JSON.stringify(point)};
|
||||||
|
const hit=p?document.elementFromPoint(p.x,p.y):null;
|
||||||
|
const describe=e=>e?{tag:e.tagName,id:e.id,className:e.getAttribute('class')}:null;
|
||||||
|
if(!b)return{missing:true,hitTarget:describe(hit)};
|
||||||
|
const s=getComputedStyle(b),r=b.getBoundingClientRect();
|
||||||
|
return{width:s.width,height:s.height,background:s.backgroundColor,color:s.color,outline:s.outline,
|
||||||
|
focused:document.activeElement===b,hover:b.matches(':hover'),pointerEvents:s.pointerEvents,
|
||||||
|
rect:{x:r.x,y:r.y,width:r.width,height:r.height},hitTarget:describe(hit),
|
||||||
|
hitWithinAction:!!hit&&(hit===b||b.contains(hit)),pointerEntry:window.__qaHoverProbe?.entry??null};
|
||||||
|
})()`)
|
||||||
win.webContents.sendInputEvent({type:'mouseMove',x:1,y:1})
|
win.webContents.sendInputEvent({type:'mouseMove',x:1,y:1})
|
||||||
await read(`document.activeElement?.blur()`); await pause(200)
|
await read(`document.activeElement?.blur()`); await pause(200)
|
||||||
const normal = await inspect()
|
const normal = await inspect()
|
||||||
check(theme + ' use action default background is transparent', normal.background, value => value === 'rgba(0, 0, 0, 0)' || value === 'transparent')
|
check(theme + ' use action default background is transparent', normal.background, value => value === 'rgba(0, 0, 0, 0)' || value === 'transparent')
|
||||||
const point = await read(`(()=>{const r=document.querySelector(${JSON.stringify(selector)}).getBoundingClientRect();return{x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)}})()`)
|
const point = await read(`(()=>{const r=document.querySelector(${JSON.stringify(selector)}).getBoundingClientRect();return{x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)}})()`)
|
||||||
win.webContents.sendInputEvent({type:'mouseMove',...point});await pause(200)
|
// Arm only after moving off the action, so prior themes/entries cannot pass.
|
||||||
const hover = await inspect()
|
await read(`(()=>{
|
||||||
check(theme + ' pointer enters use action', hover.hover, true)
|
const b=document.querySelector(${JSON.stringify(selector)});
|
||||||
|
const probe=window.__qaHoverProbe={entry:null};
|
||||||
|
const listener=event=>{probe.entry={isTrusted:event.isTrusted,pointerType:event.pointerType,
|
||||||
|
targetIsAction:event.target===b,x:event.clientX,y:event.clientY}};
|
||||||
|
b.addEventListener('pointerenter',listener);
|
||||||
|
probe.cleanup=()=>b.removeEventListener('pointerenter',listener);
|
||||||
|
})()`)
|
||||||
|
let hover
|
||||||
|
try {
|
||||||
|
win.webContents.sendInputEvent({type:'mouseMove',...point})
|
||||||
|
const deadline = Date.now() + 10000
|
||||||
|
while (true) {
|
||||||
|
hover = await inspect(point)
|
||||||
|
if (hover.pointerEntry?.isTrusted === true && hover.pointerEntry.pointerType === 'mouse'
|
||||||
|
&& hover.pointerEntry.targetIsAction && hover.hitWithinAction
|
||||||
|
&& hover.background !== normal.background) break
|
||||||
|
if (Date.now() >= deadline) {
|
||||||
|
throw Error(theme + ' native hover timeout: ' + JSON.stringify({selector,point,normal,last: hover}))
|
||||||
|
}
|
||||||
|
await pause(50)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await read('window.__qaHoverProbe?.cleanup(); delete window.__qaHoverProbe')
|
||||||
|
}
|
||||||
|
check(theme + ' trusted mouse pointer enters use action', hover.pointerEntry.isTrusted && hover.pointerEntry.pointerType === 'mouse' && hover.pointerEntry.targetIsAction, true)
|
||||||
|
check(theme + ' pointer hit target is the action or its descendant', hover.hitWithinAction, true)
|
||||||
check(theme + ' hover has a distinct background', hover.background !== normal.background, true)
|
check(theme + ' hover has a distinct background', hover.background !== normal.background, true)
|
||||||
await capture('installed-use-' + theme + '-hover')
|
await capture('installed-use-' + theme + '-hover')
|
||||||
win.webContents.sendInputEvent({type:'mouseMove',x:1,y:1})
|
win.webContents.sendInputEvent({type:'mouseMove',x:1,y:1})
|
||||||
|
|||||||
@@ -8,22 +8,36 @@ const fixture=path.resolve(process.argv[2]||'')
|
|||||||
if(!process.argv[2]||!fs.existsSync(path.join(fixture,'index.html')))throw Error('Supply build-only fixture directory')
|
if(!process.argv[2]||!fs.existsSync(path.join(fixture,'index.html')))throw Error('Supply build-only fixture directory')
|
||||||
const profile=fs.mkdtempSync(path.join(os.tmpdir(),'ycode-b04-native-'))
|
const profile=fs.mkdtempSync(path.join(os.tmpdir(),'ycode-b04-native-'))
|
||||||
app.setPath('userData',profile)
|
app.setPath('userData',profile)
|
||||||
let timer,win,finished=false
|
let timer,win,finished=false,phase='waiting-for-app'
|
||||||
|
const started=Date.now(), rendererErrors=[]
|
||||||
|
const scheduling={backgroundThrottling:false,show:true,deadlineMs:20000}
|
||||||
|
function mark(value){phase=value;console.log(JSON.stringify({phase,elapsedMs:Date.now()-started}))}
|
||||||
function finish(error,report){
|
function finish(error,report){
|
||||||
if(finished)return;finished=true;clearTimeout(timer)
|
if(finished)return;finished=true;clearTimeout(timer)
|
||||||
const result={result:error?'FAIL':'PASS',error:error?String(error):undefined,report,limits:'Synthetic own-component Electron fixture, not the installed application or original reference UI.'}
|
const result={result:error?'FAIL':'PASS',error:error?String(error):undefined,report,phase,elapsedMs:Date.now()-started,scheduling,rendererErrors,limits:'Synthetic own-component Electron fixture, not the installed application or original reference UI.'}
|
||||||
fs.writeFileSync(path.join(fixture,'native-report.json'),JSON.stringify(result,null,2))
|
fs.writeFileSync(path.join(fixture,'native-report.json'),JSON.stringify(result,null,2))
|
||||||
console.log(JSON.stringify({result:result.result,error:result.error,output:path.join(fixture,'native-report.json')}))
|
console.log(JSON.stringify({result:result.result,error:result.error,output:path.join(fixture,'native-report.json')}))
|
||||||
if(win&&!win.isDestroyed())win.destroy()
|
if(win&&!win.isDestroyed())win.destroy()
|
||||||
app.exit(error?1:0)
|
app.exit(error?1:0)
|
||||||
}
|
}
|
||||||
|
// Bound startup as well as page loading and real rAF completion.
|
||||||
|
timer=setTimeout(()=>finish(Error('Bounded native probe timed out after 20s; phase='+phase)),20000)
|
||||||
app.whenReady().then(async()=>{
|
app.whenReady().then(async()=>{
|
||||||
|
if(finished)return
|
||||||
|
mark('creating-window')
|
||||||
session.defaultSession.webRequest.onBeforeRequest((details,callback)=>callback({cancel:!['file:','data:','blob:','devtools:'].some(p=>details.url.startsWith(p))}))
|
session.defaultSession.webRequest.onBeforeRequest((details,callback)=>callback({cancel:!['file:','data:','blob:','devtools:'].some(p=>details.url.startsWith(p))}))
|
||||||
win=new BrowserWindow({width:1000,height:800,show:true,webPreferences:{nodeIntegration:false,contextIsolation:true,sandbox:true}})
|
win=new BrowserWindow({width:1000,height:800,show:true,webPreferences:{nodeIntegration:false,contextIsolation:true,sandbox:true,backgroundThrottling:false}})
|
||||||
win.webContents.on('render-process-gone',(_,details)=>finish(Error('Renderer gone: '+details.reason)))
|
win.webContents.on('render-process-gone',(_,details)=>finish(Error('Renderer gone: '+details.reason)))
|
||||||
timer=setTimeout(()=>finish(Error('Bounded native probe timed out after 20s')),20000)
|
win.webContents.on('console-message',event=>{if(event.level==='error'||event.level>=3)rendererErrors.push(event.message)})
|
||||||
|
win.webContents.on('did-fail-load',(_,code,description)=>finish(Error('Page load failed: '+code+' '+description)))
|
||||||
|
mark('loading-fixture')
|
||||||
await win.loadFile(path.join(fixture,'index.html'))
|
await win.loadFile(path.join(fixture,'index.html'))
|
||||||
|
if(finished)return
|
||||||
|
mark('waiting-for-native-frames')
|
||||||
const report=await win.webContents.executeJavaScript(`new Promise((resolve,reject)=>{const check=()=>{if(window.__b04Error)return reject(Error(window.__b04Error));if(window.__b04Report)return resolve(window.__b04Report);setTimeout(check,25)};check()})`)
|
const report=await win.webContents.executeJavaScript(`new Promise((resolve,reject)=>{const check=()=>{if(window.__b04Error)return reject(Error(window.__b04Error));if(window.__b04Report)return resolve(window.__b04Report);setTimeout(check,25)};check()})`)
|
||||||
|
if(finished)return
|
||||||
|
mark('checking-native-report')
|
||||||
|
if(rendererErrors.length)throw Error('Renderer console errors: '+rendererErrors.join('; '))
|
||||||
if(!report.samples.some(s=>s.height>0))throw Error('No nonzero native layout measured')
|
if(!report.samples.some(s=>s.height>0))throw Error('No nonzero native layout measured')
|
||||||
if(report.samples.some(s=>s.textLength>1000||s.height>2000))throw Error('Unexpected address-sized text/height spike')
|
if(report.samples.some(s=>s.textLength>1000||s.height>2000))throw Error('Unexpected address-sized text/height spike')
|
||||||
finish(null,report)
|
finish(null,report)
|
||||||
|
|||||||
Reference in new issue
Block a user