Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .github/workflows/cross-platform.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
49 changes: 31 additions & 18 deletions skills/visual/scripts/visual.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
86 changes: 58 additions & 28 deletions tests/test_visual_families.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"""
<style>* { transition-duration: 0s !important; }</style>
<script>
setTimeout(async () => {
(async () => {
const failures = [], facts = {};
const check = (ok, message) => { if (!ok) failures.push(message); };
const $ = s => document.querySelector(s), all = s => [...document.querySelectorAll(s)];
const settle = () => new Promise(r => setTimeout(r, 100));
const diagnostic = () => {
const el = $('.mit[data-v="super-board"]'), r = el?.getBoundingClientRect();
return {hash:location.hash,ghosts:!!$('#m-ghosts'),fonts:document.fonts.status,columns:all('.mcol').length,
rect:r?.toJSON(),scroll:$('.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 (ready, name) => {
const deadline = performance.now() + 6000;
while (!ready()) {
if (performance.now() >= deadline) throw Error('Timed out waiting for ' + name + ': ' + JSON.stringify(diagnostic()));
await new Promise(r => setTimeout(r, 20));
}
};
const viewReady = id => new URLSearchParams(location.hash.slice(1)).get('view') === id &&
!!$('.mit[data-v="'+id+'"][aria-current="page"]');
const click = el => { if (el) el.dispatchEvent(new MouseEvent('click', {bubbles:true})); };
const box = el => { const b = el.getBBox(); return {x:b.x,y:b.y,w:b.width,h:b.height}; };
const nodeBox = id => box($('.stage .node[data-id="'+id+'"] .n-body'));
const frame = id => $('.stage .family[data-family="'+id+'"] .family-box');
const contains = (a,b) => a.x < b.x && a.y < b.y && a.x+a.w > b.x+b.w && a.y+a.h > b.y+b.h;
try {
await waitFor(() => !$('#m-ghosts') && !!$('.mcol'), 'initial column browser');
await waitFor(() => document.fonts.status === 'loaded', 'fonts');
await document.fonts.ready;
const collect = frame('collect-family'), board = frame('board-family');
check(!!collect && !!board, 'root draws nested intake and board family boxes');
if (collect && board) {
Expand All @@ -69,18 +82,24 @@
const miller = $('.miller');
check(getComputedStyle(miller).overflowX === 'auto', 'column browser scrolls horizontally');
const button = column.querySelector('[data-v="super-board"]');
button.scrollIntoView({block:'nearest',inline:'nearest'}); await settle();
// renderTree initially reveals the last column. Reveal this row synchronously,
// then wait for the physical hit target, including the sidebar's initial layout.
button.scrollIntoView({behavior:'instant',block:'nearest',inline:'nearest'});
await waitFor(() => {
const r = button.getBoundingClientRect();
return document.elementFromPoint(r.left+r.width/2,r.top+r.height/2)?.closest('button') === button;
}, 'physical board row');
const r = button.getBoundingClientRect();
const target = document.elementFromPoint(r.left+r.width/2,r.top+r.height/2);
check(target && target.closest('button') === button, 'board row is physically clickable');
click(target); await settle();
click(target); await waitFor(() => viewReady('super-board'), 'board navigation');
check(new URLSearchParams(location.hash.slice(1)).get('view') === 'super-board', 'board row opens its view');
const childColumn = all('.mcol').find(c => c.querySelector('[data-v="sb-onboard"]'));
facts.boardChildren = childColumn ? [...childColumn.querySelectorAll('.mit')].map(b => b.dataset.v) : [];
check(facts.boardChildren.slice(0,3).join(',') === 'super-build,super-qa,super-review', 'board column lists its three lanes first');
for (const id of ['sb-onboard','sb-lint','super-board-run','sb-status','sb-stop']) check(facts.boardChildren.includes(id), 'board column exposes '+id);
}
click($('.mit[data-v="ui-refine-loop"]')); await settle();
click($('.mit[data-v="ui-refine-loop"]')); await waitFor(() => viewReady('ui-refine-loop'), 'loop navigation');
check(new URLSearchParams(location.hash.slice(1)).get('view') === 'ui-refine-loop', 'standalone loop is reachable');
const loop = frame('refine-family');
check(!!loop, 'loop has a family container');
Expand All @@ -96,18 +115,17 @@
check(facts.mainSteps.length === 6, 'loop preserves its six main steps');
check($('#m-pillbtn .n').textContent === '6 steps', 'view counts primary steps separately from nested tools');
} catch (e) { failures.push(e.stack || String(e)); }
const pre=document.createElement('pre'); pre.id='family-report'; pre.textContent=JSON.stringify({failures,facts}); document.body.appendChild(pre);
}, 150);
</script>
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'});
Expand All @@ -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');
Expand All @@ -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("</body>", probe + "</body>"))
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'<pre id="family-report"[^>]*>(.*?)</pre>', 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)")
Loading