91008f116877a4ac9de458dec4abb98ca3249dbf braney Wed Sep 23 17:15:55 2026 -0700 docent status page: try-it links for sessions loaded by URL, none on hgwbeta for named ones, refs #38252 A named session lives in one machine's hgcentral, so its hgwbeta link only answered "Could not find session"; those rows now offer genome-test alone and say why. A session loaded from a text file by URL works on any server, so a script that loads one now gets a link, with both servers. diff --git src/hg/utils/docent/tests/regress/nightlyStatus.py src/hg/utils/docent/tests/regress/nightlyStatus.py index 1bf1cd7d074..686ebe6135e 100755 --- src/hg/utils/docent/tests/regress/nightlyStatus.py +++ src/hg/utils/docent/tests/regress/nightlyStatus.py @@ -1,435 +1,447 @@ #!/usr/bin/env python3 """Write the Docent nightly status page. refs #38252 nightly.sh runs this after the tests. It reads what the job already keeps: the nightly logs (one per run, named YYYY-MM-DD_HHMM.txt), flips.log, and each committed script's proof: lines. It writes one static page with a row per script, anchored by the script's name, so a ticket comment can link to #rm38236. Each row carries a reproduce-it-yourself link: the script's steps, as far as one URL can rebuild them. track: steps are resolved by docent.js's own DOCENT_DERIVE mode, so the URL turns on the same containers the script does. Every link is fetched from genome-test with a fresh cart before it is offered, and only a link that draws what its expect: step names is called verified. Nothing here decides pass or fail; the page only reports what the log says. A problem in this script must never change the night's verdict, so nightly.sh treats its exit status as a note in the mail and nothing more. """ import argparse import concurrent.futures import datetime import glob import html import json import os import re import subprocess import sys import urllib.parse import urllib.request import yaml HERE = os.path.dirname(os.path.abspath(__file__)) DOCENT = os.path.join(HERE, '..', '..', 'docent.js') PW = '/hive/groups/browser/uiTest/pw' GITHUB = 'https://github.com/ucscGenomeBrowser/kent' REDMINE = 'https://redmine.gi.ucsc.edu/issues' TEST = 'https://genome-test.gi.ucsc.edu' BETA = 'https://hgwbeta.soe.ucsc.edu' NIGHTS = 14 PIX = 'pix=1100' # nightly.sh writes this line into the log of every --update run, which is what cron runs. MARK = 'updated: this checkout was reset to origin/master' # Weakest first, the same order as proof.js. LEVELS = ['assertion-only', 'xfail', 'sandbox-ab', 'release-ab', 'server-flip', 'caught-regression'] PLAIN = { 'assertion-only': 'Checks the fixed behavior. Never seen to fail.', 'xfail': 'Fails now, as expected. The fix has not reached genome-test.', 'sandbox-ab': 'Seen to fail on a build without the fix, and pass with it.', 'release-ab': 'Seen to fail on the release before the fix, and pass on the release ' 'with it.', 'server-flip': 'Seen to fail on genome-test, then pass when the fix arrived.', 'caught-regression': 'Went red for a real regression, which was then fixed.', } # Verbs that change nothing a URL has to carry. PASSIVE = ('shot', 'caption', 'say', 'pause', 'wait', 'note', 'title', 'session') def parseLog(path): """Return (commit, {script: 'pass'|'fail'}, {script: text of its failure}).""" commit, res, text, cur, buf = None, {}, {}, None, [] def close(): if cur and res.get(cur) == 'fail': text[cur] = ''.join(buf) for line in open(path, errors='replace'): m = re.match(r'commit:\s+(\S+)', line) if m and commit is None: commit = m.group(1) m = re.match(r'=== (\S+)', line) if m: close() cur, buf = m.group(1), [] continue if cur is None: continue if line.startswith(('docent tests', 'make:', 'full log', '--- ')): close() cur = None continue buf.append(line) if cur not in res: if line.startswith(' ok'): res[cur] = 'pass' elif line.startswith(' FAILED'): res[cur] = 'fail' close() return commit, res, text def strongest(proofs): best = -1 for p in proofs or []: words = str(p).split() if words and words[0] in LEVELS: best = max(best, LEVELS.index(words[0])) return LEVELS[best] if best >= 0 else None def norm(step): if isinstance(step, str): verb, *rest = step.split() return verb, ' '.join(rest) if rest else True verb = next(iter(step)) return verb, step[verb] def enc(s): return urllib.parse.quote(str(s), safe=':,/') def derive(yamlPath): """{step number: {var: value}} for the script's track: steps, every round merged.""" env = dict(os.environ, DOCENT_DERIVE='1', NODE_PATH=PW + '/node_modules') out = subprocess.run(['node', DOCENT, yamlPath], env=env, capture_output=True, text=True, timeout=120).stdout steps, cur = {}, None for line in out.splitlines(): m = re.match(r'step (\d+) track ', line) if m: cur = int(m.group(1)) steps[cur] = {} continue m = re.match(r'\s+round \d+ \(\d+ vars\): (.*)', line) if m and cur: for kv in m.group(1).split(): k, _, v = kv.partition('=') steps[cur][k] = v return steps def urlOf(db, pos, base, cgiVars): v = dict(cgiVars) # hideTracks=1 in the same request as hubUrl hides the hub's own rows too, so a hub # keeps the default tracks rather than losing the ones the script is about. if 'hubUrl' in v: v.pop('hideTracks', None) if base is None: q = [f'db={enc(db)}'] + ([f'position={enc(pos)}'] if pos else []) base = '/cgi-bin/hgTracks?' + '&'.join(q) extra = '&'.join(f'{k}={enc(x)}' for k, x in v.items()) url = base + ('&' if '?' in base else '?') + extra if extra else base url = url.replace('&&', '&').rstrip('&') if url.startswith('/cgi-bin/hgTracks') and 'pix=' not in url: url += '&' + PIX return url def candidates(yamlPath, doc): """One URL per expect: step that a single request can reach, in step order.""" db = doc.get('db') or 'hg38' pos = doc.get('position') steps = doc.get('steps') or [] nExpect = sum(norm(s)[0] == 'expect' for s in steps) base, cgiVars, tracks, cands, seen, stop = None, {}, None, [], 0, None + session = False for i, s in enumerate(steps, 1): verb, arg = norm(s) if verb == 'expect': seen += 1 cands.append({'url': urlOf(db, pos, base, cgiVars), 'reached': seen, + 'session': session, 'expect': arg if isinstance(arg, dict) else {}}) continue if verb in PASSIVE: continue if verb == 'go': if arg is True: continue pos, base = arg, None elif verb == 'goto': base, cgiVars = str(arg), {} elif verb == 'hide' and arg in ('all', True): cgiVars['hideTracks'] = '1' elif verb == 'track' and isinstance(arg, dict): if tracks is None: tracks = derive(yamlPath) cgiVars.update(tracks.get(i, {})) elif verb == 'hub': o = arg if isinstance(arg, dict) else {'url': arg} cgiVars['hubUrl'] = o['url'] db = o.get('db', db) pos = o.get('position', pos) elif verb == 'loadSession' and isinstance(arg, dict) and arg.get('user'): cgiVars.update({'hgS_doOtherUser': 'submit', 'hgS_otherUserName': arg['user'], 'hgS_otherUserSessionName': arg.get('name')}) + session = True + elif verb == 'loadSession' and isinstance(arg, str) and re.match(r'https?://', arg): + # A session file by URL (sessionFiles/), which any server can load. + cgiVars.update({'hgS_doLoadUrl': 'submit', 'hgS_loadUrlName': arg}) else: stop = verb break for c in cands: c['total'], c['stop'] = nExpect, stop if not cands: return None, (f'"{stop}:" comes before the first check' if stop else 'it has no expect: step') return cands, None def verify(c): """Fetch with a fresh cart and look for what the expect: step names.""" exp = c.pop('expect') rows = exp.get('rows') or [] texts = [t for t in [exp.get('text')] if isinstance(t, str)] if not rows and not texts: c['ok'] = None return c try: page = urllib.request.urlopen(TEST + c['url'], timeout=90).read() page = page.decode('utf-8', 'replace') except Exception: c['ok'] = None return c drawn = set(re.findall(r"id=['\"]tr_([A-Za-z0-9_]+)", page)) missRow = [t for t in rows if not any(d == t or d.endswith('_' + t) for d in drawn)] missText = [t for t in texts if t not in page] c['ok'] = not missRow and not missText return c def repro(yamlPath, doc): """The deepest check one URL really reproduces, or the reason there is none.""" cands, why = candidates(yamlPath, doc) if cands is None: return {'reason': why} last = None for c in reversed(cands): last = verify(c) if last['ok'] is not False: return last return last def tryIt(name, r): e = html.escape if 'url' not in r: return f'Run the script: {e(r["reason"])}.' u = e(r['url']) + # A saved session lives in one machine's central, and hgwbeta reads a different one, + # so a link that loads one would only answer "Could not find session" there. + if r.get('session'): + links = f'genome-test (a saved ' \ + f'session, so not on hgwbeta)' + else: links = f'genome-test · hgwbeta' n, m = r['reached'], r['total'] if n == m: reach = f'Reaches the last check ({n} of {m}).' elif r.get('stop'): reach = f'Reaches check {n} of {m}. Then "{e(r["stop"])}:" needs the browser UI.' else: reach = f'Reaches check {n} of {m}. Later checks need more than one request.' if name.endswith('.xfail'): return f'{links}
Shows the open bug. The fix is not on ' \ f'genome-test yet.
' if r['ok'] is True: return f'{links}
{reach} Verified on genome-test.
' if r['ok'] is False: return 'Run the script: one URL does not rebuild what its ' \ 'steps do.' return f'{links}
{reach} Not verified: nothing a fetch can see.
' def render(scripts, days, lastRun, commit, mediaUrl): e = html.escape nPass = sum(s['state'] == 'pass' for s in scripts) nFail = sum(s['state'] == 'fail' for s in scripts) byLevel = ' · '.join(f'{l} {sum(s["level"] == l for s in scripts)}' for l in LEVELS) rows = [] for s in scripts: cells = ''.join( f'' for d, h in zip(days, s['hist'])) if s['fail']: state = f'FAIL' else: state = f'{e(s["state"])}' conf = PLAIN.get(s['level'], 'No proof line.') if s['flip']: conf += f' Flipped on genome-test {s["flip"]}.' proofs = ''.join(f'
  • {e(str(p))}
  • ' for p in s['proofs']) links = [f'script'] if s['media']: links.append(f'before/after') rows.append(f''' {s["name"]} # #{s["ticket"]} {state}{s["since"]} {cells} {e(s["level"] or "none")} {e(conf)}
    proof lines
    {tryIt(s["name"], s["repro"])} {" · ".join(links)}''') fails = ''.join(f'

    {s["name"]}, failing since {s["since"]}

    ' f'
    {e(s["fail"])}
    ' for s in scripts if s['fail']) made = datetime.datetime.now().strftime('%Y-%m-%d %H:%M') page = f''' Docent Nightly Status

    Docent Nightly Status

    Run of {lastRun} against genome-test, at commit {commit[:11]}. {len(scripts)} scripts: {nPass} pass, {nFail} fail.
    The squares are the last {len(days)} days, oldest on the left. A striped square is a night with no run.
    Confidence, the strongest proof: level per script: {byLevel}.
    Written {made} by nightlyStatus.py after the nightly run (refs #38252).
    {"".join(rows)}
    scriptticketstatesincelast {len(days)} days confidencetry itlinks

    Failures in the last run

    {fails or "

    None.

    "} ''' # Every link that leaves the page opens a new tab; the in-page row links do not. return re.sub(r'