Files
framebridge/source/tests/test_screen_viewer_js.py
T

86 lines
5.3 KiB
Python

"""Run the actual viewer JS under GJS with a small DOM fixture.
This tests JS state and integrity handling, not browser decoding, CSP or ChatGPT UI.
"""
import base64
import json
import shutil
import subprocess
import unittest
from framebridge_expanded.real_screen.screen_presentation import VIEWER_HTML
from test_screen_frontdoor import png
@unittest.skipUnless(shutil.which("gjs"), "GJS is not installed on this test host")
class ScreenViewerJavaScriptTests(unittest.TestCase):
def test_original_bytes_recovery_errors_and_inflight_teardown(self):
data = png()
import hashlib
payload = {
"structuredContent": {
"ok": True, "node": "LOCAL", "hostname": "test-fixture",
"result": {
"capture_id": "1" * 32, "sha256": hashlib.sha256(data).hexdigest(),
"bytes": len(data), "width": 100, "height": 40,
"captured_at": "2026-10-24T12:00:00Z",
},
},
"_meta": {"framebridgeImage": {"mimeType": "image/png", "data": base64.b64encode(data).decode()}},
}
script = VIEWER_HTML.split("<script>", 1)[1].split("</script>", 1)[0]
bootstrap = r"""
const GLib=imports.gi.GLib;
const listeners={},elements={};
class Element {
constructor(){this.hidden=true;this.disabled=true;this.textContent="";this.attrs={};const classes=new Set();this.classList={add:x=>classes.add(x),remove:x=>classes.delete(x),contains:x=>classes.has(x)};}
setAttribute(k,v){this.attrs[k]=v;} removeAttribute(k){delete this.attrs[k];if(k==="src"||k==="href")this[k]="";}
async decode(){const b=GLib.base64_decode(this.src.split(",")[1]);const d=new DataView(b.buffer,b.byteOffset,b.byteLength);this.naturalWidth=d.getUint32(16);this.naturalHeight=d.getUint32(20);}
}
const fakeDocument={getElementById:id=>elements[id]||(elements[id]=new Element()),body:{scrollHeight:640}};
const fakeWindow={parent:{postMessage:()=>{}},addEventListener:(name,fn)=>{listeners[name]=fn;}};
globalThis.setTimeout=()=>0;
globalThis.atob=value=>Array.from(GLib.base64_decode(value),x=>String.fromCharCode(x)).join("");
async function realDigest(name,bytes){if(name!=="SHA-256")throw Error("wrong algorithm");const hex=GLib.compute_checksum_for_data(GLib.ChecksumType.SHA256,bytes);return Uint8Array.from(hex.match(/../g),x=>parseInt(x,16)).buffer;}
globalThis.crypto={subtle:{digest:realDigest}};
let checks=0;
function check(v,m){if(!v)throw Error(m);checks++;}
"""
cases = r"""
return (async()=>{
const fixture=PAYLOAD,original="data:image/png;base64,"+fixture._meta.framebridgeImage.data;
await render(fixture);
check(img.src===original,"original bytes changed");check(!img.hidden&&!viewport.hidden,"valid image hidden");
check(statusEl.textContent.includes("Original PNG verified"),"integrity success missing");
check(details.textContent.includes(fixture.structuredContent.result.sha256),"hash not displayed");
check(download.href===original,"download bytes changed");
native.onclick();check(viewport.classList.contains("native"),"native size unavailable");
fit.onclick();check(!viewport.classList.contains("native"),"fit unavailable");
await render({structuredContent:{ok:false,error:"TEST_ERROR"}});
check(img.hidden&&viewport.hidden,"error did not hide image");check(active===null,"error retained active capture");
check(!img.src&&!download.href&&!details.textContent,"error retained stale image data");
await render(fixture);check(!img.hidden&&img.src===original,"same original did not recover after error");
const bad=JSON.parse(JSON.stringify(fixture));bad.structuredContent.result.sha256="0".repeat(64);
await render(bad);check(img.hidden&&statusEl.textContent.includes("SHA-256 mismatch"),"corrupt digest not rejected");
await render(fixture);check(!img.hidden,"valid image did not recover after bad digest");
const slow=JSON.parse(JSON.stringify(fixture));slow.structuredContent.result.capture_id="2".repeat(32);
let finish;crypto.subtle.digest=(...args)=>new Promise(resolve=>{finish=()=>realDigest(...args).then(resolve);});
const inflight=render(slow);check(typeof finish==="function","digest did not start");
listeners.message({source:window.parent,data:{jsonrpc:"2.0",method:"ui/notifications/teardown",params:{}}});
await finish();await inflight;
check(img.hidden&&viewport.hidden&&!img.src,"inflight render revived after teardown");
check(fit.disabled&&native.disabled&&full.disabled,"teardown left controls enabled");
return checks;
})();
"""
executable = bootstrap + "\nconst run = function(window,document,crypto,atob,setTimeout){" + script + cases.replace("PAYLOAD", json.dumps(payload)) + "};\n"
executable += 'const loop=new GLib.MainLoop(null,false);let failed=false;run(fakeWindow,fakeDocument,globalThis.crypto,globalThis.atob,globalThis.setTimeout).then(n=>{print("PASS "+n+" viewer state assertions");loop.quit();}).catch(e=>{failed=true;printerr(String(e.stack||e));loop.quit();});loop.run();if(failed)imports.system.exit(1);'
result = subprocess.run([shutil.which("gjs"), "-c", executable],
capture_output=True, text=True, timeout=8)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("PASS 16 viewer state assertions", result.stdout)
if __name__ == "__main__":
unittest.main()