Setting the file. One moment.
Report · N8n:testmax Assistant UI · n8n-io/n8n · Skills Docs
ContentsBack to the top of the page — line 214
This file
Number 39.5
Position 5 of 13
Type Python
Size 34 KB
Lines 690 scripts/ report.py
Python · 690 lines · 34 KB
The page uses the template and the LLM steps view of testmax-n8n-ai.
16 """
17
18 import fcntl
19 import json
20 import shutil
21 import sys
22 import time
23 from datetime import datetime
24 from pathlib import Path
25
26 import common # noqa: F401 (puts the shared scripts on the import path)
27 import build_report as br
28 import live_server as ls
29
30 STATUS = { 'waiting' : ( 'st-waiting' , 'Waiting' ), 'starting' : ( 'st-waiting' , 'Starting' ), 'running' : ( 'st-running' , 'Running' ),
31 'passed' : ( 'st-done' , 'Passed' ), 'failed' : ( 'st-failed' , 'Failed' ), 'error' : ( 'st-failed' , 'Error' ),
32 'skipped' : ( 'st-skipped' , 'Skipped' )}
33 FINISHED = ( 'passed' , 'failed' , 'error' , 'skipped' )
34 KIND_LABELS = { 'feature' : 'Feature' , 'regression' : 'Regression' , 'visual' : 'Visual' }
35 VISUAL = { 'changed' : ( 'vis-changed' , 'Changed' ), 'unchanged' : ( 'vis-same' , 'Unchanged' ),
36 'branch-only' : ( 'vis-only' , 'Only on branch' ), 'master-only' : ( 'vis-only' , 'Only on master' )}
37 THEAD = ( '<tr><th>Scenario</th><th>Kind</th><th>Result</th><th>Steps</th><th>Time</th><th>LLM calls</th>'
38 '<th>Tool calls</th><th>Input tokens</th><th>Output tokens</th><th>Cost</th><th>Browser errors</th></tr>' )
39
40 CSS = """<style>
41 .run-video video {display:block;width:100%;max-height:70vh;aspect-ratio:16/10;object-fit:contain;background:var(--black);border-radius:var(--r8)}
42 .badge.vis-changed {background:var(--pink-70);color:var(--bg)}
43 .badge.vis-same {background:var(--bg-3);color:var(--black)}
44 .badge.vis-only{box-shadow:inset 0 0 0 1px var(--line-strong);color:var(--dim)}
45 .facts{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:var(--s4) var(--s16);margin:0}
46 .facts dt{font:10px/18px var(--mono);text-transform:uppercase;letter-spacing:.02em;color:var(--dim)}
47 .facts dd{margin:0;word-break:break-word}
48 .run-error{margin:var(--s12) 0 0;background:var(--pink-10);color:var(--pink-70);border-radius:var(--r8);padding:var(--s12);white-space:pre-wrap;word-break:break-word;font:12px/16px var(--mono);max-height:240px;overflow:auto}
49 .tsteps{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--s16)}
50 @media (max-width:1000px){.tsteps{grid-template-columns:repeat(2,minmax(0,1fr))}}
51 @media (max-width:640px){.tsteps{grid-template-columns:minmax(0,1fr)}}
52 .tstep{display:flex;flex-direction:column;min-width:0;background:var(--white);border:1px solid var(--line);border-radius:var(--r8);overflow:hidden}
53 .tstep.failed{background:var(--pink-10);border-color:var(--pink-70);box-shadow:0 0 0 1px var(--pink-70)}
54 .tstep-shot{display:block;width:100%;aspect-ratio:16/10;padding:0;border:0;border-bottom:1px solid var(--line);background:var(--bg-3);cursor:zoom-in;overflow:hidden}
55 .tstep-shot img{display:block;width:100%;height:100%;object-fit:cover;object-position:top center;transition:transform .15s}
56 .tstep-shot:hover img{transform:scale(1.03)}
57 .tstep-shot:focus-visible{outline:2px solid var(--pink);outline-offset:-2px}
58 @media (prefers-reduced-motion:reduce){.tstep-shot img{transition:none}}
59 .tstep-noshot{display:grid;place-items:center;aspect-ratio:16/10;background:var(--bg-2);border-bottom:1px solid var(--line);color:var(--gray-50);font:10px/14px var(--mono);text-transform:uppercase;letter-spacing:.02em}
60 .tstep-info{display:flex;gap:var(--s12);align-items:flex-start;padding:var(--s12);min-width:0}
61 .tstep-info > div{flex:1;min-width:0;display:flex;flex-direction:column;gap:var(--s4)}
62 .tstep-info.running .num{background:var(--pink-10);color:var(--pink-70)}
63 .tstep-info.passed .num{background:var(--pink)}
64 .tstep-info.failed .num{background:var(--pink-70);color:var(--bg)}
65 .tstep-title{font-weight:500;padding-top:3px;word-break:break-word}
66 .tstep-title .mono-label{font-weight:400;margin-right:var(--s8)}
67 .tstep-info pre{margin:var(--s4) 0 0;white-space:pre-wrap;word-break:break-word;font:12px/16px var(--mono);color:var(--pink-70);max-height:200px;overflow:auto}
68 .shot-viewer .theater-bar{flex-wrap:nowrap;align-items:flex-start}
69 .shot-viewer-info{flex:1;min-width:0;padding-top:3px}
70 .shot-viewer-actions{flex:none;display:flex;align-items:center;gap:var(--s16)}
71 .shot-viewer-info .tstep-info{padding:0}
72 .shot-viewer-info .tstep-title{font-size:18px;line-height:22px}
73 .shot-viewer-img{flex:1;min-height:0;display:flex;align-items:center;justify-content:center;cursor:zoom-out}
74 .shot-viewer-img img{display:block;max-width:100%;max-height:100%;border-radius:var(--r8);box-shadow:0 0 0 1px var(--line);cursor:default}
75 .err-list{margin:0;padding-left:var(--s16);font:12px/16px var(--mono)}
76 .err-list li{margin-bottom:var(--s4);word-break:break-word}
77 .vis{background:var(--white);border-radius:var(--r8);padding:var(--s12);box-shadow:inset 0 0 0 1px var(--line);margin-top:var(--s12)}
78 .vis.changed{box-shadow:inset 4px 0 0 var(--pink-70),inset 0 0 0 1px var(--line)}
79 .vis-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--s12)}
80 .vis-grid figure{margin:0}
81 .vis-grid figcaption{margin-bottom:var(--s4)}
82 .vis-grid img{display:block;width:100%;height:auto;border-radius:var(--r4);box-shadow:0 0 0 1px var(--line)}
83 .vis details summary{cursor:pointer;color:var(--dim);margin-top:var(--s8);font:10px/14px var(--mono);text-transform:uppercase;letter-spacing:.02em}
84 .vis pre{background:var(--bg);border-radius:var(--r4);padding:var(--s12);margin:var(--s8) 0 0;overflow:auto;max-height:320px;font-size:12px;line-height:16px;white-space:pre}
85 </style>"""
86
87
88 def read_json (path, default):
89 try :
90 return json.loads(path.read_text())
91 except ( OSError , ValueError ):
92 return default
93
94
95 def seconds (value):
96 return f ' { value :.0f} s'
97
98
99 def load_trace (sub, result):
100 """LLM steps from the thread events, and per-call tokens from usage.json when LangSmith had them."""
101 data = ls.load_instance_ai_live(sub)
102 events_file = sub / 'events.jsonl'
103 rows = br.read_jsonl(events_file) if events_file.exists() else []
104 starts = [r[ 'ts' ] / 1000 for r in rows if r.get( 'type' ) == 'run-start' and r.get( 'ts' )]
105 usage = read_json(sub / 'usage.json' , None )
106 if usage and starts:
107 start = starts[ 0 ]
108 data[ 'calls' ] = sorted ((
109 { 't' : br.ts(u[ 'start' ]) - start, 'start' : br.ts(u[ 'start' ]) - start,
110 'end' : br.ts(u[ 'end' ]) - start if u.get( 'end' ) else None ,
111 'name' : (u.get( 'name' ) or '' ).removeprefix( 'llm: ' ) or None ,
112 'prompt' : u[ 'in' ], 'cache_read' : u[ 'cache_read' ], 'cache_write' : u[ 'cache_write' ],
113 'out' : u[ 'out' ], 'cost' : u[ 'cost' ]} for u in usage), key =lambda c: c[ 't' ])
114 data[ 'model' ] = ', ' .join( sorted ({u[ 'model' ] for u in usage if u.get( 'model' )})) or None
115 data[ 'cost' ] = f '$ { sum (c[ "cost" ] for c in data[ "calls" ]) :.2f} '
116 data[ 'costSource' ] = 'langsmith'
117 if result.get( 'startedAt' ) and result.get( 'finishedAt' ):
118 data[ 'duration' ] = seconds(br.ts(result[ 'finishedAt' ]) - br.ts(result[ 'startedAt' ]))
119 return data
120
121
122 def load (run_dir):
123 """The plan, the status, and one dict for each run in plan order."""
124 plan = read_json(run_dir / 'plan' / 'scenarios.json' , { 'scenarios' : []})
125 status = ls.read_status(run_dir)
126 entries = status.get( 'runs' ) or {}
127 runs = []
128 for s in plan[ 'scenarios' ]:
129 for name in (s[ 'id' ], f ' { s[ "id" ] } --master' ):
130 if name not in entries:
131 continue
132 entry = entries[name]
133 sub = run_dir / 'runs' / name
134 result = read_json(sub / 'result.json' , {})
135 data = load_trace(sub, result)
136 if entry.get( 'status' ) in ( 'waiting' , 'running' ):
137 data[ 'cost' ] = '-'
138 if entry.get( 'startedAt' ):
139 data[ 'duration' ] = seconds((entry.get( 'finishedAt' ) or time.time()) - entry[ 'startedAt' ])
140 runs.append({
141 'name' : name, 'scenario' : s, 'entry' : entry, 'master' : name.endswith( '--master' ),
142 'steps' : read_json(sub / 'steps.json' , []), 'result' : result,
143 'console' : read_json(sub / 'console.json' , {}), 'meta' : read_json(sub / 'run.json' , {}),
144 'data' : data,
145 })
146 return plan, status, runs
147
148
149 def status_badge (state):
150 cls , label = STATUS .get(state, ( 'st-waiting' , state))
151 return f '<span class="badge { cls } "> { br.esc(label) } </span>'
152
153
154 def run_title (run):
155 return run[ 'scenario' ][ 'title' ] + ( ' (master)' if run[ 'master' ] else '' )
156
157
158 def check_count (steps):
159 checked = [s for s in steps if s[ 'kind' ] in ( 'step' , 'check' , 'capture' )]
160 return f ' { sum (s[ "status" ] == "passed" for s in checked) } / { len (checked) } ' if checked else '-'
161
162
163 def row_cells (run, result_html):
164 data, result = run[ 'data' ], run[ 'result' ]
165 calls, tools = data[ 'calls' ], [e for e in data[ 'events' ] if e[ 'kind' ] == 'tool' ]
166 errors = sum (e[ 'error' ] for e in tools)
167 browser = (result.get( 'consoleErrors' ) or 0 ) + (result.get( 'requestErrors' ) or 0 )
168 cells = [
169 f '<span class="run-name"> { br.esc(run_title(run)) } </span>' ,
170 KIND_LABELS .get(run[ 'scenario' ][ 'kind' ], run[ 'scenario' ][ 'kind' ]),
171 result_html, check_count(run[ 'steps' ]), data[ 'duration' ], str ( len (calls)) if calls else '-' ,
172 f ' { len (tools) } ' + ( f ' <span class="err">( { errors } err)</span>' if errors else '' ),
173 br.kilo( sum (c[ 'prompt' ] for c in calls)) if calls else '-' ,
174 br.kilo( sum (c[ 'out' ] for c in calls)) if calls else '-' ,
175 f '<strong> { br.esc(data[ "cost" ]) } </strong>' ,
176 f '<span class="err"> { browser } </span>' if browser else '0' ,
177 ]
178 return '' .join( f '<td> { c } </td>' for c in cells)
179
180
181 def result_html (run, verdict):
182 """The judge's verdict. The script status shows only while it runs or when it fails, because the Steps
183 column already counts the steps that passed."""
184 if verdict and verdict.get( 'verdict' ):
185 return br.badge(verdict[ 'verdict' ])
186 state = run[ 'entry' ].get( 'status' , 'waiting' )
187 if state != 'passed' :
188 return status_badge(state)
189 if run[ 'master' ]:
190 return '<span class="badge st-done">Baseline</span>'
191 return br.badge( None )
192
193
194 def render_video (screen):
195 """The video of the run, above the expected outcome. Only the built report has it."""
196 if not screen:
197 return ''
198 poster = f ' poster=" { br.esc(screen[ "poster" ]) } "' if screen.get( 'poster' ) else ''
199 return br.fold( 'Video' , f '<span class="mono-label">n8n UI · { br.esc(screen[ "duration" ]) } </span>' ,
200 f '<div class="run-video"><video controls muted playsinline preload="metadata" '
201 f 'src=" { br.esc(screen[ "src" ]) } " { poster } ></video></div>' , cls = 'brief-card prompt' )
202
203
204 def render_expected (run, verdict):
205 head = br.verdict_head(verdict) if verdict else ''
206 body = f '<div class="msg"> { br.esc(run[ "scenario" ][ "expected" ]) } </div>'
207 if verdict:
208 body += f '<div class="verdict-body"> { br.render_checks(verdict) } </div>'
209 elif run[ 'entry' ].get( 'status' ) in FINISHED :
210 body += '<p class="dim">Not judged yet.</p>'
211 return br.fold( 'Expected outcome' , head, body)
212
213
214 def render_run_box (run):
215 entry, result, meta = run[ 'entry' ], run[ 'result' ], run[ 'meta' ]
216 facts = [( 'Instance' , f ' { "master" if run[ "master" ] else "branch" } · { entry.get( "baseUrl" ) or "-" } ' )]
217 if entry.get( 'startedAt' ):
218 facts.append(( 'Started' , datetime.fromtimestamp(entry[ 'startedAt' ]).strftime( '%H:%M:%S' )))
219 if result.get( 'threads' ):
220 facts.append(( 'Threads' , ', ' .join(result[ 'threads' ])))
221 if meta.get( 'cached' ):
222 facts.append(( 'Reused' , 'This master run is from an earlier run of the same master commit and script.' ))
223 if meta.get( 'decisions' ):
224 facts.append(( 'Confirmations' , f ' { len (meta[ "decisions" ]) } answered by the auto-approve policy' ))
225 if result:
226 facts.append(( 'Browser errors' , f ' { result.get( "consoleErrors" , 0 ) } console, { result.get( "requestErrors" , 0 ) } requests' ))
227 dl = '' .join( f '<dt> { br.esc(k) } </dt><dd> { br.esc(v) } </dd>' for k, v in facts)
228 error = result.get( 'error' ) or (entry.get( 'reason' ) if entry.get( 'status' ) in ( 'error' , 'skipped' ) else None )
229 error_html = f '<pre class="run-error"> { br.esc(error) } </pre>' if error else ''
230 return br.fold( 'Run' , status_badge(entry.get( 'status' , 'waiting' )), f '<dl class="facts"> { dl } </dl> { error_html } ' )
231
232
233 def render_scenario_steps (steps, files):
234 if not steps:
235 return br.fold( 'Scenario steps' , '' , '<p class="dim">No steps yet.</p>' )
236 items = []
237 for s in steps:
238 status, title = br.esc(s[ 'status' ]), br.esc(s[ 'title' ])
239 duration = f ' · took { s[ "duration" ] :.1f} s' if s.get( 'duration' ) is not None else ''
240 if s.get( 'shot' ):
241 src = f ' { files }{ br.esc(s[ "shot" ]) } '
242 thumb = ( f '<button type="button" class="tstep-shot" data-src=" { src } " aria-label="Show step { s[ "i" ] } '
243 f 'full screen"><img src=" { src } " loading="lazy" alt="Screen after: { title } "></button>' )
244 else :
245 thumb = '<div class="tstep-noshot">No screenshot</div>'
246 error = f '<pre> { br.esc(s[ "error" ]) } </pre>' if s.get( 'error' ) else ''
247 items.append(
248 f '<li class="tstep { status } "> { thumb } <div class="tstep-info { status } "><span class="num"> { s[ "i" ] } </span><div>'
249 f '<div class="tstep-title"><span class="mono-label"> { br.esc(s[ "kind" ]) } </span><span> { title } </span></div>'
250 f '<div class="mono-label">at { s[ "t" ] :.1f} s { duration } </div> { error } </div></div></li>' )
251 passed = sum (s[ 'status' ] == 'passed' for s in steps)
252 return br.fold( 'Scenario steps' , f '<span class="mono-label"> { passed } of { len (steps) } passed</span>' ,
253 f '<ol class="tsteps"> { "" .join(items) } </ol>' )
254
255
256 def render_errors (console):
257 console_errors, request_errors = console.get( 'consoleErrors' ) or [], console.get( 'requestErrors' ) or []
258 if not console_errors and not request_errors:
259 return ''
260 items = [ f '<li> { br.esc(e.get( "text" , "" )) } </li>' for e in console_errors[: 50 ]]
261 items += [ f '<li> { e.get( "status" ) or "failed" } { br.esc(e.get( "method" , "" )) } { br.esc(e.get( "url" , "" )) } '
262 f ' { " · " + br.esc(e[ "error" ]) if e.get( "error" ) else "" } </li>' for e in request_errors[: 50 ]]
263 return br.fold( 'Browser errors' , f '<span class="mono-label"> { len (console_errors) } console · '
264 f ' { len (request_errors) } requests</span>' , f '<ul class="err-list"> { "" .join(items) } </ul>' )
265
266
267 def render_panel (run, verdict, files, scale, screen = None ):
268 data = run[ 'data' ]
269 prompt = (run[ 'result' ].get( 'prompts' ) or [{}])[ 0 ].get( 'text' , '' )
270 load_error = f '<div class="box err">Could not read the trace: { br.esc(data[ "loadError" ]) } </div>' if data.get( 'loadError' ) else ''
271 return ( f ' { load_error }{ render_video(screen) } '
272 f ' { render_expected(run, verdict) }{ render_run_box(run) } '
273 f ' { render_scenario_steps(run[ "steps" ], files) } '
274 f ' { br.render_steps(data[ "calls" ], data[ "events" ], * scale, prompt) } '
275 f ' { render_errors(run[ "console" ]) } ' )
276
277
278 def render_case (plan, status):
279 branch, master = status.get( 'branch' ) or {}, status.get( 'master' )
280 kinds = [s[ 'kind' ] for s in plan[ 'scenarios' ]]
281 lines = [ f 'Branch { branch.get( "commit" ) or "?" } ' + ( ' with local changes' if branch.get( 'dirty' ) else '' )
282 + ( f ' on { branch[ "url" ] } ' if branch.get( 'url' ) else ' on a fresh instance' )]
283 if master is not None :
284 lines.append( f 'Master { (master.get( "commit" ) or "(building)" )[: 11 ] } (the merge base) on a fresh instance' )
285 lines.append( f ' { len (kinds) } scenarios: ' + ', ' .join( f ' { kinds.count(k) } { k } ' for k in KIND_LABELS if kinds.count(k)))
286 lines.append( f ' { status.get( "concurrency" , 1 ) } at a time' )
287 return ( f '<div class="case"><div class="box"><div class="mono-label">Feature</div><div class="msg">'
288 f ' { br.esc(plan.get( "feature" ) or "-" ) } </div></div><div class="box"><div class="mono-label">Run</div>'
289 f '<div class="msg"> { br.esc( chr ( 10 ).join(lines)) } </div></div></div>' )
290
291
292 def notes (status, runs, verdicts):
293 parts = []
294 if not verdicts and status.get( 'phase' ) == 'done' :
295 parts.append( 'The runs are not judged yet. This page reloads when the verdicts and the token usage are ready.' )
296 parts.append( 'Times are seconds from the start of each scenario. Several scenarios ran at the same time on one '
297 'machine, so their times include the shared load. Tokens and cost come from LangSmith, matched by the '
298 'traces of the threads of each scenario. An asterisk on a time marks text placed at the previous tool call.' )
299 if any (r[ 'master' ] for r in runs):
300 parts.append( 'Visual scenarios ran on a fresh branch instance and on a fresh master instance, so both start '
301 'from the same empty state. Master is the merge base of the branch.' )
302 if status.get( 'stale' ):
303 parts.append( 'Warning: these packages had source changes newer than their build, so the branch instance ran '
304 f 'an old build: { ", " .join(status[ "stale" ]) } .' )
305 return br.esc( ' ' .join(parts))
306
307
308 def render_visual (results, runs, files):
309 if not results:
310 return ''
311 titles = {r[ 'scenario' ][ 'id' ]: r[ 'scenario' ][ 'title' ] for r in runs}
312 cards = []
313 for r in results:
314 cls , label = VISUAL .get(r[ 'status' ], ( 'vis-only' , r[ 'status' ]))
315 ratio = f ' { 100 * r[ "ratio" ] :.2f} % of pixels' if r.get( 'ratio' ) is not None else ''
316 sizes = ( f ' · size { r[ "masterSize" ][ 0 ] } × { r[ "masterSize" ][ 1 ] } → { r[ "branchSize" ][ 0 ] } × { r[ "branchSize" ][ 1 ] } '
317 if r.get( 'branchSize' ) and r[ 'branchSize' ] != r.get( 'masterSize' ) else '' )
318 figures = ''
319 for caption, key in (( 'Master' , 'master' ), ( 'Branch' , 'branch' ), ( 'Difference' , 'diff' )):
320 src = files(r, key)
321 figures += ( f '<figure><figcaption class="mono-label"> { caption } </figcaption>'
322 + ( f '<a href=" { src } " target="_blank"><img src=" { src } " loading="lazy" alt=" { caption } : { br.esc(r[ "name" ]) } "></a>'
323 if src else '<p class="dim">No screen.</p>' ) + '</figure>' )
324 tree = ( f '<details><summary>Accessibility tree diff</summary><pre> { br.esc(r[ "ariaDiff" ]) } </pre></details>'
325 if r.get( 'ariaDiff' ) else '' )
326 cards.append(
327 f '<div class="vis { "changed" if r[ "status" ] != "unchanged" else "" } "><div class="head">'
328 f '<h3> { br.esc(titles.get(r[ "scenario" ], r[ "scenario" ])) } › { br.esc(r[ "name" ]) } </h3>'
329 f '<span class="mono-label"> { ratio }{ sizes } </span><span class="badge { cls } "> { label } </span></div>'
330 f '<div class="vis-grid"> { figures } </div> { tree } </div>' )
331 changed = sum (r[ 'status' ] != 'unchanged' for r in results)
332 return br.fold( 'Visual check against master' ,
333 f '<span class="mono-label"> { len (results) } captures · { changed } changed</span>' ,
334 '<p class="fineprint">Pink pixels in the difference changed by more than a small color threshold. '
335 'Magenta boxes are masked parts, such as LLM text. The accessibility tree diff shows changes in '
336 'structure, roles and labels.</p>' + '' .join(cards))
337
338
339 STEP_VIEWER_JS = """
340 // A step screenshot opens full screen. The listeners are delegated, because the live page replaces the panels.
341 (()=>{
342 const viewer = document.createElement('div');
343 viewer.className = 'theater shot-viewer';
344 viewer.hidden = true;
345 viewer.setAttribute('role', 'dialog');
346 viewer.setAttribute('aria-modal', 'true');
347 viewer.setAttribute('aria-label', 'Step screenshot');
348 viewer.innerHTML = '<div class="theater-bar"><div class="shot-viewer-info"></div><div class="shot-viewer-actions">'
349 + '<span class="mono-label" data-shot-count></span>'
350 + '<button type="button" class="btn" data-shot-move="-1">Previous</button>'
351 + '<button type="button" class="btn" data-shot-move="1">Next</button>'
352 + '<button type="button" class="btn accent" data-shot-close>Close</button></div></div>'
353 + '<div class="shot-viewer-img"><img alt=""></div>'
354 + '<p class="fineprint">Press the left or right arrow key to change the step. Press Esc to close.</p>';
355 document.body.append(viewer);
356 let run = null, index = 0, opener = null;
357 const shots = () => [...(document.getElementById(run)?.querySelectorAll('.tstep-shot') || [])];
358 function render(){
359 const items = shots();
360 if (!items.length) return close();
361 index = Math.min(index, items.length - 1);
362 const button = items[index], img = viewer.querySelector('.shot-viewer-img img');
363 img.src = button.dataset.src;
364 img.alt = button.querySelector('img').alt;
365 viewer.querySelector('.shot-viewer-info').replaceChildren(button.closest('.tstep').querySelector('.tstep-info').cloneNode(true));
366 viewer.querySelector('[data-shot-count]').textContent = `${index + 1} of ${items.length}`;
367 }
368 function open(button){
369 run = button.closest('.run').id;
370 index = Math.max(0, shots().indexOf(button));
371 opener = button;
372 viewer.hidden = false;
373 document.body.classList.add('theater-open');
374 render();
375 viewer.querySelector('[data-shot-close]').focus();
376 }
377 function close(){
378 viewer.hidden = true;
379 run = null;
380 document.body.classList.remove('theater-open');
381 if (opener?.isConnected) opener.focus();
382 }
383 function move(step){
384 const count = shots().length;
385 if (!count) return close();
386 index = (index + step + count) % c ount;
387 render();
388 }
389 document.addEventListener('click', e=>{
390 const button = e.target.closest('.tstep-shot');
391 if (button) open(button);
392 });
393 viewer.addEventListener('click', e=>{
394 const step = e.target.closest('[data-shot-move]');
395 if (step) move(Number(step.dataset.shotMove));
396 else if (e.target.closest('[data-shot-close]') || e.target.classList.contains('shot-viewer-img')) close();
397 });
398 document.addEventListener('keydown', e=>{
399 if (viewer.hidden) return;
400 if (e.key === 'Escape') close();
401 else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { e.preventDefault(); move(e.key === 'ArrowRight' ? 1 : -1); }
402 });
403 })();
404 """
405
406 VIDEO_JS = """
407 // The video of a run plays only while its panel shows.
408 const showPanel = show;
409 show = (id)=>{
410 showPanel(id);
411 document.querySelectorAll('.run:not(.active) .run-video video').forEach(v=>v.pause());
412 };
413 """
414
415 RELOAD_JS = """
416 // Served by the live page: reload when the report is built again, for example after the verdicts.
417 (()=>{
418 if (!location.protocol.startsWith('http')) return;
419 const saved = JSON.parse(sessionStorage.getItem('report-view') || 'null');
420 if (saved) {
421 sessionStorage.removeItem('report-view');
422 if (document.getElementById(saved.run)) show(saved.run);
423 window.scrollTo(0, saved.y);
424 }
425 setInterval(async ()=>{
426 try {
427 const version = (await (await fetch('version.txt', {cache: 'no-store'})).text()).trim();
428 if (version && version !== REPORT_VERSION) {
429 const run = document.querySelector('.run.active')?.id;
430 sessionStorage.setItem('report-view', JSON.stringify({run, y: window.scrollY}));
431 location.reload();
432 }
433 } catch {}
434 }, 3000);
435 })();
436 """
437
438
439 def build (run_dir):
440 """Write report/index.html with its images and videos. Returns its path. Two builds of one run can
441 happen at the same time (run.py, report.py, the live page), so they take turns."""
442 run_dir.mkdir( parents = True , exist_ok = True )
443 with (run_dir / 'report.lock' ).open( 'w' ) as lock:
444 fcntl.flock(lock, fcntl. LOCK_EX )
445 return _build(run_dir)
446
447
448 def _build (run_dir):
449 plan, status, runs = load(run_dir)
450 verdicts = br.load_verdicts(run_dir)
451 report = run_dir / 'report'
452 for sub in ( 'runs' , 'screens' , 'visual' ):
453 shutil.rmtree(report / sub, ignore_errors = True )
454 report.mkdir( parents = True , exist_ok = True )
455 for sub in ( 'fonts' , 'brand' ):
456 if (br. ASSET_CACHE / sub).exists():
457 shutil.copytree(br. ASSET_CACHE / sub, report / sub, dirs_exist_ok = True )
458
459 scale = br.token_scale([(r[ 'name' ], {}, r[ 'data' ]) for r in runs])
460 rows, panels, screens = [], [], []
461 for i, run in enumerate (runs):
462 name, sub = run[ 'name' ], run_dir / 'runs' / run[ 'name' ]
463 shots = report / 'runs' / name
464 for png in sub.glob( '*.png' ):
465 if png.name not in ( 'live.png' , 'screen-poster.png' ):
466 shots.mkdir( parents = True , exist_ok = True )
467 shutil.copy(png, shots / png.name)
468 verdict = verdicts.get(name)
469 screen = None
470 if (sub / 'screen.webm' ).exists():
471 (report / 'screens' ).mkdir( exist_ok = True )
472 shutil.copy(sub / 'screen.webm' , report / 'screens' / f ' { name } .webm' )
473 screen = { 'name' : name, 'title' : run_title(run), 'duration' : run[ 'data' ][ 'duration' ], 'type' : 'video' ,
474 'src' : f 'screens/ { name } .webm' , 'verdict' : (verdict or {}).get( 'verdict' ),
475 'label' : KIND_LABELS .get(run[ 'scenario' ][ 'kind' ], '' )}
476 if (sub / 'screen-poster.png' ).exists():
477 shutil.copy(sub / 'screen-poster.png' , report / 'screens' / f ' { name } -poster.png' )
478 screen[ 'poster' ] = f 'screens/ { name } -poster.png'
479 screens.append(screen)
480 active = ' active' if i == 0 else ''
481 rows.append( f '<tr class="row { active } " data-run=" { name } " onclick="show( \'{ name }\' )">'
482 + row_cells(run, result_html(run, verdict)) + '</tr>' )
483 panels.append( f '<section class="run { active } " id=" { name } ">'
484 + render_panel(run, verdict, f 'runs/ { name } /' , scale, screen) + '</section>' )
485
486 results = read_json(run_dir / 'visual.json' , [])
487 for r in results:
488 target = report / 'visual' / r[ 'scenario' ]
489 target.mkdir( parents = True , exist_ok = True )
490 for key, source in (( 'branch' , run_dir / 'runs' / r[ 'scenario' ] / 'captures' / f ' { r[ "name" ] } .png' ),
491 ( 'master' , run_dir / 'runs' / f ' { r[ "scenario" ] } --master' / 'captures' / f ' { r[ "name" ] } .png' ),
492 ( 'diff' , run_dir / (r.get( 'diff' ) or 'missing' ))):
493 if source.is_file():
494 shutil.copy(source, target / f ' { r[ "name" ] } . { key } .png' )
495
496 def visual_file (r, key):
497 return ( f 'visual/ { r[ "scenario" ] } / { r[ "name" ] } . { key } .png'
498 if (report / 'visual' / r[ 'scenario' ] / f ' { r[ "name" ] } . { key } .png' ).exists() else None )
499
500 replay = br.render_replay(screens, 'Each video shows one scenario in the n8n UI. Press Esc to close.' )
501 version = str (time.time_ns())
502 page = br.fill_template(
503 br.esc(plan.get( 'title' ) or 'Instance AI feature test' ), '' .join(rows), notes(status, runs, verdicts),
504 '' .join(panels), replay = replay, after_table = render_visual(results, runs, visual_file), head = CSS ,
505 scripts = ( f '<script>const SCREENS = { json.dumps(screens) } ; const REPORT_VERSION = " { version } ";</script>'
506 f '<script>window.addEventListener("DOMContentLoaded", ()=> {{{ VIDEO_JS }{ RELOAD_JS }}} );</script>'
507 f '<script> { STEP_VIEWER_JS } </script>' ),
508 case = render_case(plan, status), thead = THEAD , kicker = 'Assistant UI test' )
509 (report / 'index.html' ).write_text(page)
510 (report / 'version.txt' ).write_text(version)
511 return report / 'index.html'
512
513
514 def clock (status):
515 started = status.get( 'startedAt' )
516 elapsed = seconds((status.get( 'finishedAt' ) or time.time()) - started) if started else ''
517 return ' · ' .join(x for x in ( f ' { status.get( "concurrency" , 1 ) } at a time' , elapsed, status.get( 'step' )) if x)
518
519
520 def live_box (status, runs):
521 cards = []
522 for run in runs:
523 name, title = run[ 'name' ], br.esc(run_title(run))
524 cards.append(
525 f '<div class="live-card"><div class="live-screen"><button type="button" class="live-zoom" '
526 f 'data-zoom=" { name } " aria-label="Show { title } full screen"><img data-shot=" { name } " '
527 f 'alt=" { title } in the n8n UI" hidden></button>'
528 f '<div class="live-empty" data-shot-empty=" { name } ">Waiting for the first screenshot</div></div>'
529 f '<div class="reel-meta"><span class="run-name"> { title } </span>'
530 f '<span data-live-badge=" { name } "> { status_badge(run[ "entry" ].get( "status" , "waiting" )) } </span></div></div>' )
531 return ( f '<div class="box"><div class="head"><h2>Live</h2><span class="mono-label" id="live-clock">'
532 f ' { br.esc(clock(status)) } </span></div><div class="live-grid"> { "" .join(cards) } </div></div>' )
533
534
535 def render_live (run_dir):
536 plan, status, runs = load(run_dir)
537 verdicts = br.load_verdicts(run_dir)
538 scale = br.token_scale([(r[ 'name' ], {}, r[ 'data' ]) for r in runs])
539 rows, panels = [], []
540 for i, run in enumerate (runs):
541 name, active = run[ 'name' ], ' active' if i == 0 else ''
542 rows.append( f '<tr class="row { active } " data-run=" { name } " onclick="show( \'{ name }\' )">'
543 + row_cells(run, result_html(run, verdicts.get(name))) + '</tr>' )
544 panels.append( f '<section class="run { active } " id=" { name } ">'
545 + render_panel(run, verdicts.get(name), f '/files/runs/ { name } /' , scale) + '</section>' )
546 text = ( 'This page updates while the scenarios run, and changes to the full report when the run is done. '
547 'Tokens, cost, the visual check and verdicts come after the run.' )
548 return br.fill_template(
549 br.esc(plan.get( 'title' ) or 'Instance AI feature test' ), '' .join(rows), br.esc(text), '' .join(panels),
550 replay = live_box(status, runs), head = CSS ,
551 scripts = f '<script>const SCREENS = [];</script><script> { ls. LIVE_JS } </script><script> { STEP_VIEWER_JS } </script>' ,
552 case = render_case(plan, status), thead = THEAD , kicker = 'Assistant UI test' )
553
554
555 class ScenarioApp :
556 """The live page for live_server.serve(): one card, table row and panel for each scenario run."""
557
558 def __init__ (self, run_dir):
559 self .run_dir = run_dir
560
561 def entry (self, name):
562 return ls.read_status( self .run_dir).get( 'runs' , {}).get(name)
563
564 def finished (self, name):
565 return ( self .entry(name) or {}).get( 'status' ) in FINISHED
566
567 def done (self):
568 return ls.read_status( self .run_dir).get( 'phase' ) == 'done' and ( self .run_dir / 'report' / 'index.html' ).exists()
569
570 def page (self):
571 return render_live( self .run_dir)
572
573 def state (self):
574 plan, status, runs = load( self .run_dir)
575 verdicts = br.load_verdicts( self .run_dir)
576 scale = br.token_scale([(r[ 'name' ], {}, r[ 'data' ]) for r in runs])
577 state = { 'phase' : status.get( 'phase' , 'running' ), 'clock' : clock(status), 'runs' : {}}
578 if self .done():
579 state[ 'report' ] = '/report/index.html'
580 for run in runs:
581 name = run[ 'name' ]
582 shot = self .shot(name)
583 state[ 'runs' ][name] = {
584 'row' : row_cells(run, result_html(run, verdicts.get(name))),
585 'panel' : render_panel(run, verdicts.get(name), f '/files/runs/ { name } /' , scale),
586 'badge' : status_badge(run[ 'entry' ].get( 'status' , 'waiting' )),
587 'shot' : shot.stat().st_mtime if shot.exists() else None ,
588 }
589 return state
590
591 def shot (self, name):
592 return self .run_dir / 'runs' / name / 'live.png'
593
594 def casts (self, name):
595 return ()
596
597 def file (self, path):
598 """The step screenshots of a run while it runs: /files/runs/<name>/<file>.png."""
599 if not path.startswith( '/files/runs/' ) or not path.endswith( '.png' ):
600 return None
601 base = ( self .run_dir / 'runs' ).resolve()
602 candidate = ( self .run_dir / path.removeprefix( '/files/' )).resolve()
603 return candidate if candidate.is_relative_to(base) and candidate.is_file() else None
604
605
606 def triage (run, visual):
607 """The reasons to look closer at a run. An empty list means that the run looks clean: every step and check
608 passed, with no browser error, no failed tool call, and no visual change."""
609 reasons = []
610 status = run[ 'entry' ].get( 'status' )
611 if status != 'passed' :
612 reasons.append( f 'the run { status } ' )
613 failed = [s for s in run[ 'steps' ] if s[ 'status' ] == 'failed' ]
614 if failed:
615 reasons.append( f ' { len (failed) } failed step(s) or check(s)' )
616 console = run[ 'console' ]
617 errors = len (console.get( 'consoleErrors' ) or []) + len (console.get( 'requestErrors' ) or [])
618 if errors:
619 reasons.append( f ' { errors } browser error(s)' )
620 tool_errors = sum (e[ 'error' ] for e in run[ 'data' ][ 'events' ] if e[ 'kind' ] == 'tool' )
621 if tool_errors:
622 reasons.append( f ' { tool_errors } failed tool call(s)' )
623 changed = [r[ 'name' ] for r in visual if r[ 'scenario' ] == run[ 'scenario' ][ 'id' ] and r[ 'status' ] != 'unchanged' ]
624 if changed and not run[ 'master' ]:
625 reasons.append( f 'visual change in { ", " .join(changed) } ' )
626 return reasons
627
628
629 def summarize (run_dir):
630 """Plain text for the judge: a triage list, then each run with its steps, final reply, errors and visual
631 results."""
632 plan, status, runs = load(run_dir)
633 visual = read_json(run_dir / 'visual.json' , [])
634 out = [ f '# { plan.get( "title" , "" ) } ' , '' , 'Feature:' , plan.get( 'feature' ) or '-' , '' , '## Triage' , '' ,
635 'Clean runs passed every step and check, with no browser error, failed tool call or visual change.' ,
636 'For these, check that the last reply and the final screenshot match the expected result.' ,
637 'Look closely at the other runs: their screenshots, errors and diffs.' , '' ]
638 judged = [r for r in runs if not r[ 'master' ]]
639 for run in judged:
640 reasons = triage(run, visual)
641 out.append( f '- { run[ "name" ] } : ' + ( 'clean' if not reasons else 'look closer: ' + '; ' .join(reasons)))
642 out.append( '' )
643 for run in runs:
644 s, result, data = run[ 'scenario' ], run[ 'result' ], run[ 'data' ]
645 out += [ f '## { run[ "name" ] } : { run_title(run) } ( { s[ "kind" ] } , { "master" if run[ "master" ] else "branch" } )' ,
646 f 'Expected: { s[ "expected" ] } ' ,
647 f 'Status: { run[ "entry" ].get( "status" ) } ' + ( f ' ( { result[ "error" ] } )' if result.get( 'error' ) else '' ),
648 'Steps:' ]
649 for st in run[ 'steps' ]:
650 shot = f ' (runs/ { run[ "name" ] } / { st[ "shot" ] } )' if st.get( 'shot' ) else ''
651 out.append( f ' { st[ "i" ] } . [ { st[ "status" ] } ] { st[ "kind" ] } : { st[ "title" ] }{ shot } ' )
652 if st.get( 'error' ):
653 out.append( ' ' + st[ 'error' ].replace( ' \n ' , ' \n ' )[: 1500 ])
654 for p in result.get( 'prompts' ) or []:
655 out.append( f 'Prompt (thread { p.get( "thread" ) } ): { p[ "text" ] } ' )
656 tools = [e for e in data[ 'events' ] if e[ 'kind' ] == 'tool' ]
657 if tools:
658 out.append( 'Tool calls: ' + ', ' .join(e[ 'name' ] + ( ' (error)' if e[ 'error' ] else '' ) for e in tools))
659 texts = [e[ 'text' ] for e in data[ 'events' ] if e[ 'kind' ] == 'text' ]
660 if texts:
661 out.append( 'Last reply: ' + texts[ - 1 ].strip()[: 2000 ])
662 for d in run[ 'meta' ].get( 'decisions' ) or []:
663 out.append( f 'Confirmation: { d[ "decision" ] } [ { d.get( "toolName" ) } ]' )
664 console = run[ 'console' ]
665 for e in (console.get( 'consoleErrors' ) or [])[: 8 ]:
666 out.append( f 'Console error: { e.get( "text" , "" )[: 300 ] } ' )
667 for e in (console.get( 'requestErrors' ) or [])[: 8 ]:
668 out.append( f 'Request error: { e.get( "status" ) } { e.get( "method" ) } { e.get( "url" ) } ' )
669 if s[ 'kind' ] == 'visual' and not run[ 'master' ]:
670 for r in (v for v in visual if v[ 'scenario' ] == s[ 'id' ]):
671 ratio = f ', { 100 * r[ "ratio" ] :.2f} % of pixels' if r.get( 'ratio' ) is not None else ''
672 out.append( f 'Visual { r[ "name" ] } : { r[ "status" ] }{ ratio } ' )
673 if r.get( 'ariaDiff' ):
674 out.append( ' Accessibility tree diff: \n ' + ' \n ' .join(r[ 'ariaDiff' ].splitlines()[: 40 ]))
675 out.append( '' )
676 return ' \n ' .join(out)
677
678
679 def main ():
680 if len (sys.argv) < 2 :
681 sys.exit( __doc__ )
682 run_dir = Path(sys.argv[ 1 ]).resolve()
683 if '--summary' in sys.argv:
684 print (summarize(run_dir))
685 else :
686 print (build(run_dir))
687
688
689 if __name__ == '__main__' :
690 main()