theme.mjs (8613B)
1 // Presentation-only browser regression; no compiler/runtime assets required. 2 // nix shell nixpkgs#nodejs nixpkgs#chromium -c node ext/demo-site/test/theme.mjs 3 import assert from 'node:assert/strict'; 4 import {spawn} from 'node:child_process'; 5 import {mkdtemp,readFile,writeFile,copyFile,rm} from 'node:fs/promises'; 6 import {tmpdir} from 'node:os'; 7 import {join} from 'node:path'; 8 import {pathToFileURL} from 'node:url'; 9 const dir=await mkdtemp(join(tmpdir(),'arboricx-theme-')); 10 let browser,socket; 11 try{ 12 const root=new URL('../',import.meta.url); 13 const html=(await readFile(new URL('index.html',root),'utf8')).replace(/<script type="module"[^>]*><\/script>/,''); 14 await writeFile(join(dir,'index.html'),html); 15 await copyFile(new URL('style.css',root),join(dir,'style.css')); 16 await copyFile(new URL('theme.js',root),join(dir,'theme.js')); 17 browser=spawn(process.argv[2]||'chromium',['--headless','--no-sandbox','--disable-dev-shm-usage','--remote-debugging-port=0',`--user-data-dir=${join(dir,'profile')}`,'about:blank'],{stdio:['ignore','ignore','pipe']}); 18 const endpoint=await new Promise((resolve,reject)=>{ 19 let log='';const timer=setTimeout(()=>reject(Error(log)),15000); 20 browser.on('error',reject); 21 browser.stderr.on('data',chunk=>{log+=chunk;const m=log.match(/DevTools listening on (ws:\/\/\S+)/);if(m){clearTimeout(timer);resolve(new URL(m[1]));}}); 22 }); 23 const tab=await(await fetch(`http://${endpoint.host}/json/new?about:blank`,{method:'PUT'})).json(); 24 socket=new WebSocket(tab.webSocketDebuggerUrl); 25 await new Promise((resolve,reject)=>{socket.onopen=resolve;socket.onerror=reject;}); 26 let id=0;const pending=new Map(); 27 socket.onmessage=({data})=>{const event=JSON.parse(data);if(event.id){const p=pending.get(event.id);pending.delete(event.id);event.error?p.reject(Error(JSON.stringify(event.error))):p.resolve(event.result);}}; 28 const send=(method,params={})=>new Promise((resolve,reject)=>{pending.set(++id,{resolve,reject});socket.send(JSON.stringify({id,method,params}));}); 29 const evaluate=async expression=>{const r=await send('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true});if(r.exceptionDetails)throw Error(JSON.stringify(r.exceptionDetails));return r.result.value;}; 30 await send('Page.navigate',{url:pathToFileURL(join(dir,'index.html')).href}); 31 for(let i=0;!await evaluate("document.readyState==='complete' && !!document.getElementById('source-editor')");i++){assert.ok(i<200);await new Promise(r=>setTimeout(r,25));} 32 // Install the same presentation wrappers used by syntax.ts, without loading a runtime. 33 await evaluate(`(()=>{for(const id of ['source-editor']){const input=document.getElementById(id),wrapper=document.createElement('div');wrapper.className='syntax-editor';input.before(wrapper);wrapper.append(input);input.classList.add('syntax-input');}})()`); 34 await evaluate(`(()=>{const card=document.createElement('section');card.className='definition-card';card.style.position='relative';card.innerHTML='<div class="inline-io"><details><summary>IO details</summary></details><button>Run IO</button><input aria-label="Line"><select><option>Text</option></select></div>';document.querySelector('.source-panel').append(card);})()`); 35 for(const scheme of ['light','dark','light']){ 36 await send('Emulation.setEmulatedMedia',{features:[{name:'prefers-color-scheme',value:scheme}]}); 37 await evaluate('new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))'); 38 const result=await evaluate(`(()=>{ 39 const css=e=>getComputedStyle(e),body=css(document.body),root=css(document.documentElement); 40 const rgb=s=>s.match(/[\\d.]+/g).slice(0,3).map(Number); 41 const lum=s=>rgb(s).map(v=>{v/=255;return v<=.04045?v/12.92:((v+.055)/1.055)**2.4;}).reduce((s,v,i)=>s+v*[.2126,.7152,.0722][i],0); 42 const contrast=(a,b)=>(Math.max(lum(a),lum(b))+.05)/(Math.min(lum(a),lum(b))+.05); 43 const background=element=>{for(let node=element;node;node=node.parentElement){const color=css(node).backgroundColor;if(color!=='rgba(0, 0, 0, 0)')return color;}return body.backgroundColor;}; 44 const pairs=[[body.color,body.backgroundColor]]; 45 for(const selector of ['.intro','a','.button.primary','#source-editor','#settings-panel','#import-dialog','.definition-card summary','.definition-card .inline-io button','.definition-card .inline-io input']){ 46 const e=document.querySelector(selector),s=css(e); 47 pairs.push([selector==='#source-editor'?s.caretColor:s.color,background(e)]); 48 } 49 const editor=document.getElementById('source-editor').parentElement,ec=css(editor); 50 for(const token of ['comment','string','escape','number','keyword','definition','tree','contract','invalid','operator']){ 51 const span=document.createElement('span');span.className='syntax-'+token;editor.append(span);pairs.push([css(span).color,ec.backgroundColor]);span.remove(); 52 } 53 return {cardScheme:css(document.querySelector('.definition-card select')).colorScheme,scheme:root.colorScheme,paper:body.backgroundColor,contrast:pairs.map(([a,b])=>contrast(a,b)),source:css(document.getElementById('source-editor')).caretColor, 54 chrome:[...document.querySelectorAll('meta[name="theme-color"]')].filter(m=>matchMedia(m.media).matches).map(m=>m.content)}; 55 })()`); 56 assert.equal(result.scheme,scheme);assert.equal(result.cardScheme,'dark','Dark card native controls remain legible on a light page'); 57 assert.equal(result.paper,scheme==='dark'?'rgb(20, 30, 26)':'rgb(245, 242, 234)'); 58 assert.deepEqual(result.chrome,[scheme==='dark'?'#141e1a':'#f5f2ea']); 59 assert.equal(result.source,'rgb(227, 237, 222)'); 60 for(const ratio of result.contrast)assert.ok(ratio>=4.5,`${scheme} text contrast ${ratio}`); 61 } 62 const scheme=()=>evaluate('getComputedStyle(document.documentElement).colorScheme'); 63 await evaluate("document.getElementById('theme-toggle').click()"); 64 assert.equal(await scheme(),'dark','Toggle overrides light system preference: '+JSON.stringify(await evaluate("({theme:document.documentElement.dataset.theme,hidden:document.getElementById('theme-toggle').hidden,aria:document.getElementById('theme-toggle').getAttribute('aria-label'),scripts:[...document.scripts].map(s=>s.src),href:location.href})"))); 65 assert.equal(await evaluate("document.getElementById('theme-toggle').getAttribute('aria-label')"),'Switch to light mode'); 66 await send('Page.reload'); 67 for(let i=0;!await evaluate("document.readyState==='complete' && !document.getElementById('theme-toggle')?.hidden");i++){assert.ok(i<200);await new Promise(r=>setTimeout(r,25));} 68 assert.equal(await scheme(),'dark','Explicit choice survives reload'); 69 await send('Emulation.setEmulatedMedia',{features:[{name:'prefers-color-scheme',value:'dark'}]}); 70 await evaluate("document.getElementById('theme-toggle').focus()"); 71 await send('Input.dispatchKeyEvent',{type:'keyDown',key:' ',code:'Space',windowsVirtualKeyCode:32}); 72 await send('Input.dispatchKeyEvent',{type:'keyUp',key:' ',code:'Space',windowsVirtualKeyCode:32}); 73 assert.equal(await scheme(),'light','Keyboard toggles to light even on a dark system'); 74 for(const width of [320,390,1440]){ 75 await send('Emulation.setDeviceMetricsOverride',{width,height:844,deviceScaleFactor:1,mobile:width<950}); 76 assert.equal(await evaluate('document.documentElement.scrollWidth<=innerWidth'),true,'Header fits '+width); 77 assert.equal(await evaluate("document.getElementById('theme-toggle').getBoundingClientRect().height>=44"),true,'Touch target'); 78 } 79 await evaluate("localStorage.removeItem('arboricx-theme');window.dispatchEvent(new StorageEvent('storage',{key:'arboricx-theme',newValue:null}))"); 80 assert.equal(await scheme(),'dark','Clearing preference resumes system default'); 81 // Storage failure must not prevent page-local toggling. 82 await evaluate("Storage.prototype.setItem=()=>{throw Error('blocked')};document.getElementById('theme-toggle').click()"); 83 assert.equal(await scheme(),'light'); 84 await evaluate("document.getElementById('source-editor').classList.add('syntax-input')"); 85 await send('Emulation.setEmulatedMedia',{features:[{name:'prefers-color-scheme',value:'dark'},{name:'forced-colors',value:'active'}]}); 86 assert.equal(await evaluate("getComputedStyle(document.getElementById('source-editor')).color===getComputedStyle(document.getElementById('source-editor')).caretColor"),true); 87 console.log('Theme checks passed: system changes, contrast, toggle/keyboard, persistence, mobile header, blocked storage and forced colors.'); 88 }finally{ 89 socket?.close(); 90 if(browser&&browser.exitCode===null){const exited=new Promise(r=>browser.once('exit',r));browser.kill();await exited;} 91 await rm(dir,{recursive:true,force:true}); 92 }