#!/usr/bin/env python3
"""Run a test plan of Instance AI scenarios and build one report. The judge step is separate.
run.py --plan
/scenarios.json [--only id1,id2] [--concurrency 4]
run.py --rerun last| the plan of an earlier run, again in a new folder
run.py --plan ... --try a quick check of new scripts: no master, no live page, no report
run.py --plan ... --estimate print the expected cost and time, then stop
run.py --check check the setup, then stop
Steps:
1. Check the setup, and build this checkout with turbo (a few seconds when nothing changed).
2. Start fresh n8n instances from the build. Each feature and regression scenario gets its own
instance, unless it sets "isolated": false. Instances start ahead of their scenario, so a
scenario does not wait for one.
3. Run the scenarios, several at the same time.
4. At the same time: build the master merge base (baseline.py), start a fresh master instance and
a fresh branch instance, and run each visual scenario on both. A master run that passed is kept
and used again while the merge base and the script stay the same.
5. Compare the visual captures, write summary.txt for the judge, and build the report.
6. Fetch the token usage from LangSmith and build the report again.
A local page shows the runs live (live_page.py). It opens in the default browser, unless --no-open.
Settings come from .env.local and .env.ai-surfaces.
"""
import argparse
import difflib
import hashlib
import json
import os
import re
import shutil
import signal
import socket
import statistics
import subprocess
import sys
import threading
import time
import webbrowser
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from pathlib import Path
import baseline
import report
from common import CACHE, HERE, PLANS_ROOT, REPO_ROOT, RUNS_ROOT, SHARED, Instance, client, git, setting
from langsmith_usage import fetch_calls, totals
from live import enabled as live_enabled
from run_instance_ai import decide
KINDS = ('feature', 'regression', 'visual')
LIB = HERE.parent / 'scenarios'
ID = re.compile(r'^[a-z0-9][a-z0-9-]{0,60}$')
MASTER_CACHE = CACHE / 'master-runs'
MASTER_CACHE_DAYS = 14
PRINT_LOCK = threading.Lock()
RUNNERS = set()
S = '.agents/skills/testmax-assistant-ui/scripts'
def say(line, prefix=None):
with PRINT_LOCK:
print(f'[{prefix}] {line}' if prefix else line, flush=True)
def step(title):
say(f'\n=== {title} ===')
def now_iso():
return datetime.now(timezone.utc).isoformat()
def slug(text, words=8):
return '-'.join(re.findall(r'[a-z0-9]+', text.lower())[:words]) or 'run'
def script_path(spec, base):
return LIB / spec[4:] if spec.startswith('lib:') else (base / spec).resolve()
def load_plan(path):
"""Read and check scenarios.json. Returns the plan with each script path resolved."""
path = Path(path).resolve()
plan = json.loads(path.read_text())
errors, seen = [], set()
if not plan.get('scenarios'):
errors.append('The plan has no scenarios.')
if not isinstance(plan.get('featureFlags', {}), dict):
errors.append('featureFlags must be an object of flag names and values.')
for s in plan.get('scenarios', []):
sid = s.get('id', '')
if not ID.match(sid) or '--' in sid:
errors.append(f'Scenario id "{sid}": use a-z, 0-9 and single "-" characters.')
if sid in seen:
errors.append(f'Scenario id "{sid}" is used two times.')
seen.add(sid)
if s.get('kind') not in KINDS:
errors.append(f'Scenario "{sid}": kind must be one of {", ".join(KINDS)}.')
if not s.get('script') or not script_path(s['script'], path.parent).is_file():
errors.append(f'Scenario "{sid}": script {s.get("script")} not found.')
if not s.get('title') or not s.get('expected'):
errors.append(f'Scenario "{sid}": give a title and an expected outcome.')
if errors:
sys.exit('The plan is not valid:\n ' + '\n '.join(errors))
for s in plan['scenarios']:
s['scriptPath'] = str(script_path(s['script'], path.parent))
plan.setdefault('source', str(path))
plan['name'] = path.parent.name if path.parent.name != 'plan' else slug(plan.get('title') or 'run')
return plan
def run_dirs():
"""Earlier run folders, newest first."""
if not RUNS_ROOT.exists():
return []
found = [d for d in RUNS_ROOT.iterdir() if (d / 'plan' / 'scenarios.json').is_file()]
return sorted(found, key=lambda d: (d / 'plan' / 'scenarios.json').stat().st_mtime, reverse=True)
def rerun_plan(arg):
"""The plan to run again. The source plan wins over the copy, so a fixed script is used."""
if arg == 'last':
found = run_dirs()
if not found:
sys.exit(f'No earlier run in {RUNS_ROOT}.')
run_dir = found[0]
else:
run_dir = Path(arg).expanduser().resolve()
copy = run_dir / 'plan' / 'scenarios.json'
if not copy.is_file():
sys.exit(f'{run_dir} has no plan/scenarios.json.')
source = json.loads(copy.read_text()).get('source')
if source and Path(source).is_file():
say(f'rerun of {run_dir.name}, with the current plan and scripts in {Path(source).parent}')
return source
say(f'rerun of {run_dir.name}, with the scripts copied into that run')
return copy
def new_run_dir(root, name):
base = root / f'{datetime.now():%Y%m%d-%H%M%S}-{name}'
run_dir, n = base, 2
while run_dir.exists():
run_dir, n = base.with_name(f'{base.name}-{n}'), n + 1
run_dir.mkdir(parents=True)
return run_dir
def copy_plan(plan, run_dir):
"""Keep the plan and its scripts in the run folder, so --rerun can repeat the run."""
plan_dir = run_dir / 'plan'
(plan_dir / 'scripts').mkdir(parents=True, exist_ok=True)
saved = json.loads(json.dumps(plan))
saved.pop('name', None)
for s in saved['scenarios']:
target = plan_dir / 'scripts' / f'{s["id"]}.mjs'
shutil.copy(s.pop('scriptPath'), target)
s['script'] = f'scripts/{target.name}'
(plan_dir / 'scenarios.json').write_text(json.dumps(saved, indent=2))
class Progress:
"""Writes status.json, which the live page reads."""
def __init__(self, run_dir, data):
self.path, self.lock, self.data = run_dir / 'status.json', threading.Lock(), data
self.save()
def save(self):
tmp = self.path.with_suffix('.tmp')
tmp.write_text(json.dumps(self.data, indent=2))
os.replace(tmp, self.path)
def update(self, **fields):
with self.lock:
self.data.update(fields)
self.save()
def run(self, name, **fields):
with self.lock:
self.data['runs'][name].update(fields)
self.save()
def finish(self):
with self.lock:
for entry in self.data['runs'].values():
if entry['status'] in ('waiting', 'starting'):
entry.update(status='skipped', reason=entry.get('reason') or 'The run did not start.')
elif entry['status'] == 'running':
entry['status'] = 'error'
if self.data.get('phase') != 'done':
self.data.update(phase='done', step='Done', finishedAt=time.time())
self.save()
def close_stream(res):
"""Close an event stream that another thread reads. close() alone waits for the next server
keep-alive (about 13 seconds), because the reader holds the buffer lock. A socket shutdown ends the
read at once."""
try:
with socket.socket(fileno=os.dup(res.fileno())) as sock:
sock.shutdown(socket.SHUT_RDWR)
except (OSError, ValueError):
pass
try:
res.close()
except Exception:
pass
class ThreadEvents:
"""Reads the event stream of each thread that a scenario opens. The stream starts at the first event."""
def __init__(self, base_url, out, auto_confirm, prefix):
self.client = client(base_url).login()
self.out, self.auto_confirm, self.prefix = out, auto_confirm, prefix
self.file, self.lock = (out / 'events.jsonl').open('a'), threading.Lock()
self.run_ids, self.decisions, self.streams, self.closed = [], [], [], False
def follow(self, thread_id):
threading.Thread(target=self._read, args=(thread_id,), daemon=True).start()
def _read(self, thread_id):
try:
res = self.client.open_stream(f'/rest/instance-ai/events/{thread_id}')
except Exception as e:
say(f'event stream of {thread_id} did not open: {e}', self.prefix)
return
self.streams.append(res)
data, name = [], None
try:
for raw in res:
line = raw.decode(errors='replace').rstrip('\r\n')
if line.startswith('data:'):
data.append(line[5:].lstrip())
elif line.startswith('event:'):
name = line[6:].strip()
elif line == '' and data:
try:
self._event(json.loads('\n'.join(data)))
except json.JSONDecodeError:
pass
data, name = [], None
except Exception:
# close() ends the stream when the scenario stops.
pass
def _event(self, event):
with self.lock:
if self.closed:
return
self.file.write(json.dumps(event) + '\n')
self.file.flush()
payload = event.get('payload') or {}
if event.get('type') == 'run-start' and payload.get('langsmithRunId'):
self.run_ids.append(payload['langsmithRunId'])
if event.get('type') == 'confirmation-request' and self.auto_confirm:
body, label = decide(payload)
try:
self.client.request('POST', f'/rest/instance-ai/confirm/{payload["requestId"]}', body)
ok = True
except RuntimeError as e:
ok, label = False, f'{label} (failed: {str(e)[:200]})'
self.decisions.append({'at': now_iso(), 'toolName': payload.get('toolName'), 'decision': label, 'ok': ok})
say(f'confirm: {label} [{payload.get("toolName")}]', self.prefix)
def close(self):
with self.lock:
self.closed = True
self.file.close()
for res in self.streams:
close_stream(res)
def run_job(name, scenario, url, instance, run_dir, progress, live):
sub = run_dir / 'runs' / name
sub.mkdir(parents=True, exist_ok=True)
progress.run(name, status='running', startedAt=time.time(), baseUrl=url)
auto_confirm = scenario.get('autoConfirm', True)
timeout_min = float(scenario.get('timeoutMin', 10))
started = now_iso()
try:
events = ThreadEvents(url, sub, auto_confirm, name)
except Exception as e:
progress.run(name, status='error', finishedAt=time.time(), reason=f'Cannot sign in to {url}: {e}')
return
cmd = ['node', str(HERE / 'run_scenario.mjs'), '--out', str(sub), '--script', scenario['scriptPath'],
'--timeout-min', str(timeout_min), '--viewport', scenario.get('viewport', '1600x1000')]
cmd += ['--live'] if live else []
cmd += ['--auto-confirm'] if auto_confirm else []
proc = subprocess.Popen(cmd, cwd=HERE, env={**os.environ, 'N8N_BASE_URL': url},
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
RUNNERS.add(proc)
# The scenario has its own time limit. This one covers a runner that hangs outside the scenario.
watchdog = threading.Timer(timeout_min * 60 + 180, proc.kill)
watchdog.start()
with (sub / 'runner.log').open('w') as log:
for line in proc.stdout:
log.write(line)
say(line.rstrip(), name)
match = re.match(r'thread (\S+)', line)
if match:
events.follow(match[1])
proc.wait()
RUNNERS.discard(proc)
watchdog.cancel()
# The last events of a run can arrive just after the scenario ends.
time.sleep(1)
events.close()
try:
result = json.loads((sub / 'result.json').read_text())
except (OSError, ValueError):
result = {'status': 'error', 'error': f'The runner stopped without a result (exit {proc.returncode}). See runner.log.'}
(sub / 'result.json').write_text(json.dumps(result, indent=2))
meta = {
'kind': 'scenario', 'scenario': scenario['id'], 'instance': instance, 'baseUrl': url,
'threadIds': result.get('threads', []), 'langsmithRunIds': events.run_ids, 'decisions': events.decisions,
'startedAt': started, 'finishedAt': now_iso(),
'screen': 'screen.webm' if (sub / 'screen.webm').exists() else None,
}
(sub / 'run.json').write_text(json.dumps(meta, indent=2))
progress.run(name, status=result['status'], finishedAt=time.time(), reason=result.get('error'))
def stale_packages():
"""Packages with a source change in this branch that is newer than their build output."""
try:
base = subprocess.run(['git', 'merge-base', 'HEAD', 'origin/master'], cwd=REPO_ROOT, capture_output=True,
text=True, check=True).stdout.strip()
changed = subprocess.run(['git', 'diff', '--name-only', base], cwd=REPO_ROOT, capture_output=True,
text=True, check=True).stdout.split()
changed += [line[3:] for line in subprocess.run(['git', 'status', '--porcelain'], cwd=REPO_ROOT,
capture_output=True, text=True, check=True).stdout.splitlines()]
except subprocess.CalledProcessError:
return []
newest_dist, stale = {}, set()
for rel in changed:
path = REPO_ROOT / rel
if not rel.startswith('packages/') or '/src/' not in rel or re.search(r'(\.test\.|__tests__|\.md$)', rel):
continue
if not path.is_file():
continue
pkg = REPO_ROOT / rel.split('/src/', 1)[0]
dist = pkg / 'dist'
if not dist.exists():
continue
if pkg not in newest_dist:
newest_dist[pkg] = max((f.stat().st_mtime for f in dist.rglob('*') if f.is_file()), default=0)
if path.stat().st_mtime > newest_dist[pkg]:
stale.add(str(pkg.relative_to(REPO_ROOT)))
return sorted(stale)
def build_branch(log_path):
"""Build n8n and its packages with turbo. Unchanged packages come from the turbo cache in seconds."""
started = time.time()
with log_path.open('w') as log:
code = subprocess.run(['pnpm', 'turbo', 'run', 'build', '--filter=n8n...', '--output-logs=errors-only'],
cwd=REPO_ROOT, stdout=log, stderr=subprocess.STDOUT).returncode
text = log_path.read_text()
if code != 0:
sys.exit(f'The build failed. The end of {log_path}:\n' + '\n'.join(text.splitlines()[-30:]))
cached = re.search(r'Cached:\s+(\d+) cached, (\d+) total', text)
if cached and cached[1] == cached[2]:
say(f'build: up to date ({time.time() - started:.0f}s)')
else:
say(f'build: done in {time.time() - started:.0f}s')
def preflight(build, need_master, local_instances=True):
"""Problems that stop a run (errors) or make the report less useful (warnings).
local_instances is False with --branch-url: the branch runs then need no local build."""
errors, warnings = [], []
for tool in ('node', 'git') + (('pnpm',) if build or need_master else ()):
if not shutil.which(tool):
errors.append(f'{tool} is not on the PATH.')
if shutil.which('node'):
probe = ("import(process.argv[1]).then(async (m) => { const b = await m.launchBrowser(); await b.close(); })"
".catch((e) => { console.error(e.message.split('\\n')[0]); process.exit(1); })")
res = subprocess.run(['node', '-e', probe, str(SHARED / 'n8n_browser.mjs')], capture_output=True, text=True,
timeout=60)
if res.returncode != 0:
errors.append('Playwright cannot start a browser: '
f'{(res.stderr or res.stdout).strip()[:300]}. Run pnpm install, and install Google Chrome.')
if not build and local_instances:
for path in ('packages/cli/dist', 'packages/frontend/editor-ui/dist/index.html'):
if not (REPO_ROOT / path).exists():
errors.append(f'{path} does not exist. Build first, or run without --no-build.')
if not setting('N8N_INSTANCE_AI_MODEL'):
warnings.append('N8N_INSTANCE_AI_MODEL is not set in .env.local, .env.ai-surfaces or the environment. '
'Scenarios that send a prompt can fail.')
if not setting('LANGSMITH_API_KEY'):
warnings.append('LANGSMITH_API_KEY is not set. The report has no tokens or cost.')
elif (setting('LANGSMITH_TRACING') or '').lower() != 'true':
warnings.append('LANGSMITH_TRACING is not true. The instances send no traces, so the report has no tokens or cost.')
if (setting('N8N_DIAGNOSTICS_ENABLED') or '').lower() == 'false':
warnings.append('N8N_DIAGNOSTICS_ENABLED is false. Instance AI sends no traces, so the report has no tokens or cost.')
RUNS_ROOT.mkdir(parents=True, exist_ok=True)
free = shutil.disk_usage(RUNS_ROOT).free / 1e9
if free < 2:
warnings.append(f'Only {free:.1f} GB free in {RUNS_ROOT}. A run needs about 0.5 GB.')
if need_master and not baseline.built():
CACHE.mkdir(parents=True, exist_ok=True)
free = shutil.disk_usage(CACHE).free / 1e9
warnings.append('The master build does not exist yet. The first build takes 15 to 20 minutes'
+ (f', and needs about 3 GB (free: {free:.1f} GB).' if free < 5 else '.'))
return errors, warnings
def history():
"""Cost and time of earlier runs, by scenario id. Master runs and runs without a result are left out."""
by_id, every = {}, []
for run_dir in run_dirs():
try:
runs = json.loads((run_dir / 'status.json').read_text()).get('runs', {})
except (OSError, ValueError):
continue
for name, entry in runs.items():
if name.endswith('--master') or entry.get('status') not in ('passed', 'failed'):
continue
if not (entry.get('startedAt') and entry.get('finishedAt')):
continue
usage = run_dir / 'runs' / name / 'usage.json'
try:
if usage.exists():
cost = sum(c['cost'] for c in json.loads(usage.read_text()))
else:
meta = json.loads((run_dir / 'runs' / name / 'run.json').read_text())
cost = None if meta.get('langsmithRunIds') else 0.0
except (OSError, ValueError):
cost = None
sample = (cost, entry['finishedAt'] - entry['startedAt'])
by_id.setdefault(entry.get('scenario', name), []).append(sample)
every.append(sample)
return by_id, every
def estimate(jobs, concurrency, compare_master, flags):
"""Expected cost and time of the jobs, from the earlier runs of the same scenario ids. A master run that
can be reused costs nothing."""
by_id, every = history()
try:
base = baseline.target() if compare_master else None
except subprocess.CalledProcessError:
base = None
def median(samples, index, default):
values = [x[index] for x in samples if x[index] is not None]
return statistics.median(values) if values else default
any_cost, any_time = median(every, 0, None), median(every, 1, 60)
rows, cost_total, known = [], 0.0, True
for name, s, role in jobs:
if role == 'master' and base and (MASTER_CACHE / master_key(s, base, flags) / 'result.json').is_file():
rows.append((name + ' (kept)', 0.0, 0, True))
continue
samples = by_id.get(s['id'], [])
cost = median(samples, 0, any_cost)
seconds = median(samples, 1, any_time)
if cost is None:
known = False
else:
cost_total += cost
rows.append((name, cost, seconds, bool(samples)))
times = sorted((r[2] for r in rows), reverse=True)
wall = max(times[0] if times else 0, sum(times) / max(1, concurrency)) + 15
return {'rows': rows, 'cost': cost_total if known else None, 'seconds': wall,
'masterBuild': compare_master and not baseline.built()}
def print_estimate(est):
say('Estimate (median of earlier runs; * means no earlier run of this scenario):')
for name, cost, seconds, seen in est['rows']:
price = f'${cost:.2f}' if cost is not None else ' ? '
say(f' {name:44} {price:>7} {seconds:5.0f}s{"" if seen else " *"}')
total = f'about ${est["cost"]:.2f}' if est['cost'] is not None else 'unknown cost'
extra = ', plus 15 to 20 minutes for the first master build' if est['masterBuild'] else ''
say(f' {len(est["rows"])} runs, {total}, about {est["seconds"] / 60:.1f} minutes{extra}')
def fetch_usage(run_dir, names, timeout=90):
"""Token usage and cost of each run from LangSmith, in one query for all runs. LangSmith receives traces
with a delay, so the query repeats until each run has calls and the count stops changing."""
if not setting('LANGSMITH_API_KEY'):
return
metas = {}
for name in names:
file = run_dir / 'runs' / name / 'run.json'
meta = json.loads(file.read_text()) if file.exists() else {}
if meta.get('langsmithRunIds') and not meta.get('cached'):
metas[name] = meta
if not metas:
return
start = min(m['startedAt'] for m in metas.values())
end = max(m['finishedAt'] for m in metas.values())
traces = {t for m in metas.values() for t in m['langsmithRunIds']}
deadline, previous, calls = time.time() + timeout, None, []
while True:
try:
calls = fetch_calls(start, end, traces)
except (SystemExit, OSError, ValueError) as e:
say(f'usage failed: {str(e)[:200]}')
return
seen = {c['trace'] for c in calls}
complete = all(set(m['langsmithRunIds']) & seen for m in metas.values())
if (complete and len(calls) == previous) or time.time() > deadline:
break
previous = len(calls)
time.sleep(10)
for name, meta in metas.items():
own = [c for c in calls if c['trace'] in set(meta['langsmithRunIds'])]
(run_dir / 'runs' / name / 'usage.json').write_text(json.dumps(own, indent=1))
usage = totals(own)
say(f'{usage["calls"]} LLM calls, ${usage["cost"]:.2f}', name)
def compare_trees(run_dir):
"""Add the accessibility tree diff of each capture to visual.json."""
path = run_dir / 'visual.json'
if not path.exists():
return []
results = json.loads(path.read_text())
for r in results:
trees = []
for name in (f'{r["scenario"]}--master', r['scenario']):
file = run_dir / 'runs' / name / 'captures' / f'{r["name"]}.aria.yml'
trees.append(file.read_text().splitlines() if file.exists() else [])
diff = '\n'.join(difflib.unified_diff(*trees, 'master', 'branch', lineterm='', n=2))
r['ariaDiff'] = diff
r['pixelStatus'] = r['status']
if diff and r['status'] == 'unchanged':
r['status'] = 'changed'
path.write_text(json.dumps(results, indent=2))
return results
def master_key(scenario, commit, flags):
"""A master run gives the same screens while the commit, the flags, the script and the runner stay the same."""
h = hashlib.sha256()
overrides = setting('N8N_FEATURE_FLAG_OVERRIDES') or ''
for part in (commit, json.dumps(flags or {}, sort_keys=True), overrides, scenario.get('viewport', '1600x1000'),
str(scenario.get('autoConfirm', True))):
h.update(part.encode() + b'\0')
for file in (Path(scenario['scriptPath']), HERE / 'kit.mjs', HERE / 'run_scenario.mjs'):
h.update(file.read_bytes())
return h.hexdigest()[:20]
def save_master_run(sub, key):
if json.loads((sub / 'result.json').read_text()).get('status') != 'passed':
return
target = MASTER_CACHE / key
shutil.rmtree(target, ignore_errors=True)
shutil.copytree(sub, target, ignore=shutil.ignore_patterns('live.png', 'usage.json'))
cutoff = time.time() - MASTER_CACHE_DAYS * 86400
for old in MASTER_CACHE.iterdir():
if old.is_dir() and old.stat().st_mtime < cutoff:
shutil.rmtree(old, ignore_errors=True)
def reuse_master_run(sub, key):
"""Copy a kept master run into this run. Returns False when there is none."""
source = MASTER_CACHE / key
if not (source / 'result.json').is_file():
return False
shutil.copytree(source, sub, dirs_exist_ok=True)
meta = json.loads((sub / 'run.json').read_text())
meta['cached'] = True
(sub / 'run.json').write_text(json.dumps(meta, indent=2))
return True
def start_live_server(run_dir):
url_file = run_dir / 'live-url.txt'
url_file.unlink(missing_ok=True)
log = (run_dir / 'live-server.log').open('w')
# A new session, so the page still serves the report after this script ends.
subprocess.Popen([sys.executable, str(HERE / 'live_page.py'), '--run-dir', str(run_dir),
'--parent-pid', str(os.getpid())],
cwd=HERE, stdin=subprocess.DEVNULL, stdout=log, stderr=log, start_new_session=True)
for _ in range(50):
if url_file.exists() and url_file.read_text().strip():
return url_file.read_text().strip()
time.sleep(0.1)
return None
def open_in_default_browser(url):
"""Opens url in the default browser. Returns False if no browser opened.
$BROWSER is not used: agents often set it to a command that does nothing.
"""
command = {'darwin': ['open'], 'linux': ['xdg-open']}.get(sys.platform)
if command and shutil.which(command[0]):
try:
return subprocess.run(command + [url], capture_output=True, timeout=15).returncode == 0
except (OSError, subprocess.TimeoutExpired):
return False
try:
return webbrowser.open(url)
except webbrowser.Error:
return False
def result_table(run_dir, runs):
"""A plain text table of the results, with the first problem of each run that did not pass."""
lines = [f' {"run":44} {"status":15} {"steps":>7} {"time":>6} {"errors":>6}']
for name, entry in runs.items():
sub = run_dir / 'runs' / name
steps = report.read_json(sub / 'steps.json', [])
result = report.read_json(sub / 'result.json', {})
checked = [s for s in steps if s['kind'] in ('step', 'check', 'capture')]
passed = f'{sum(s["status"] == "passed" for s in checked)}/{len(checked)}' if checked else '-'
took = (entry.get('finishedAt') or 0) - (entry.get('startedAt') or 0)
errors = (result.get('consoleErrors') or 0) + (result.get('requestErrors') or 0)
status = entry['status'] + (' (kept)' if report.read_json(sub / 'run.json', {}).get('cached') else '')
lines.append(f' {name:44} {status:15} {passed:>7} {took:5.0f}s {errors:>6}')
failed = next((s for s in steps if s['status'] == 'failed'), None)
if failed:
problem = f'{failed["kind"]} "{failed["title"]}": {failed.get("error") or ""}'
else:
problem = entry.get('reason') if entry['status'] != 'passed' else ''
if (problem or '').strip():
lines.append(f' {problem.strip().splitlines()[0][:160]}')
return '\n'.join(lines)
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--plan', help='scenarios.json')
ap.add_argument('--rerun', help='"last", or an earlier run folder. Uses the current plan and scripts when they exist.')
ap.add_argument('--only', help='Comma-separated scenario ids.')
ap.add_argument('--run-dir')
ap.add_argument('--concurrency', type=int, default=int(setting('FEATURE_TEST_CONCURRENCY', default='4')))
ap.add_argument('--branch-url', help='Use this running instance for the branch runs instead of fresh ones.')
ap.add_argument('--try', dest='try_mode', action='store_true',
help='A quick check of new scripts: no master, no live page, no usage, no report.')
ap.add_argument('--estimate', action='store_true', help='Print the expected cost and time, then stop.')
ap.add_argument('--check', action='store_true', help='Check the setup, then stop.')
ap.add_argument('--no-build', action='store_true', help='Do not build this checkout first.')
ap.add_argument('--no-baseline', action='store_true', help='Skip master. Visual scenarios run on the branch only.')
ap.add_argument('--no-cache', action='store_true', help='Run master again, also when a kept master run exists.')
ap.add_argument('--no-fetch', action='store_true', help='Use origin/master as it is, without git fetch.')
ap.add_argument('--no-live', action='store_true', help='Do not start the live page.')
ap.add_argument('--no-open', action='store_true',
help='Do not open the live page in the default browser. Use this when you open it in the editor browser.')
ap.add_argument('--keep-instances', action='store_true', help='Keep the fresh instances running after the run.')
args = ap.parse_args()
if args.try_mode:
args.no_baseline = args.no_live = True
build = not args.no_build and not args.branch_url
if args.check and not (args.plan or args.rerun):
errors, warnings = preflight(build, need_master=True)
for w in warnings:
say(f'warning: {w}')
for e in errors:
say(f'error: {e}')
branch = git('rev-parse', '--abbrev-ref', 'HEAD').replace('/', '-')
say(f'plan folder for this branch: {PLANS_ROOT / branch}//')
say('The setup is ready.' if not errors else 'Fix the errors, then run again.')
sys.exit(1 if errors else 0)
if not (args.plan or args.rerun):
sys.exit('Give --plan , --rerun last|, or --check.')
# The fresh instances run in their own sessions. Exit through the cleanup, so they stop too.
for sig in (signal.SIGTERM, signal.SIGHUP):
signal.signal(sig, lambda signum, _frame: sys.exit(128 + signum))
plan = load_plan(rerun_plan(args.rerun) if args.rerun else args.plan)
if args.only:
wanted = {x.strip() for x in args.only.split(',')}
plan['scenarios'] = [s for s in plan['scenarios'] if s['id'] in wanted]
if not plan['scenarios']:
sys.exit(f'No scenario matches --only {args.only}.')
visual = [s for s in plan['scenarios'] if s['kind'] == 'visual']
compare_master = bool(visual) and not args.no_baseline
jobs = [] # (name, scenario, instance role)
for s in plan['scenarios']:
if args.branch_url:
role = 'branch'
elif s['kind'] == 'visual':
role = 'branch-visual'
else:
role = 'isolated' if s.get('isolated', True) else 'branch'
jobs.append((s['id'], s, role))
if s['kind'] == 'visual' and compare_master:
jobs.append((f'{s["id"]}--master', s, 'master'))
est = estimate(jobs, args.concurrency, compare_master, plan.get('featureFlags'))
print_estimate(est)
if args.estimate:
return
errors, warnings = preflight(build, compare_master, local_instances=not args.branch_url)
for w in warnings:
say(f'warning: {w}')
if errors:
sys.exit('The setup is not ready:\n ' + '\n '.join(errors))
if args.run_dir:
run_dir = Path(args.run_dir).expanduser().resolve()
run_dir.mkdir(parents=True, exist_ok=True)
else:
run_dir = new_run_dir(RUNS_ROOT / 'try' if args.try_mode else RUNS_ROOT, plan['name'])
copy_plan(plan, run_dir)
say(f'run directory: {run_dir}')
branch_commit = subprocess.run(['git', 'rev-parse', '--short', 'HEAD'], cwd=REPO_ROOT, capture_output=True,
text=True).stdout.strip()
dirty = bool(subprocess.run(['git', 'status', '--porcelain', '--', 'packages'], cwd=REPO_ROOT,
capture_output=True, text=True).stdout.strip())
live = live_enabled() and not args.no_live
progress = Progress(run_dir, {
'phase': 'running', 'step': 'Building the branch' if build else 'Starting the instances',
'startedAt': time.time(), 'concurrency': args.concurrency,
'branch': {'commit': branch_commit, 'dirty': dirty, 'url': args.branch_url},
'master': {'commit': None} if compare_master else None,
'runs': {name: {'status': 'waiting', 'scenario': s['id'], 'instance': role.split('-')[0]}
for name, s, role in jobs},
})
live_url = None
if live:
live_url = start_live_server(run_dir)
if live_url:
say(f'live page: {live_url}/report/live')
if not args.no_open and not open_in_default_browser(f'{live_url}/report/live'):
say(f'no browser opened. Open {live_url}/report/live to watch the runs.')
else:
say(f'the live page did not start, see {run_dir / "live-server.log"}')
instances, lock = [], threading.Lock()
keep = args.keep_instances
pool = ThreadPoolExecutor(max_workers=max(1, args.concurrency))
# Shared instances start at once. Isolated instances start ahead of their scenario, with at most one
# more alive than the number of scenarios that run at the same time.
shared_pool = ThreadPoolExecutor(max_workers=3)
boot_pool = ThreadPoolExecutor(max_workers=args.concurrency + 1)
alive = threading.BoundedSemaphore(args.concurrency + 1)
futures, shared = [], {}
def start_instance(name, root):
inst = Instance(name, root, run_dir / 'instances' / name, run_dir / 'instances' / f'{name}.log',
plan.get('featureFlags'))
with lock:
instances.append(inst)
started = time.time()
inst.start()
# The port only: Cursor opens an editor tab for each new localhost URL that a command prints.
say(f'{name} instance ready in {time.time() - started:.0f}s on port {inst.port}')
return inst
def boot_isolated(name):
alive.acquire()
try:
return start_instance(name, REPO_ROOT)
except Exception:
alive.release()
raise
def run_on(name, scenario, role, instance):
"""Run one job when its instance is ready. instance is a future of an Instance, or a URL."""
progress.run(name, status='starting')
try:
inst = instance.result() if hasattr(instance, 'result') else None
except Exception as e:
progress.run(name, status='error', finishedAt=time.time(), reason=f'The instance did not start: {e}')
return
url = inst.url if inst else instance
try:
run_job(name, scenario, url, role.split('-')[0], run_dir, progress, live)
if role == 'master' and not args.no_cache:
save_master_run(run_dir / 'runs' / name,
master_key(scenario, progress.data['master']['commit'], plan.get('featureFlags')))
finally:
if role == 'isolated':
if not keep:
inst.stop(clean=True)
alive.release()
def submit(name, scenario, role, instance):
futures.append(pool.submit(run_on, name, scenario, role, instance))
def skip(role, reason):
for name, _, r in jobs:
if r == role and progress.data['runs'][name]['status'] == 'waiting':
progress.run(name, status='skipped', reason=reason)
master_jobs = [(name, s) for name, s, r in jobs if r == 'master']
base_commit = None
if compare_master:
try:
base_commit = baseline.target(fetch=not args.no_fetch)
except subprocess.CalledProcessError as e:
say(f'cannot find the master merge base: {e}', 'master')
if base_commit:
progress.update(master={'commit': base_commit})
if not args.no_cache:
left = []
for name, s in master_jobs:
if reuse_master_run(run_dir / 'runs' / name, master_key(s, base_commit, plan.get('featureFlags'))):
now = time.time()
progress.run(name, status='passed', startedAt=now, finishedAt=now)
say(f'master run kept from an earlier run of {base_commit[:11]}', name)
else:
left.append((name, s))
master_jobs = left
def master_side():
try:
built = baseline.prepare(fetch=False, say=lambda m: say(m, 'master'))
progress.update(master={'commit': built['commit']})
inst = start_instance('master', built['root'])
for name, s in master_jobs:
submit(name, s, 'master', inst.url)
except Exception as e:
say(f'master is not available: {e}', 'master')
skip('master', f'Master is not available: {e}')
visual_error = None
try:
if build:
step('Build this checkout')
build_branch(run_dir / 'build.log')
elif not args.branch_url:
stale = stale_packages()
if stale:
say('warning: these packages have source changes that are newer than their build: '
f'{", ".join(stale)}. The fresh instances run the old build.')
progress.update(stale=stale)
step(f'Run {len(jobs)} scenario runs, {args.concurrency} at a time')
progress.update(step='Running the scenarios')
for role in ('branch', 'branch-visual'):
if any(r == role for _, _, r in jobs):
shared[role] = args.branch_url or shared_pool.submit(start_instance, role, REPO_ROOT)
boots = {name: boot_pool.submit(boot_isolated, name) for name, _, r in jobs if r == 'isolated'}
master_thread = None
if compare_master and master_jobs and base_commit:
master_thread = threading.Thread(target=master_side, daemon=True)
master_thread.start()
elif compare_master and not base_commit:
skip('master', 'The master merge base is not known.')
for name, s, role in jobs:
if role == 'isolated':
submit(name, s, role, boots[name])
elif role in shared:
submit(name, s, role, shared[role])
if master_thread:
master_thread.join()
# Jobs can be added while the pool runs, so wait until the list stops growing.
while any(not f.done() for f in list(futures)):
time.sleep(1)
for f in futures:
if f.exception():
say(f'a run failed: {f.exception()}')
pool.shutdown()
if not keep:
for inst in instances:
inst.stop(clean=True)
names = [name for name, _, _ in jobs]
if compare_master:
step('Compare the visual captures with master')
progress.update(step='Comparing the captures with master')
diff = subprocess.run(['node', 'visual_diff.mjs', str(run_dir)], cwd=HERE)
if diff.returncode != 0:
visual_error = f'visual_diff.mjs failed with exit code {diff.returncode}. The visual check is not complete.'
say(f'error: {visual_error}')
progress.update(visualError=visual_error)
trees = [r for r in compare_trees(run_dir) if r.get('ariaDiff')]
if trees:
say(f'{len(trees)} captures with a difference in the accessibility tree: '
+ ', '.join(f'{r["scenario"]}/{r["name"]}' for r in trees))
step('Summarize the runs for the judge')
summary = report.summarize(run_dir)
if visual_error:
summary = f'VISUAL CHECK FAILED: {visual_error}\n\n{summary}'
(run_dir / 'summary.txt').write_text(summary)
if not args.try_mode:
step('Build the report')
progress.update(step='Building the report')
report.build(run_dir)
progress.finish()
finally:
pool.shutdown(wait=False, cancel_futures=True)
boot_pool.shutdown(wait=False, cancel_futures=True)
shared_pool.shutdown(wait=False, cancel_futures=True)
for proc in list(RUNNERS):
proc.kill()
if not keep:
for inst in instances:
inst.stop(clean=True)
progress.finish()
runs = progress.data['runs']
visual_results = json.loads((run_dir / 'visual.json').read_text()) if (run_dir / 'visual.json').exists() else []
not_passed = [n for n, e in runs.items() if e['status'] != 'passed' and not n.endswith('--master')]
rerun = f'python3 {S}/run.py --rerun {run_dir}' + (f' --only {",".join(not_passed)}' if not_passed else '')
usage_later = not args.try_mode and bool(setting('LANGSMITH_API_KEY'))
say('\n=== Result ===')
say(result_table(run_dir, runs))
if not_passed:
say(f'Run the ones that did not pass again: {rerun}')
print(json.dumps({
'runDir': str(run_dir),
'report': None if args.try_mode else str(run_dir / 'report' / 'index.html'),
'live': f'{live_url}/report/index.html' if live_url else None,
'summary': str(run_dir / 'summary.txt'),
'results': {name: runs[name]['status'] for name in runs},
'visual': {'captures': len(visual_results),
'changed': [f'{r["scenario"]}/{r["name"]}' for r in visual_results if r['status'] != 'unchanged'],
'error': visual_error},
'instancePorts': {i.name: i.port for i in instances} if keep else None,
'rerun': rerun,
'usage': 'The token usage comes in the next lines. The report updates when it is done.' if usage_later else None,
'next': ('Fix the scripts that failed, then run --try again, or run the full plan.' if args.try_mode else
'Judge each run from summary.txt and write verdicts.json. The live page rebuilds the report.'),
}, indent=2), flush=True)
if usage_later:
step('Fetch the token usage from LangSmith')
fetch_usage(run_dir, [n for n in runs])
report.build(run_dir)
say('usage: done, the report is updated')
if __name__ == '__main__':
main()