// Dependency-free Chrome DevTools checks. Run after pnpm build:education. import { spawn } from 'node:child_process' import { existsSync } from 'node:fs' import { mkdtemp, rm, mkdir, writeFile, readFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import assert from 'node:assert/strict' const chromePath = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' assert.ok(existsSync(chromePath), 'Set CHROME_PATH to a local Chrome/Chromium executable') const profile = await mkdtemp(join(tmpdir(), 'reformer-chrome-')) const shots = process.argv[2] || '/tmp/reformer-checks' await mkdir(shots, { recursive: true }) const chrome = spawn(chromePath, ['--headless=new', '--no-sandbox', '--disable-gpu', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'], { stdio: ['ignore', 'ignore', 'pipe'] }) let socket const wait = ms => new Promise(resolve => setTimeout(resolve, ms)) try { const port = await new Promise((done, fail) => { const timer = setTimeout(() => fail(new Error('Chrome startup timed out')), 15000) chrome.stderr.on('data', data => { const match = String(data).match(/DevTools listening on ws:\/\/[^:]+:(\d+)/) if (match) { clearTimeout(timer); done(Number(match[1])) } }) chrome.on('error', fail) chrome.on('exit', code => { clearTimeout(timer); fail(new Error(`Chrome exited: ${code}`)) }) }) const targets = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json() socket = new WebSocket(targets.find(t => t.type === 'page').webSocketDebuggerUrl) await new Promise(resolve => socket.addEventListener('open', resolve, { once: true })) let id = 0 const pending = new Map(), errors = [], requests = [] socket.addEventListener('message', event => { const data = JSON.parse(event.data) if (data.id) { const task = pending.get(data.id) if (task) { pending.delete(data.id); clearTimeout(task.timer); data.error ? task.reject(new Error(data.error.message)) : task.resolve(data.result) } } if (data.method === 'Runtime.exceptionThrown') errors.push(data.params.exceptionDetails.text) if (data.method === 'Log.entryAdded' && data.params.entry.level === 'error') errors.push(data.params.entry.text) if (data.method === 'Network.requestWillBeSent') requests.push(data.params.request.url) }) const send = (method, params = {}) => new Promise((resolve, reject) => { const key = ++id, timer = setTimeout(() => { pending.delete(key); reject(new Error(`${method} timed out`)) }, 12000) pending.set(key, { resolve, reject, timer }); socket.send(JSON.stringify({ id: key, method, params })) }) const evaluate = async expression => { const result = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }) if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text) return result.result.value } const until = async expression => { for (let i = 0; i < 80; i++) { if (await evaluate(expression)) return; await wait(50) } throw new Error(`Condition timed out: ${expression}`) } const shot = async (name, width, height = 900) => { await evaluate('window.scrollTo(0,0)'); await wait(80) const { data } = await send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true, clip: { x: 0, y: 0, width, height, scale: 1 } }) await writeFile(join(shots, `${name}.png`), Buffer.from(data, 'base64')) } for (const method of ['Page.enable', 'Runtime.enable', 'Log.enable', 'Network.enable']) await send(method) const url = pathToFileURL(resolve('packages/server/public/learn/reformer/index.html')).href const navigate = async (width, reduced = false, noScript = false) => { await send('Emulation.setDeviceMetricsOverride', { width, height: 920, deviceScaleFactor: 1, mobile: width < 700 }) await send('Emulation.setTouchEmulationEnabled', { enabled: width < 700, maxTouchPoints: 1 }) await send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: reduced ? 'reduce' : 'no-preference' }] }) await send('Emulation.setScriptExecutionDisabled', { value: noScript }) await send('Page.navigate', { url }) await wait(150) if (!noScript) await until('document.body.classList.contains("ready")') } const click = selector => evaluate(`document.querySelector(${JSON.stringify(selector)}).click()`) const setRange = (selector, value) => evaluate(`(()=>{const el=document.querySelector(${JSON.stringify(selector)});el.value=${value};el.dispatchEvent(new Event('input',{bubbles:true}));return true})()`) const noOverflow = async () => assert.equal(await evaluate('document.documentElement.scrollWidth > innerWidth'), false, 'horizontal overflow') for (const width of [375, 390, 430, 1100]) { await navigate(width, true); await noOverflow() assert.equal(await evaluate("/教练审阅版|教练已审核|尚未完成审核|待教练审核|人体教学暂未开放/.test(document.body.innerText)"), false, 'editorial labels and unavailable feature placeholders must not appear') assert.equal(await evaluate("document.querySelector('#teaching-media').hidden"), true, 'unconfigured media stays out of the reading flow') for (const value of [0, 25, 50, 75, 100]) { await setRange('#timeline', value) await until(`Math.abs(Number(document.querySelector('.stage').dataset.progress)-${value / 100})<0.0002`) await noOverflow() const bbox = await evaluate(`(()=>{const b=document.querySelector('.reformer-svg').getBBox();return {x:b.x,y:b.y,right:b.x+b.width,bottom:b.y+b.height}})()`) assert.ok(bbox.x >= 0 && bbox.y >= 0 && bbox.right <= 760 && bbox.bottom <= 475, `drawing clipped at ${width}px / ${value}%: ${JSON.stringify(bbox)}`) } await setRange('#timeline', 0); await wait(40) await shot(`assembled-${width}`, width, width < 700 ? 920 : 1000) await click('[data-view="exploded"]'); await until("document.querySelector('.stage').dataset.explode==='1'") await noOverflow(); await shot(`exploded-${width}`, width, 920) await click('[data-view="assembled"]'); await wait(40) await click('[data-part-button="pulleys"]') assert.equal(await evaluate("document.querySelector('#part-name').textContent"), '滑轮') assert.equal(await evaluate("document.querySelectorAll('[data-component=\"pulleys\"].is-selected').length"), 1) await click('[data-preset="b1"]') assert.equal(await evaluate("document.querySelectorAll('[data-spring-button][aria-pressed=\"true\"]').length"), 1) await click('[data-spring-button="b1"]') assert.equal(await evaluate("document.querySelectorAll('[data-spring-button][aria-pressed=\"true\"]').length"), 1, 'must retain one connection') await click('[data-preset="r1,r2,r3,b1,g1"]') assert.equal(await evaluate("document.querySelectorAll('[data-spring-button][aria-pressed=\"true\"]').length"), 5) await setRange('#position', 70); await until("Math.abs(Number(document.querySelector('.stage').dataset.position)-0.7)<0.001") await click('#static-toggle'); assert.equal(await evaluate("document.querySelector('.static-parts').getBoundingClientRect().height>0"), true) await click('#static-toggle'); assert.equal(await evaluate("document.body.classList.contains('ready')"), true) console.log(`${width}px: layout, geometry bounds, selection, springs, manual sliders, static toggle passed`) } await navigate(390) await click('[data-part-button="carriage"]') // cancels the initial tour await click('#play'); await wait(450); await click('#play') const paused = await evaluate("Number(document.querySelector('.stage').dataset.progress)") await wait(200) assert.equal(await evaluate("Number(document.querySelector('.stage').dataset.progress)"), paused, 'pause must hold the position') assert.ok(paused > 0, 'playback did not advance') await setRange('#timeline', 75); await until("Math.abs(Number(document.querySelector('.stage').dataset.progress)-0.75)<0.0002") await click('#play'); await wait(250); await click('#play') assert.ok(await evaluate("Number(document.querySelector('.stage').dataset.progress) > 0.75"), 'resume should continue from seek position') await click('#replay'); await wait(150); await click('#play') assert.ok(await evaluate("Number(document.querySelector('.stage').dataset.progress) < 0.1"), 'replay should restart') await click('[data-view="exploded"]'); await wait(120); await click('[data-view="assembled"]') await until("Number(document.querySelector('.stage').dataset.explode)<0.0002") // Actual touch input on the native range control, with the sticky diagram still in view. await evaluate("document.querySelector('#position').scrollIntoView({block:'end'})") await wait(100) const rect = await evaluate("(()=>{const r=document.querySelector('#position').getBoundingClientRect();return {x:r.x,y:r.y,w:r.width,h:r.height}})()") const touch = (type, x) => send('Input.dispatchTouchEvent', { type, touchPoints: type === 'touchEnd' ? [] : [{ x, y: rect.y + rect.h / 2, id: 1 }] }) await touch('touchStart', rect.x + 20) for (let i = 0; i <= 12; i++) { await touch('touchMove', rect.x + 20 + i / 12 * (rect.w - 40)); await wait(20) } await touch('touchEnd', 0); await wait(900) assert.ok(await evaluate("Number(document.querySelector('.stage').dataset.position)>0.7"), 'touch drag failed') assert.ok(await evaluate("document.querySelector('.diagram-panel').getBoundingClientRect().bottom > 0"), 'diagram must remain visible while adjusting slider') const screen = await send('Page.captureScreenshot', { format: 'png' }) await writeFile(join(shots, 'touch-controls.png'), Buffer.from(screen.data, 'base64')) console.log('Playback: pause, seek, resume, replay, mid-transition reversal and touch drag passed') await navigate(390, false, true) await wait(300) // CDP evaluation remains usable while page-owned scripts are disabled. assert.ok(await evaluate("document.querySelector('.static-parts').getBoundingClientRect().height > 100"), 'no-JS parts must be visible') assert.equal(await evaluate("document.querySelector('.controls').getBoundingClientRect().height"), 0) await shot('no-javascript', 390, 1600) await navigate(390, true) await wait(2000) assert.equal(await evaluate("Number(document.querySelector('.stage').dataset.progress)"), 0, 'reduced motion must not start the tour') assert.equal(await evaluate("document.querySelector('#play').disabled"), true) if (process.argv.includes('--write-native-fallback')) { const svg = await readFile('packages/server/public/learn/reformer/reformer.svg', 'utf8') const data = await evaluate(`(async()=>{const image=new Image();image.src='data:image/svg+xml;charset=utf-8,'+encodeURIComponent(${JSON.stringify(svg)});await image.decode();const canvas=document.createElement('canvas');canvas.width=1140;canvas.height=713;canvas.getContext('2d').drawImage(image,0,0,1140,713);return canvas.toDataURL('image/png').split(',')[1]})()`) await writeFile('packages/app/src/static/learn/reformer.png', Buffer.from(data, 'base64')) console.log('Updated native PNG fallback from the standalone SVG.') } assert.deepEqual(errors, [], 'browser console errors') assert.deepEqual(requests.filter(url => /^https?:/.test(url)), [], 'standalone page must not request external assets') console.log(`No-JS fallback, reduced motion, zero external requests and clean browser console passed. Screenshots: ${shots}`) } finally { socket?.close(); chrome.kill('SIGKILL'); await wait(200); await rm(profile, { recursive: true, force: true }) }