diff --git a/tests/test_board_view.py b/tests/test_board_view.py
index a29d29e..3185975 100644
--- a/tests/test_board_view.py
+++ b/tests/test_board_view.py
@@ -500,27 +500,14 @@ def test_drawer_shell_and_plain_text_security_contract():
assert forbidden not in PAGE
-def test_drawer_interactions_and_async_lifecycle():
- """Execute the shipped JS with a small DOM/fetch/timer harness, no host or network.
-
- Node is optional locally; the static contract and route tests always run in the
- Python-only gate. When available this also checks races and event behavior.
- """
- import shutil
- import subprocess
-
- import pytest
- from ghplugin.view import PAGE
-
- node = shutil.which("node")
- if not node:
- pytest.skip("Node is required for the JavaScript interaction harness")
- script = PAGE.split('", 1)[0]
- script = script[script.index(" const $ =") :].replace(" kit.initPluginView(boot);", "")
- harness = r"""
+# A tiny DOM / fetch / timer harness so the shipped drawer JS runs with no host or network.
+# `lastIntervalMs` records the most recent poll cadence (15s default, 30s for Files); `latest()`
+# points at the newest fetch; `bodyClick` fires a delegated #drawer-body click whose target
+# matches one attribute selector (the log / file toggles).
+_DRAWER_HARNESS = r"""
const assert = require('node:assert/strict');
const elements = new Map(), events = {}, requests = [], timers = new Map();
-let timerId = 0;
+let timerId = 0, lastIntervalMs = 0;
function element(id){
if(!elements.has(id)) elements.set(id, {
id, dataset: {}, handlers: {}, attrs: {}, writes: 0, scrollTop: 0, isConnected: true,
@@ -536,12 +523,46 @@ def test_drawer_interactions_and_async_lifecycle():
}
const document = {hidden: false, getElementById: element, addEventListener(k,v){events[k]=v;}};
const kit = {apiFetch(path){return new Promise((resolve,reject)=>requests.push({path,resolve,reject}));}};
-const setInterval = (fn,ms) => {assert.equal(ms,15000); timers.set(++timerId,fn); return timerId;};
+const setInterval = (fn,ms) => {lastIntervalMs = ms; timers.set(++timerId,fn); return timerId;};
const clearInterval = id => timers.delete(id);
const flush = async()=>{await Promise.resolve(); await Promise.resolve(); await Promise.resolve();};
const reply = async(index,data,ok=true)=>{requests[index].resolve({ok,json:async()=>data}); await flush();};
+const latest = () => requests.length - 1;
+const bodyClick = (attrs) => element('drawer-body').handlers.click({target:{closest:(sel)=>{
+ for(const k in attrs){ if(sel.includes(k)) return {getAttribute:(n)=>attrs[n]}; }
+ return null;
+}}});
"""
- checks = r"""
+
+
+def _run_drawer_js(checks):
+ """Execute the shipped drawer JS (``_DRAWER_HARNESS`` + the page module + ``checks``).
+
+ Node is optional locally; the static contract and route tests always run in the
+ Python-only gate. When available this also checks races, event behavior, and the
+ Checks/Files tab rendering."""
+ import shutil
+ import subprocess
+
+ import pytest
+ from ghplugin.view import PAGE
+
+ node = shutil.which("node")
+ if not node:
+ pytest.skip("Node is required for the JavaScript interaction harness")
+ script = PAGE.split('", 1)[0]
+ script = script[script.index(" const $ =") :].replace(" kit.initPluginView(boot);", "")
+ result = subprocess.run(
+ [node, "-"], input=_DRAWER_HARNESS + script + checks, text=True, capture_output=True, timeout=15
+ )
+ assert result.returncode == 0, result.stderr
+
+
+def test_drawer_interactions_and_async_lifecycle():
+ """Open/close, escaping, snapshot suppression, races, visibility, and arrow-key tab nav
+ across the three real tabs — Files polls slower (30s), Overview/Checks at 15s."""
+ _run_drawer_js(
+ r"""
(async()=>{
const hostile = '&';
const data = {number:7,title:hostile,state:'OPEN',author:{login:hostile},
@@ -559,7 +580,7 @@ def test_drawer_interactions_and_async_lifecycle():
element('list').handlers.click({button:0,target:{closest:()=>null},preventDefault(){assert.fail('issue intercepted');}});
assert.equal(click(),true);
assert.equal(requests[0].path,'/api/plugins/github/detail/pr?repo=o%2Fn&number=7');
- assert.equal(timers.size,1);
+ assert.equal(timers.size,1); assert.equal(lastIntervalMs,15000);
assert.equal(element('drawer').hidden,false);
await reply(0,data);
assert.ok(element('drawer-title').innerHTML.includes('<img'));
@@ -586,14 +607,15 @@ def test_drawer_interactions_and_async_lifecycle():
document.hidden=false; events.visibilitychange(); assert.equal(timers.size,1);
await reply(7,{error:hostile}); assert.ok(element('drawer-body').innerHTML.includes('<img'));
pollDrawer(); await reply(8,data); assert.ok(element('drawer-body').innerHTML.includes('Author'));
- // Add a future tab to verify automatic activation beyond today's single Overview.
- DRAWER_TABS.checks = {label:'Checks',path:'/checks',render:()=>'
Checks
'}; - renderDrawerTabs(); - for(const [key,want] of [['ArrowRight','checks'],['Home','overview'],['End','checks'],['ArrowRight','overview'],['ArrowLeft','checks']]){ + // Overview + the two real tabs are keyboard-reachable; Files restarts the poller at 30s. + assert.deepEqual(Object.keys(DRAWER_TABS),['overview','checks','files']); + for(const [key,want,ms] of [['ArrowRight','checks',15000],['Home','overview',15000], + ['End','files',30000],['ArrowRight','overview',15000],['ArrowLeft','files',30000]]){ let prevented=false; element('drawer-tabs').handlers.keydown({key,preventDefault(){prevented=true;}}); assert.equal(drawer.tab,want); assert.equal(prevented,true); assert.equal(document.activeElement.id,'dtab-'+want); + assert.equal(lastIntervalMs,ms); } const pending=requests.length-1; events.keydown({key:'Escape',preventDefault(){}}); @@ -609,5 +631,158 @@ def test_drawer_interactions_and_async_lifecycle(): assert.ok(!issueRow(data).includes('data-pr=')); })().catch(e=>{console.error(e);process.exitCode=1;}); """ - result = subprocess.run([node, "-"], input=harness + script + checks, text=True, capture_output=True, timeout=15) - assert result.returncode == 0, result.stderr + ) + + +def test_drawer_checks_tab_renders_summary_logs_and_badge(): + """Checks tab: summary, failing-first order, escaped names, a fail-count badge, and a + lazily-loaded failure log in an escaped whose expanded state survives re-renders."""
+ _run_drawer_js(
+ r"""
+(async()=>{
+ const row = element('pr-row'); row.dataset={repo:'o/n',pr:'7'};
+ openDrawer('o/n','7',row);
+ await reply(latest(),{number:7,title:'PR',state:'OPEN',author:{login:'kj'},
+ headRefName:'f',baseRefName:'main',body:'b',createdAt:'2026-01-01',updatedAt:'2026-01-02'});
+ selectDrawerTab('checks',false);
+ assert.equal(DRAWER_TABS.checks.path,'/api/plugins/github/detail/pr/checks');
+ assert.ok(requests[latest()].path.startsWith('/api/plugins/github/detail/pr/checks?repo=o%2Fn&number=7'));
+ const evil = 'evil';
+ const checks = {summary:{pass:1,fail:2,pending:0,skipped:1}, checks:[
+ {name:'lint',workflow:'CI',status:'COMPLETED',conclusion:'SUCCESS',url:'https://x/1',
+ started_at:'2026-01-01T00:00:00Z',completed_at:'2026-01-01T00:00:30Z',run_id:null},
+ {name:evil,workflow:'CI',status:'COMPLETED',conclusion:'FAILURE',url:'https://x/2',
+ started_at:'2026-01-01T00:00:00Z',completed_at:'2026-01-01T00:01:05Z',run_id:42},
+ {name:'skip-step',workflow:null,status:'COMPLETED',conclusion:'SKIPPED',url:null,
+ started_at:null,completed_at:null,run_id:null},
+ {name:'deploy',workflow:'CD',status:'COMPLETED',conclusion:'TIMED_OUT',url:'https://x/3',
+ started_at:null,completed_at:null,run_id:99},
+ ]};
+ await reply(latest(),checks);
+ let html = element('drawer-body').innerHTML;
+ assert.ok(html.includes('1 passing') && html.includes('2 failing'));
+ assert.ok(html.includes('0 pending') && html.includes('1 skipped'));
+ assert.ok(html.indexOf('evil') < html.indexOf('lint')); // failing first
+ assert.ok(html.indexOf('deploy') < html.indexOf('lint'));
+ assert.ok(html.includes('<b>evil') && !html.includes('evil')); // check name escaped
+ assert.ok(html.includes('dot fail') && html.includes('dot pass') && html.includes('dot skipped'));
+ assert.ok(html.includes('1m 5s')); // duration
+ assert.ok(element('drawer-tabs').innerHTML.includes('chk-badge">2<')); // fail-count badge
+ // Lazily fetch the failure log for run 42.
+ const before = requests.length;
+ bodyClick({'data-run-id':'42'});
+ assert.equal(requests.length,before+1);
+ assert.ok(requests[latest()].path.includes('run_id=42'));
+ assert.ok(element('drawer-body').innerHTML.includes('Loading'));
+ await reply(latest(),{log_tail:'ok\n'});
+ html = element('drawer-body').innerHTML;
+ assert.ok(html.includes(''));
+ assert.ok(html.includes('<script>bad') && !html.includes('\n-gone',truncated:false,binary:false},
+ {path:'img.png',old_path:null,status:'added',additions:0,deletions:0,patch:null,truncated:false,binary:true},
+ {path:'new.txt',old_path:'old.txt',status:'renamed',additions:1,deletions:1,
+ patch:'@@ -1 +1 @@\n-a\n+b',truncated:false,binary:false},
+ {path:'big.txt',old_path:null,status:'modified',additions:9,deletions:0,patch:null,truncated:true,binary:false},
+ ]};
+ await reply(latest(),diff);
+ let html = element('drawer-body').innerHTML;
+ assert.ok(html.includes('4 files changed'));
+ assert.ok(html.includes('>M<') && html.includes('>A<') && html.includes('>R<')); // status letters
+ assert.ok(html.includes('old.txt → new.txt')); // rename
+ // <=20 files: expanded by default — tinted add/del lines, muted hunk, escaped patch.
+ assert.ok(html.includes('dl add') && html.includes('dl del') && html.includes('dl hunk'));
+ assert.ok(html.includes('<script>x') && !html.includes('