From 47d8faa961a3376484b371c5c8cfe13f46630dbe Mon Sep 17 00:00:00 2001 From: Eric Tech <146783360+EricTechPro@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:29:11 -0700 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=90=9B=20[fix]=20visual:=20Chrome=20c?= =?UTF-8?q?leanup=20works=20on=20Python=203.9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Retry best-effort profile cleanup with Python 3.9-compatible APIs. - Keep the cleanup-race regression and cover Python 3.9 in CI. --- .github/workflows/cross-platform.yml | 7 ++-- README.md | 2 +- skills/visual/scripts/visual.py | 49 ++++++++++++++++++---------- 3 files changed, 36 insertions(+), 22 deletions(-) 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: From 7bd0ba961abbf902dd2b30d0cd2d758747ec6545 Mon Sep 17 00:00:00 2001 From: Eric Tech <146783360+EricTechPro@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:42:05 -0700 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=A7=AA=20[test]=20visual:=20wait=20fo?= =?UTF-8?q?r=20deterministic=20column=20navigation=20readiness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The virtual-time family probe hit an offscreen board row while inherited smooth scrolling was pending. Await boot, fonts, and completed navigation; reveal physical click targets instantly and retain all assertions. Co-Authored-By: Codex --- tests/test_visual_families.py | 40 ++++++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/tests/test_visual_families.py b/tests/test_visual_families.py index d17160cf..7fe57dcb 100644 --- a/tests/test_visual_families.py +++ b/tests/test_visual_families.py @@ -36,17 +36,28 @@ probe = r""" """ @@ -125,10 +138,21 @@ 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 waitFor=async (expression,name)=>{const end=Date.now()+10000;while(!await evaluate(expression)){if(Date.now()>end)throw Error('Timed out waiting for '+name+': '+JSON.stringify(await evaluate('({hash:location.hash,ghosts:!!document.querySelector("#m-ghosts"),fonts:document.fonts.status,columns:document.querySelectorAll(".mcol").length})')));await delay(20);}}; + await cdp('Page.navigate',{url}); + await waitFor('!!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'); 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 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'); From d60abd083e8a194256c408105355e37904aef69f Mon Sep 17 00:00:00 2001 From: Eric Tech <146783360+EricTechPro@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:53:11 -0700 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=A7=AA=20[test]=20visual:=20use=20rea?= =?UTF-8?q?l=20CDP=20time=20for=20physical=20column=20readiness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DOM and fonts can be ready before the sidebar physical layout is painted under virtual time. Reuse the CDP harness for every physical family assertion, wait for actual hit and completed sidebar widths, and include monotonic deadline diagnostics. Co-Authored-By: Codex --- tests/test_visual_families.py | 58 +++++++++++++++++++---------------- 1 file changed, 32 insertions(+), 26 deletions(-) diff --git a/tests/test_visual_families.py b/tests/test_visual_families.py index 7fe57dcb..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,16 +32,20 @@ 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'}); @@ -138,12 +143,19 @@ 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}); - const waitFor=async (expression,name)=>{const end=Date.now()+10000;while(!await evaluate(expression)){if(Date.now()>end)throw Error('Timed out waiting for '+name+': '+JSON.stringify(await evaluate('({hash:location.hash,ghosts:!!document.querySelector("#m-ghosts"),fonts:document.fonts.status,columns:document.querySelectorAll(".mcol").length})')));await delay(20);}}; + 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('!!document.querySelector(".mcol") && !document.querySelector("#m-ghosts")','initial column browser'); + 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=>{ await evaluate(`document.querySelector('.mit[data-v="${id}"]').scrollIntoView({behavior:'instant',block:'nearest',inline:'nearest'})`); @@ -152,6 +164,7 @@ 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")'); @@ -170,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)")