diff --git a/scripts/README.md b/scripts/README.md index 6ad9e79..d4a0e2c 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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 进行未经允许的操作。 +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 断言都不等于真实布局或像素验收。 真实 dev 启动遵循 [Dev 启动 SOP](../docs/协作/Dev启动SOP.md),测试应用另用隔离数据,不能以启动测试窗口代替用户要求的 dev。 diff --git a/scripts/native-probe-contract.test.mjs b/scripts/native-probe-contract.test.mjs new file mode 100644 index 0000000..e27046e --- /dev/null +++ b/scripts/native-probe-contract.test.mjs @@ -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) + } +}) diff --git a/scripts/test-mode-chip-native-electron.cjs b/scripts/test-mode-chip-native-electron.cjs new file mode 100644 index 0000000..13fea2c --- /dev/null +++ b/scripts/test-mode-chip-native-electron.cjs @@ -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() diff --git a/scripts/test-mode-chip-native.mjs b/scripts/test-mode-chip-native.mjs index 11d4ca9..11f844f 100644 --- a/scripts/test-mode-chip-native.mjs +++ b/scripts/test-mode-chip-native.mjs @@ -4,11 +4,10 @@ */ import fs from 'node:fs' import path from 'node:path' -import assert from 'node:assert/strict' import { createRequire } from 'node:module' import { spawn } from 'node:child_process' -import { createHash } from 'node:crypto' -const require = createRequire(import.meta.url), module = require('electron') +const require = createRequire(import.meta.url) +if (process.versions.electron) throw new Error('Launch this fixture builder with Node, not Electron') const base = path.resolve('.tmp') fs.mkdirSync(base, { recursive: true }) const file = 'src/renderer/src/workbuddy/composer/ModeChip.tsx' @@ -27,51 +26,51 @@ function Fixture(){const [mode,setMode]=useState('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' }) fs.writeFileSync(path.join(output, 'index.html'), '
') } -if (!process.versions.electron) { - const output = fs.mkdtempSync(path.join(base, 'mode-chip-native-')), env = { ...process.env } - delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL - if (process.argv.includes('--build-only')) { - await prepareFixture(output); console.log(JSON.stringify({ output, fixtureBuilt: true, nativeExecuted: false })) - } 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) } +const output = fs.mkdtempSync(path.join(base, 'mode-chip-native-')) +function phase(name) { + const detail = { phase: name, time: new Date().toISOString(), output } + fs.writeFileSync(path.join(output, 'runner-phase.json'), JSON.stringify(detail, null, 2)) + console.log(JSON.stringify(detail)) } +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) } diff --git a/scripts/test-recommended-skills-ui.mjs b/scripts/test-recommended-skills-ui.mjs index d177b93..f72d9b0 100644 --- a/scripts/test-recommended-skills-ui.mjs +++ b/scripts/test-recommended-skills-ui.mjs @@ -199,15 +199,52 @@ createRoot(document.getElementById('root')).render( const verifyCurrentActionStyles = async theme => { 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}) await read(`document.activeElement?.blur()`); await pause(200) const normal = await inspect() 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)}})()`) - win.webContents.sendInputEvent({type:'mouseMove',...point});await pause(200) - const hover = await inspect() - check(theme + ' pointer enters use action', hover.hover, true) + // Arm only after moving off the action, so prior themes/entries cannot pass. + await read(`(()=>{ + 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) await capture('installed-use-' + theme + '-hover') win.webContents.sendInputEvent({type:'mouseMove',x:1,y:1}) diff --git a/scripts/verify-b04-image-native.cjs b/scripts/verify-b04-image-native.cjs index ec8e7df..1e6fa94 100644 --- a/scripts/verify-b04-image-native.cjs +++ b/scripts/verify-b04-image-native.cjs @@ -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') const profile=fs.mkdtempSync(path.join(os.tmpdir(),'ycode-b04-native-')) 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){ 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)) console.log(JSON.stringify({result:result.result,error:result.error,output:path.join(fixture,'native-report.json')})) if(win&&!win.isDestroyed())win.destroy() 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()=>{ + if(finished)return + mark('creating-window') 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))) - 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')) + 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()})`) + 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.textLength>1000||s.height>2000))throw Error('Unexpected address-sized text/height spike') finish(null,report)