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("