← Files HA Interaction AuditARCHIVED FILE

skills/ha-interaction-audit/assets/harness/browser-helpers.mjs

6.83 KB · Oct 3, 2026 · 06:34 UTC

↓ Download file

// Puppeteer helpers for Browserless. No live HA connection or browser launch here.
export function createBrowserHelpers(page) {
  const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
  // A path is CSS at each successive open shadow root, ending at one element.
  const inspect = async (path, scroll = false) => page.evaluate(({path,scroll}) => {
    let root = document, el;
    for (let i = 0; i < path.length; i++) {
      const matches = root.querySelectorAll(path[i]);
      if (matches.length !== 1) throw new Error('Target must be unique: '+path[i]);
      el = matches[0];
      if (i < path.length-1) {
        root = el.shadowRoot;
        if (!root) throw new Error('Missing open shadow root: '+path[i]);
      }
    }
    if (!el) throw new Error('Empty target path');
    let rect = el.getBoundingClientRect();
    if (scroll && (rect.top < 0 || rect.bottom > innerHeight || rect.left < 0 || rect.right > innerWidth)) {
      el.scrollIntoView({block:'center',inline:'center',behavior:'instant'});
      rect = el.getBoundingClientRect();
    }
    const x = rect.left+rect.width/2, y = rect.top+rect.height/2;
    let hit = document.elementFromPoint(x,y);
    while (hit?.shadowRoot) {
      const next = hit.shadowRoot.elementFromPoint(x,y);
      if (!next || next === hit) break;
      hit = next;
    }
    let node = hit, receives = false;
    while (node) {
      if (node === el) { receives = true; break; }
      node = node.parentElement || node.getRootNode()?.host;
    }
    let active = document.activeElement;
    while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
    const css = getComputedStyle(el);
    let ancestor=el, ancestorsVisible=true, inert=false;
    while(ancestor) {
      const style=getComputedStyle(ancestor);
      if(style.display==='none' || ['hidden','collapse'].includes(style.visibility) || Number(style.opacity)===0) ancestorsVisible=false;
      inert ||= ancestor.hasAttribute('inert');
      ancestor=ancestor.parentElement || ancestor.getRootNode()?.host;
    }
    const viewport={left:window.visualViewport?.offsetLeft??0,top:window.visualViewport?.offsetTop??0,
      width:window.visualViewport?.width??innerWidth,height:window.visualViewport?.height??innerHeight};
    const clipped={left:Math.max(rect.left,viewport.left),top:Math.max(rect.top,viewport.top),
      right:Math.min(rect.right,viewport.left+viewport.width),bottom:Math.min(rect.bottom,viewport.top+viewport.height)};
    const area=Math.max(0,clipped.right-clipped.left)*Math.max(0,clipped.bottom-clipped.top);
    const viewportFraction=rect.width*rect.height>0?area/(rect.width*rect.height):0;
    const hitSamples=[];
    if(area>0) for(const [fx,fy] of [[.5,.5],[.2,.2],[.8,.2],[.2,.8],[.8,.8]]) {
      const px=clipped.left+(clipped.right-clipped.left)*fx,py=clipped.top+(clipped.bottom-clipped.top)*fy;
      let candidate=document.elementFromPoint(px,py);
      while(candidate?.shadowRoot) {
        const next=candidate.shadowRoot.elementFromPoint(px,py);
        if(!next || next===candidate) break;
        candidate=next;
      }
      let cursor=candidate,matched=false;
      while(cursor) {
        if(cursor===el) {matched=true;break;}
        cursor=cursor.parentElement || cursor.getRootNode()?.host;
      }
      hitSamples.push({x:px,y:py,receives:matched});
    }
    const cssVisible=ancestorsVisible && (typeof el.checkVisibility!=='function' ||
      el.checkVisibility({checkOpacity:true,checkVisibilityCSS:true,contentVisibilityAuto:true}));
    return {x,y,top:rect.top,bottom:rect.bottom,left:rect.left,right:rect.right,
      width:rect.width,height:rect.height,receives,disabled:inert||el.matches(':disabled,[aria-disabled="true"]'),
      visible:cssVisible&&css.display!=='none'&&rect.width>0&&rect.height>0,
      viewport,viewportFraction,hitSamples,inert,
      sampledHitFraction:hitSamples.length?hitSamples.filter(s=>s.receives).length/hitSamples.length:0,
      inViewport:x>=0&&y>=0&&x<innerWidth&&y<innerHeight,
      focused:active===el,value:'value' in el?el.value:null,
      selectionStart:el.selectionStart??null,selectionEnd:el.selectionEnd??null,
      selectionDirection:el.selectionDirection??null,scrollY,scrollTop:el.scrollTop,
      tag:el.tagName,id:el.id};
  },{path,scroll});
  return {
    inspect,
    async sample(path,{durationMs=1000,intervalMs=100}={}) {
      if(!Number.isFinite(durationMs) || durationMs<=0 || durationMs>30000 ||
        !Number.isFinite(intervalMs) || intervalMs<=0 || intervalMs>durationMs)
        throw new Error('Use a bounded sample window and positive interval');
      const samples=[],start=performance.now();
      do {
        let observation;
        try {observation=await inspect(path);}
        catch(error) {error.auditSamples=samples; throw error;}
        samples.push({at:performance.now()-start,observation});
        const elapsed=samples.at(-1).at-samples[0].at;
        if(elapsed>=durationMs) break;
        await pause(Math.min(intervalMs,durationMs-elapsed));
      } while(true);
      return {samples,timeDomain:'monotonic-real',requestedDurationMs:durationMs,
        scope:'Sampled geometry and hit tests; inspect screenshots and device-specific gaps separately'};
    },
    async tap(path,{timeoutMs=2500,stableMs=80}={}) {
      await inspect(path,true);
      const started = Date.now(); let prior, stableAt = Date.now();
      while (Date.now()-started < timeoutMs) {
        const now = await inspect(path);
        if (!prior || ['top','left','width','height'].some(k=>Math.abs(now[k]-prior[k])>.5)) stableAt=Date.now();
        if (now.visible && now.inViewport && now.receives && !now.disabled && Date.now()-stableAt>=stableMs) {
          await page.touchscreen.tap(now.x,now.y);
          return {input:'browser-touch',point:{x:now.x,y:now.y},target:now};
        }
        prior=now; await pause(20);
      }
      throw new Error('Target did not become stable, visible and actionable');
    },
    async type(text,{delay=20}={}) { await page.keyboard.type(text,{delay}); },
    async until(fn,{timeoutMs=2500,intervalMs=25}={}) {
      const start=Date.now(); let value;
      do { value=await fn(); if(value) return value; await pause(intervalMs); } while(Date.now()-start<timeoutMs);
      throw new Error('Expected condition did not settle before deadline');
    },
    async swipe({x,y,toX,toY,durationMs=220,steps=12}) {
      const session = await page.target().createCDPSession();
      try {
        await session.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[{x,y}]});
        for(let i=1;i<=steps;i++) {
          await session.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[{x:x+(toX-x)*i/steps,y:y+(toY-y)*i/steps}]});
          await pause(durationMs/steps);
        }
      } finally {
        try { await session.send('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[]}); }
        finally { await session.detach(); }
      }
    }
  };
}

SHA-256: d0169ba67f35be63d830b388a872ff7ece183a7c65afbb5b6825b52d2572fcdc