diff --git a/.github/workflows/cross-platform.yml b/.github/workflows/cross-platform.yml index 5a7291a4..7d9af2f6 100644 --- a/.github/workflows/cross-platform.yml +++ b/.github/workflows/cross-platform.yml @@ -19,7 +19,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python: ["3.10", "3.12"] + python: ["3.9", "3.10", "3.12"] steps: - uses: actions/checkout@v4 @@ -61,13 +61,14 @@ jobs: run: python tests/test_status_parse.py safety: - name: safety (${{ matrix.os }}) + name: safety (${{ matrix.os }} / py${{ matrix.python }}) runs-on: ${{ matrix.os }} timeout-minutes: 20 strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest] + python: ["3.9", "3.12"] steps: - uses: actions/checkout@v4 @@ -76,7 +77,7 @@ jobs: persist-credentials: false - uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: ${{ matrix.python }} - uses: actions/setup-node@v4 with: node-version: "22" diff --git a/README.md b/README.md index ccd95a27..a43d235b 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Or as a Claude Code plugin: ``` > [!NOTE] -> The plugin ships the skills only. Run `/super-board:super-board onboard` and its 🔍 Checks step adds the guard hooks, scripts and workflows. Needs Claude Code, `gh`, `jq`, bash 3.2+ and Python 3; the installer checks. +> The plugin ships the skills only. Run `/super-board:super-board onboard` and its 🔍 Checks step adds the guard hooks, scripts and workflows. Needs Claude Code, `gh`, `jq`, bash 3.2+ and Python 3.9+; the installer checks. ## Quick start diff --git a/skills/visual/scripts/visual.py b/skills/visual/scripts/visual.py index 41822ddc..dfa4c202 100644 --- a/skills/visual/scripts/visual.py +++ b/skills/visual/scripts/visual.py @@ -757,28 +757,41 @@ def find_chrome() -> str | None: def chrome(binary: str, *args: str, done=None, timeout: int = 45) -> str: """Run headless Chrome and return stdout. Headless Chrome on macOS can linger after it has written its output, so poll for `done(stdout_text)` and stop it ourselves.""" + import shutil import tempfile # Chrome subprocesses can still write to the profile after the parent exits. - with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as prof, tempfile.TemporaryFile("w+") as out: - proc = subprocess.Popen([binary, "--headless", "--disable-gpu", "--hide-scrollbars", - "--no-first-run", "--no-default-browser-check", "--mute-audio", - f"--user-data-dir={prof}", *args], - stdout=out, stderr=subprocess.DEVNULL, text=True) - t0, text = time.time(), "" - while time.time() - t0 < timeout: - if proc.poll() is not None: - break + prof = tempfile.mkdtemp() + try: + with tempfile.TemporaryFile("w+") as out: + proc = subprocess.Popen([binary, "--headless", "--disable-gpu", "--hide-scrollbars", + "--no-first-run", "--no-default-browser-check", "--mute-audio", + f"--user-data-dir={prof}", *args], + stdout=out, stderr=subprocess.DEVNULL, text=True) + t0, text = time.time(), "" + while time.time() - t0 < timeout: + if proc.poll() is not None: + break + out.seek(0) + text = out.read() + if done and done(text): + time.sleep(0.3) + break + time.sleep(0.25) + if proc.poll() is None: + proc.kill() + proc.wait() out.seek(0) - text = out.read() - if done and done(text): - time.sleep(0.3) + return out.read() + finally: + # Python 3.9 has no TemporaryDirectory(ignore_cleanup_errors=...). + # Retry late profile writes briefly; cleanup must never hide Chrome's result/error. + for attempt in range(3): + shutil.rmtree(prof, onerror=lambda *_: None) + if not os.path.exists(prof): break - time.sleep(0.25) - if proc.poll() is None: - proc.kill() - proc.wait() - out.seek(0) - return out.read() + if attempt < 2: + time.sleep(0.1) + def run_check(page: Path, shots: Path | None, is_map: bool) -> dict: diff --git a/tests/test_visual_families.py b/tests/test_visual_families.py index d17160cf..780fdbc3 100644 --- a/tests/test_visual_families.py +++ b/tests/test_visual_families.py @@ -1,9 +1,7 @@ #!/usr/bin/env python3 """Owner acceptance: nested skill families and usable column navigation in Chrome.""" -import html import copy import json -import re import shutil import subprocess import sys @@ -34,19 +32,34 @@ invalid["views"][0][field] = value assert any(message in error for error in visual.validate_map(invalid)), (field, value) probe = r""" - - + return {failures,facts}; +})() """ -# Virtual time does not advance animation frames reliably. CDP clicks and real elapsed time -# exercise the production resize/animation path instead of screenshot mode's instant path. +# Virtual time advances timers before scrolling/layout is painted. Use CDP real time +# for physical family hits and the normal-motion production resize/animation path. motion_probe = r""" import {spawn} from 'node:child_process'; import {mkdtemp,readFile,rm} from 'node:fs/promises'; import {tmpdir} from 'node:os'; -const [binary,url]=process.argv.slice(1), delay=ms=>new Promise(r=>setTimeout(r,ms)); +const [binary,url,familyProbe]=process.argv.slice(1), delay=ms=>new Promise(r=>setTimeout(r,ms)); const profile=await mkdtemp(tmpdir()+'/visual-navigation-'); // Test normal motion explicitly: macOS CI can default to reduced motion. const browser=spawn(binary,['--headless','--disable-gpu','--force-prefers-no-reduced-motion','--remote-debugging-port=0','--no-first-run','--no-default-browser-check','--user-data-dir='+profile,'about:blank'],{stdio:'ignore'}); @@ -125,10 +143,29 @@ const cdp=(method,params={})=>new Promise((resolve,reject)=>{const id=++next;pending.set(id,{resolve,reject});ws.send(JSON.stringify({id,method,params}));}); const evaluate=async expression=>{const r=await cdp('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true});if(r.exceptionDetails)throw Error(JSON.stringify(r.exceptionDetails));return r.result.value;}; await cdp('Page.enable'); await cdp('Emulation.setDeviceMetricsOverride',{width:1440,height:1000,deviceScaleFactor:1,mobile:false}); - await cdp('Page.navigate',{url}); await delay(500); + const diagnostic=()=>evaluate(`(()=>{const el=document.querySelector('.mit[data-v="super-board"]'),r=el?.getBoundingClientRect();return {hash:location.hash,ghosts:!!document.querySelector("#m-ghosts"),fonts:document.fonts.status,columns:document.querySelectorAll(".mcol").length,rect:r?.toJSON(),scroll:document.querySelector('.miller')?.scrollLeft,viewport:[innerWidth,innerHeight],target:r && document.elementFromPoint(r.left+r.width/2,r.top+r.height/2)?.outerHTML.slice(0,300)};})()`); + const waitFor=async (expression,name)=>{const end=performance.now()+10000;while(!await evaluate(expression)){if(performance.now()>end)throw Error('Timed out waiting for '+name+': '+JSON.stringify(await diagnostic()));await delay(20);}}; + await cdp('Page.navigate',{url:url+'#shot=1'}); + await waitFor('window.__instant && !!document.querySelector(".mcol") && !document.querySelector("#m-ghosts")','family initial column browser'); + const family=await evaluate(familyProbe); + failures.push(...family.failures); Object.assign(facts,family.facts); + await cdp('Page.navigate',{url}); + await waitFor('!window.__instant && !!document.querySelector(".mcol") && !document.querySelector("#m-ghosts")','initial column browser'); + await waitFor('document.fonts.status === "loaded"','fonts'); + await evaluate('document.fonts.ready.then(()=>true)'); check(await evaluate('!window.__instant && !matchMedia("(prefers-reduced-motion: reduce)").matches'),'normal motion is enabled without screenshot flags'); + const sidebarReady='Math.abs(document.querySelector("#m-tree").getBoundingClientRect().width-Math.min(parseFloat(document.body.style.getPropertyValue("--browser-width")),innerWidth*.38))<1'; + await waitFor(sidebarReady,'initial sidebar width'); facts.beforeWidth=await evaluate('document.querySelector("#m-tree").getBoundingClientRect().width'); - const click=async id=>{const p=await evaluate(`(()=>{const el=document.querySelector('.mit[data-v="${id}"]');const r=el.getBoundingClientRect();return{x:r.left+r.width/2,y:r.top+r.height/2};})()`);await cdp('Input.dispatchMouseEvent',{type:'mousePressed',...p,button:'left',clickCount:1});await cdp('Input.dispatchMouseEvent',{type:'mouseReleased',...p,button:'left',clickCount:1});await delay(1600);}; + const click=async id=>{ + await evaluate(`document.querySelector('.mit[data-v="${id}"]').scrollIntoView({behavior:'instant',block:'nearest',inline:'nearest'})`); + await waitFor(`(()=>{const el=document.querySelector('.mit[data-v="${id}"]');const r=el.getBoundingClientRect();return document.elementFromPoint(r.left+r.width/2,r.top+r.height/2)?.closest('button')===el;})()`,'hittable '+id); + const p=await evaluate(`(()=>{const r=document.querySelector('.mit[data-v="${id}"]').getBoundingClientRect();return{x:r.left+r.width/2,y:r.top+r.height/2};})()`); + await cdp('Input.dispatchMouseEvent',{type:'mousePressed',...p,button:'left',clickCount:1}); + await cdp('Input.dispatchMouseEvent',{type:'mouseReleased',...p,button:'left',clickCount:1}); + await waitFor(`new URLSearchParams(location.hash.slice(1)).get('view')==='${id}' && !!document.querySelector('.mit[data-v="${id}"][aria-current="page"]')`,'completed '+id+' navigation'); + await waitFor(sidebarReady,'completed '+id+' sidebar width'); + }; await click('super-board'); facts.boardView=await evaluate('new URLSearchParams(location.hash.slice(1)).get("view")'); facts.afterWidth=await evaluate('document.querySelector("#m-tree").getBoundingClientRect().width'); @@ -146,21 +183,14 @@ with tempfile.TemporaryDirectory() as directory: page = Path(directory) / "fixture.html" visual.write_page(page, model) - page.write_text(page.read_text().replace("", probe + "")) - dom = visual.chrome(binary, "--virtual-time-budget=8000", "--window-size=1440,1000", "--dump-dom", - page.as_uri() + "#shot=1", done=lambda s: 'id="family-report"' in s) - match = re.search(r'
]*>(.*?)
', dom, re.S) - assert match, "family probe did not finish" - report = json.loads(html.unescape(match[1])) - assert not report["failures"], report - visual.write_page(page, model) node = shutil.which("node") if node and subprocess.run([node, "-e", "process.exit(typeof WebSocket === 'function' ? 0 : 1)"], capture_output=True).returncode == 0: - result = subprocess.run([node, "--input-type=module", "-e", motion_probe, binary, page.as_uri()], - capture_output=True, text=True, timeout=25) + result = subprocess.run([node, "--input-type=module", "-e", motion_probe, binary, page.as_uri(), probe], + capture_output=True, text=True, timeout=45) assert result.returncode == 0, result.stderr report = json.loads(result.stdout) assert not report["failures"], report else: - print("SKIP: normal-motion navigation probe (needs Node with built-in WebSocket)") + print("SKIP: test_visual_families.py (needs Node with built-in WebSocket)") + sys.exit(0) print("PASS: test_visual_families.py (nested boxes, physical clicks, readable rows, animated navigation)")