Back to Funclet
SOURCE / PINNED RELEASE

Made of little things.

Funclet

Release
0b497cfc2f66…
Author-recorded commit
1e531b24f490…
License
LICENSE
Author’s source reference
nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-6b7a37cac1d

Archive hash verified: 71d97a89ed8c40c2…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

tests/mobile-graphics.mjs
// Real Chromium/WebGL at Android density, with controlled GPU faults and a host fixture.
import assert from 'node:assert/strict';
import { readFileSync, realpathSync, mkdirSync, writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { execFileSync } from 'node:child_process';
import { fixture } from './fixtures/host.mjs';
const require=createRequire(`${realpathSync('node_modules/@napplet/conformance-cli')}/package.json`);
const {chromium,devices}=require('playwright');
const executablePath=JSON.parse(execFileSync('soyli',['browser','path','--json'],{encoding:'utf8'}))
  .executables.find(x=>x.installed&&x.name.startsWith('chromium'))?.path;
assert.ok(executablePath,'soyLI Chromium must be installed');
const browser=await chromium.launch({executablePath});
const html=readFileSync('dist/index.html','utf8');
mkdirSync('.napplet-space/mobile',{recursive:true});

function instrument() {
  window.__gpu={draws:0,frames:0,fail:'',contexts:[],buffers:[]};
  const getContext=HTMLCanvasElement.prototype.getContext;
  HTMLCanvasElement.prototype.getContext=function(type,...args) {
    const gl=getContext.call(this,type,...args);
    if(!gl||type!=='webgl2'||gl.__instrumented)return gl;
    gl.__instrumented=true;window.__gpu.contexts.push(args[0]);
    for(const name of ['drawElements','drawArrays','drawElementsInstanced']) {
      const draw=gl[name].bind(gl);
      gl[name]=(...args)=>{window.__gpu.draws++;if(window.__gpu.fail==='draw')throw new Error('Injected GPU draw failure');return draw(...args);};
    }
    const clear=gl.clear.bind(gl);
    gl.clear=(...args)=>{window.__gpu.frames++;return clear(...args);};
    const viewport=gl.viewport.bind(gl);
    gl.viewport=(...args)=>{if(window.__gpu.fail==='resize')throw new Error('Injected GPU resize failure');return viewport(...args);};
    const storage=gl.renderbufferStorage.bind(gl);
    gl.renderbufferStorage=(...args)=>{window.__gpu.buffers.push(args.slice(1));return storage(...args);};
    const programParameter=gl.getProgramParameter.bind(gl);
    gl.getProgramParameter=(program,name)=>window.__gpu.fail==='shader'&&name===gl.LINK_STATUS?false:programParameter(program,name);
    return gl;
  };
}

try {
  const context=await browser.newContext({...devices['Pixel 7'],viewport:{width:390,height:844},deviceScaleFactor:3});
  const page=await context.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));
  page.on('console',msg=>{if(msg.type()==='error'&&/Shader Error|VALIDATE_STATUS|shader.*compile/i.test(msg.text()))errors.push(msg.text());});
  await page.setContent('<meta name="viewport" content="width=device-width,initial-scale=1"><iframe sandbox="allow-scripts" style="position:fixed;inset:0;width:100%;height:100%;border:0"></iframe>');
  const frame=page.frameLocator('iframe');const stage=frame.locator('#receiver-stage');const canvas=stage.locator('canvas.receiver-canvas');
  const app=()=>page.frames().find(f=>f.url()==='about:srcdoc');
  const mount=async(fail='')=>{
    await page.locator('iframe').evaluate((el,source)=>el.srcdoc=source,
      html.replace('<head>',`<head><script>(${fixture.toString()})();(${instrument.toString()})();window.__gpu.fail=${JSON.stringify(fail)};<\/script>`));
    await frame.getByRole('button',{name:'Listen to Test Radio',exact:true}).tap();
    await frame.locator('#playback-label').filter({hasText:'ON AIR'}).waitFor();
    if(!fail){await canvas.waitFor();assert.equal(await stage.getAttribute('data-renderer'),'webgl');}
  };
  const fallbackPlayback=async()=>{
    await stage.locator('.scene-fallback').waitFor();
    assert.equal(await canvas.count(),0,'permanent failure releases the GPU canvas');
    await frame.getByRole('button',{name:'Pause radio',exact:true}).tap();
    await frame.locator('#playback-label').filter({hasText:'PAUSED'}).waitFor();
    await frame.getByRole('button',{name:'Play radio',exact:true}).tap();
    await frame.locator('#playback-label').filter({hasText:'ON AIR'}).waitFor();
    await frame.locator('#volume').fill('35');await frame.locator('#volume').dispatchEvent('input');
    assert.equal(await app().evaluate(()=>window.__test.calls.filter(c=>c.type==='create').length),1,'graphics recovery must not retune playback');
  };
  await mount();await page.waitForTimeout(300);
  await app().evaluate(()=>{window.__gpu.frames=0;window.__gpu.draws=0;});
  await page.waitForTimeout(1000);
  const metrics=await app().evaluate(()=>({gpu:window.__gpu,canvas:{width:document.querySelector('.receiver-canvas').width,height:document.querySelector('.receiver-canvas').height},dpr:devicePixelRatio,coarse:matchMedia('(pointer:coarse)').matches}));
  assert.equal(metrics.coarse,true);assert.equal(metrics.dpr,3);assert.equal(metrics.gpu.contexts[0].antialias,false);
  assert.ok(metrics.gpu.frames>0&&metrics.gpu.frames<=32,'mobile frames must be bounded near 30 fps');
  assert.deepEqual(metrics.gpu.buffers,[],'mobile must not allocate shadow renderbuffers');
  const image=await canvas.screenshot();await page.waitForTimeout(300);
  assert.ok(!image.equals(await canvas.screenshot()),'the 3D radio and cube must keep animating');
  // Phone rotation and large host frames cannot allocate an unbounded drawing buffer.
  for(const [width,height] of [[844,390],[1440,900],[390,844]]) {
    await page.setViewportSize({width,height});await page.waitForTimeout(150);
    const size=await canvas.evaluate(el=>({width:el.width,height:el.height}));
    assert.ok(size.width*size.height<=400_000&&Math.max(size.width,size.height)<=1024);
    assert.equal(await stage.getAttribute('data-renderer'),'webgl');
  }
  await canvas.screenshot({path:'.napplet-space/mobile/receiver-android.png'});
  // Offscreen embeds stop issuing GPU work, then resume when visible again.
  await page.locator('iframe').evaluate(el=>el.style.top='2000px');await page.waitForTimeout(100);
  const stopped=await app().evaluate(()=>window.__gpu.draws);await page.waitForTimeout(200);
  assert.equal(await app().evaluate(()=>window.__gpu.draws),stopped);
  await page.locator('iframe').evaluate(el=>el.style.top='0');await page.waitForTimeout(150);
  assert.ok(await app().evaluate(()=>window.__gpu.draws)>stopped);
  // Transient GPU loss restores once; repeated loss releases the view, not the player.
  await canvas.evaluate(el=>{window.__gpu.reset=el.getContext('webgl2').getExtension('WEBGL_lose_context');window.__gpu.reset.loseContext();});
  await stage.locator('.scene-fallback').waitFor();
  await app().evaluate(()=>window.__gpu.reset.restoreContext());
  await canvas.waitFor({state:'visible'});await page.waitForTimeout(150);
  assert.equal(await stage.getAttribute('data-renderer'),'webgl');
  await canvas.evaluate(el=>el.getContext('webgl2').getExtension('WEBGL_lose_context').loseContext());
  await fallbackPlayback();
  // Faults on a later animation frame and in ResizeObserver must stay inside the view.
  await mount();await app().evaluate(()=>window.__gpu.fail='draw');
  await fallbackPlayback();
  await mount();await app().evaluate(()=>window.__gpu.fail='resize');
  await page.setViewportSize({width:400,height:844});await fallbackPlayback();
  await mount('shader');await fallbackPlayback();
  assert.deepEqual(errors,[],'graphics faults must not escape into the application lifecycle');
  const result={passed:true,engine:'Chromium, Pixel 7 emulation (not physical Android hardware)',metrics,
    checks:['30 fps and drawing-buffer budget','no MSAA or dynamic shadow renderbuffer','animated 3D','portrait/landscape resizing','offscreen suspension','one GPU restoration, bounded repeated loss','draw, resize and shader failures preserve playback'],errors};
  writeFileSync('.napplet-space/mobile/results.json',JSON.stringify(result,null,2));console.log(JSON.stringify(result,null,2));
} finally {await browser.close();}