1*0e16a31dSJim Jagielski#************************************************************** 2*0e16a31dSJim Jagielski# 3*0e16a31dSJim Jagielski# Licensed to the Apache Software Foundation (ASF) under one 4*0e16a31dSJim Jagielski# or more contributor license agreements. See the NOTICE file 5*0e16a31dSJim Jagielski# distributed with this work for additional information 6*0e16a31dSJim Jagielski# regarding copyright ownership. The ASF licenses this file 7*0e16a31dSJim Jagielski# to you under the Apache License, Version 2.0 (the 8*0e16a31dSJim Jagielski# "License"); you may not use this file except in compliance 9*0e16a31dSJim Jagielski# with the License. You may obtain a copy of the License at 10*0e16a31dSJim Jagielski# 11*0e16a31dSJim Jagielski# http://www.apache.org/licenses/LICENSE-2.0 12*0e16a31dSJim Jagielski# 13*0e16a31dSJim Jagielski# Unless required by applicable law or agreed to in writing, 14*0e16a31dSJim Jagielski# software distributed under the License is distributed on an 15*0e16a31dSJim Jagielski# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16*0e16a31dSJim Jagielski# KIND, either express or implied. See the License for the 17*0e16a31dSJim Jagielski# specific language governing permissions and limitations 18*0e16a31dSJim Jagielski# under the License. 19*0e16a31dSJim Jagielski# 20*0e16a31dSJim Jagielski#************************************************************** 21*0e16a31dSJim Jagielski 22*0e16a31dSJim Jagielski# usage: gate.py <tag> [--stages s,...] [--modes ext,strict] [--fixture-modes v00,v10,v11,ext,strict] [--prev DIR] [--jobs FILE] [--limit N] [--port P] 23*0e16a31dSJim Jagielski# [--build mod,...] [--libs lib.dylib,...] [--stall SECONDS] [--force] [--crosscheck] 24*0e16a31dSJim Jagielski# The corpus regression gate as one resumable run in runs/gate-<tag>/. Stages, in order: 25*0e16a31dSJim Jagielski# build rebuild the modules (--build) and deliver them to the solver 26*0e16a31dSJim Jagielski# refresh copy the rebuilt libraries (--libs) from the solver into the app copy and verify them 27*0e16a31dSJim Jagielski# corpus save the job list in ODF 1.2 Extended (ext) and strict (strict) mode with the app copy 28*0e16a31dSJim Jagielski# validate validate.py and the checkers on the saved packages; kinds compared with baseline/ and --prev; 29*0e16a31dSJim Jagielski# a checker that did not run, a shall finding of idcheck/pkgcheck/doctypecheck, or more formula findings than --prev fails 30*0e16a31dSJim Jagielski# dump reload every saved package and dump a fixed property set (dump7.py), with a stall watchdog 31*0e16a31dSJim Jagielski# diff dumps (diff7.py) and packages (cmp7.py) against --prev; differences not in gate-volatile.tsv are unexplained 32*0e16a31dSJim Jagielski# fixtures gate-fixtures.tsv saved in five version modes, reload checks, validation; results against --prev 33*0e16a31dSJim Jagielski# --jobs freezes the inputs (source<TAB>output lines; outputs are rewritten); default: --prev's list, else make-jobs.sh. 34*0e16a31dSJim Jagielski# Exit status 0 only if every stage that ran passed. 35*0e16a31dSJim Jagielskiimport argparse, collections, json, os, re, shutil, socket, subprocess, sys, threading, time 36*0e16a31dSJim Jagielski 37*0e16a31dSJim JagielskiD = os.path.dirname(os.path.abspath(__file__)) 38*0e16a31dSJim JagielskiPY = [sys.executable, "-I"] 39*0e16a31dSJim JagielskiSTAGES = ["build", "refresh", "corpus", "validate", "dump", "diff", "fixtures"] 40*0e16a31dSJim JagielskiFIX_MODES = ["v00", "v10", "v11", "ext", "strict"] 41*0e16a31dSJim JagielskiDEFAULT_LIBS = "libxo.dylib,libsw.dylib,libsc.dylib,libsd.dylib,libsfx.dylib" 42*0e16a31dSJim Jagielski 43*0e16a31dSJim Jagielski 44*0e16a31dSJim Jagielskidef listening(port): 45*0e16a31dSJim Jagielski with socket.socket() as s: 46*0e16a31dSJim Jagielski s.settimeout(0.5) 47*0e16a31dSJim Jagielski return s.connect_ex(("localhost", port)) == 0 48*0e16a31dSJim Jagielski 49*0e16a31dSJim Jagielski 50*0e16a31dSJim Jagielskidef kill_office(profile): 51*0e16a31dSJim Jagielski subprocess.run(["pkill", "-f", "profile_" + profile], capture_output=True) 52*0e16a31dSJim Jagielski time.sleep(2) 53*0e16a31dSJim Jagielski 54*0e16a31dSJim Jagielski 55*0e16a31dSJim Jagielskidef run(cmd, log=None, timeout=None, cwd=None, env=None): 56*0e16a31dSJim Jagielski """Runs cmd, appending its output to log; returns the exit code (124 on timeout).""" 57*0e16a31dSJim Jagielski out = open(log, "a") if log else subprocess.DEVNULL 58*0e16a31dSJim Jagielski try: 59*0e16a31dSJim Jagielski return subprocess.run(cmd, stdout=out, stderr=subprocess.STDOUT, timeout=timeout, cwd=cwd, env=env).returncode 60*0e16a31dSJim Jagielski except subprocess.TimeoutExpired: 61*0e16a31dSJim Jagielski return 124 62*0e16a31dSJim Jagielski finally: 63*0e16a31dSJim Jagielski if log: 64*0e16a31dSJim Jagielski out.close() 65*0e16a31dSJim Jagielski 66*0e16a31dSJim Jagielski 67*0e16a31dSJim Jagielskidef read_jobs(path): 68*0e16a31dSJim Jagielski return [tuple(l.rstrip("\n").split("\t")) for l in open(path) if l.strip()] 69*0e16a31dSJim Jagielski 70*0e16a31dSJim Jagielski 71*0e16a31dSJim Jagielskidef rewrite_jobs(jobs, outdir): 72*0e16a31dSJim Jagielski """Same sources, outputs moved into outdir.""" 73*0e16a31dSJim Jagielski return [(src, os.path.join(outdir, os.path.basename(out))) for src, out in jobs] 74*0e16a31dSJim Jagielski 75*0e16a31dSJim Jagielski 76*0e16a31dSJim Jagielskidef read_volatile(path=os.path.join(D, "gate-volatile.tsv")): 77*0e16a31dSJim Jagielski return {l.split("\t")[0]: l.rstrip("\n").split("\t")[1] for l in open(path) if l.strip() and not l.startswith("#")} 78*0e16a31dSJim Jagielski 79*0e16a31dSJim Jagielski 80*0e16a31dSJim Jagielskidef classify(diff, volatile): 81*0e16a31dSJim Jagielski """diff: diff7.py's result; returns (identical, volatile differing, unexplained differing).""" 82*0e16a31dSJim Jagielski differing = sorted(k for k, v in diff.items() if v["ndiff"]) 83*0e16a31dSJim Jagielski vol = [k for k in differing if k in volatile] 84*0e16a31dSJim Jagielski return len(diff) - len(differing), vol, [k for k in differing if k not in volatile] 85*0e16a31dSJim Jagielski 86*0e16a31dSJim Jagielski 87*0e16a31dSJim Jagielskidef run_with_stall(cmd, progress, stall, poll=2.0, log=None): 88*0e16a31dSJim Jagielski """Runs cmd until it exits or progress() has not changed for `stall` seconds (then kills it). 89*0e16a31dSJim Jagielski Returns ('exit', returncode) or ('stalled', None).""" 90*0e16a31dSJim Jagielski out = open(log, "a") if log else subprocess.DEVNULL 91*0e16a31dSJim Jagielski p = subprocess.Popen(cmd, stdout=out, stderr=subprocess.STDOUT) 92*0e16a31dSJim Jagielski last, since = progress(), time.time() 93*0e16a31dSJim Jagielski try: 94*0e16a31dSJim Jagielski while p.poll() is None: 95*0e16a31dSJim Jagielski time.sleep(poll) 96*0e16a31dSJim Jagielski now = progress() 97*0e16a31dSJim Jagielski if now != last: 98*0e16a31dSJim Jagielski last, since = now, time.time() 99*0e16a31dSJim Jagielski elif time.time() - since > stall: 100*0e16a31dSJim Jagielski p.kill(); p.wait() 101*0e16a31dSJim Jagielski return "stalled", None 102*0e16a31dSJim Jagielski return "exit", p.returncode 103*0e16a31dSJim Jagielski finally: 104*0e16a31dSJim Jagielski if log: 105*0e16a31dSJim Jagielski out.close() 106*0e16a31dSJim Jagielski 107*0e16a31dSJim Jagielski 108*0e16a31dSJim Jagielskidef bridge_dead(path): 109*0e16a31dSJim Jagielski """True for a dump that only records that the office died under an earlier document.""" 110*0e16a31dSJim Jagielski try: 111*0e16a31dSJim Jagielski d = json.load(open(path)) 112*0e16a31dSJim Jagielski except Exception: 113*0e16a31dSJim Jagielski return False 114*0e16a31dSJim Jagielski return isinstance(d, dict) and (str(d.get("error", "")).startswith("Binary URP bridge") or str(d.get("body_error", "")).startswith("Binary URP bridge")) 115*0e16a31dSJim Jagielski 116*0e16a31dSJim Jagielski 117*0e16a31dSJim Jagielskidef dump_dir(src, dest, profile, port, stall, log, tool=None, poll=2.0): 118*0e16a31dSJim Jagielski """Reloads every package in src into dest/*.json. A document that stalls the office or kills it gets an error 119*0e16a31dSJim Jagielski marker only after `stall` seconds without progress, never because the whole batch took long. 120*0e16a31dSJim Jagielski tool(port, dest, files) -> argv replaces dump7.py under the office (for tests); then no office is started.""" 121*0e16a31dSJim Jagielski os.makedirs(dest, exist_ok=True) 122*0e16a31dSJim Jagielski files = sorted(os.path.join(src, f) for f in os.listdir(src) if not f.startswith(".")) 123*0e16a31dSJim Jagielski count = lambda: len([f for f in os.listdir(dest) if f.endswith(".json")]) 124*0e16a31dSJim Jagielski stalls = 0 125*0e16a31dSJim Jagielski retried = 0 126*0e16a31dSJim Jagielski for attempt in range(4): 127*0e16a31dSJim Jagielski while count() < len(files): 128*0e16a31dSJim Jagielski if tool is None and not listening(port): 129*0e16a31dSJim Jagielski if run([os.path.join(D, "start.sh"), profile, str(port)], log) != 0: 130*0e16a31dSJim Jagielski return {"error": "office did not start", "stalls": stalls} 131*0e16a31dSJim Jagielski before = count() 132*0e16a31dSJim Jagielski marker = os.path.join(dest, ".current") 133*0e16a31dSJim Jagielski argv = tool(port, dest, files) if tool else [os.path.join(D, "runpy.sh"), os.path.join(D, "dump7.py"), str(port), dest] + files 134*0e16a31dSJim Jagielski how, rc = run_with_stall(argv, lambda: (count(), os.path.getmtime(marker) if os.path.exists(marker) else 0), stall, poll, log=log) 135*0e16a31dSJim Jagielski if how == "stalled" or rc != 0: 136*0e16a31dSJim Jagielski cur = open(marker).read().strip() if os.path.exists(marker) else "" 137*0e16a31dSJim Jagielski if cur and not os.path.exists(os.path.join(dest, os.path.basename(cur) + ".json")): 138*0e16a31dSJim Jagielski reason = ("HANG: no progress for %ds" % stall) if how == "stalled" else "CRASH: rc=%s" % rc 139*0e16a31dSJim Jagielski json.dump({"error": reason}, open(os.path.join(dest, os.path.basename(cur) + ".json"), "w")) 140*0e16a31dSJim Jagielski stalls += 1 141*0e16a31dSJim Jagielski if tool is None: 142*0e16a31dSJim Jagielski kill_office(profile) 143*0e16a31dSJim Jagielski elif count() == before: 144*0e16a31dSJim Jagielski return {"error": "dump7.py made no progress", "stalls": stalls} 145*0e16a31dSJim Jagielski # dump7.py records "bridge disposed" for every document after an office crash; those are not dumps, so redo them 146*0e16a31dSJim Jagielski gone = [f for f in os.listdir(dest) if f.endswith(".json") and bridge_dead(os.path.join(dest, f))] 147*0e16a31dSJim Jagielski if not gone or attempt == 3: 148*0e16a31dSJim Jagielski break 149*0e16a31dSJim Jagielski if tool is None: 150*0e16a31dSJim Jagielski kill_office(profile) 151*0e16a31dSJim Jagielski for f in gone: 152*0e16a31dSJim Jagielski os.remove(os.path.join(dest, f)) 153*0e16a31dSJim Jagielski retried += len(gone) 154*0e16a31dSJim Jagielski if tool is None: 155*0e16a31dSJim Jagielski kill_office(profile) 156*0e16a31dSJim Jagielski res = {"documents": count(), "stalls": stalls} 157*0e16a31dSJim Jagielski if retried: 158*0e16a31dSJim Jagielski res["bridge_retried"] = retried 159*0e16a31dSJim Jagielski return res 160*0e16a31dSJim Jagielski 161*0e16a31dSJim Jagielski 162*0e16a31dSJim Jagielskidef count_done(done): 163*0e16a31dSJim Jagielski ok = fail = 0 164*0e16a31dSJim Jagielski if os.path.exists(done): 165*0e16a31dSJim Jagielski for l in open(done): 166*0e16a31dSJim Jagielski ok += l.rstrip("\n").endswith("\tOK") 167*0e16a31dSJim Jagielski fail += "\tFAIL" in l 168*0e16a31dSJim Jagielski return ok, fail 169*0e16a31dSJim Jagielski 170*0e16a31dSJim Jagielski 171*0e16a31dSJim Jagielskiclass Gate: 172*0e16a31dSJim Jagielski def __init__(self, a): 173*0e16a31dSJim Jagielski self.a = a 174*0e16a31dSJim Jagielski self.run = os.path.join(D, "runs", "gate-" + a.tag) 175*0e16a31dSJim Jagielski os.makedirs(self.run, exist_ok=True) 176*0e16a31dSJim Jagielski self.res_path = os.path.join(self.run, "results.json") 177*0e16a31dSJim Jagielski self.res = json.load(open(self.res_path)) if os.path.exists(self.res_path) else {} 178*0e16a31dSJim Jagielski self.modes = a.modes.split(",") 179*0e16a31dSJim Jagielski self.fix_modes = a.fixture_modes.split(",") 180*0e16a31dSJim Jagielski self.prev = os.path.abspath(a.prev) if a.prev else None 181*0e16a31dSJim Jagielski 182*0e16a31dSJim Jagielski def save(self): 183*0e16a31dSJim Jagielski json.dump(self.res, open(self.res_path, "w"), indent=1) 184*0e16a31dSJim Jagielski 185*0e16a31dSJim Jagielski def log(self, name): 186*0e16a31dSJim Jagielski return os.path.join(self.run, name + ".log") 187*0e16a31dSJim Jagielski 188*0e16a31dSJim Jagielski def prev_path(self, *candidates): 189*0e16a31dSJim Jagielski for c in candidates: 190*0e16a31dSJim Jagielski p = os.path.join(self.prev, c) if self.prev else None 191*0e16a31dSJim Jagielski if p and os.path.exists(p): 192*0e16a31dSJim Jagielski return p 193*0e16a31dSJim Jagielski return None 194*0e16a31dSJim Jagielski 195*0e16a31dSJim Jagielski # ---- stages 196*0e16a31dSJim Jagielski def build(self): 197*0e16a31dSJim Jagielski mods = [m for m in self.a.build.split(",") if m] 198*0e16a31dSJim Jagielski if not mods: 199*0e16a31dSJim Jagielski return {"skipped": "no --build modules"} 200*0e16a31dSJim Jagielski env = os.environ.get("ODFV_BUILD_ENV") or sorted(__import__("glob").glob(os.path.join(D, "..", "..", "main", "*Env.Set.sh")))[-1] 201*0e16a31dSJim Jagielski res = {} 202*0e16a31dSJim Jagielski for m in mods: 203*0e16a31dSJim Jagielski cmd = "cd %s/../../main && source %s && cd %s && perl $SOLARENV/bin/build.pl && perl $SOLARENV/bin/deliver.pl" % (D, env, m) 204*0e16a31dSJim Jagielski res[m] = run(["/bin/zsh", "-c", cmd], self.log("build_" + m)) 205*0e16a31dSJim Jagielski return {"ok": all(v == 0 for v in res.values()), "rc": res} 206*0e16a31dSJim Jagielski 207*0e16a31dSJim Jagielski def refresh(self): 208*0e16a31dSJim Jagielski libs = [l for l in self.a.libs.split(",") if l] 209*0e16a31dSJim Jagielski out = subprocess.run([os.path.join(D, "refresh-app.sh")] + libs, capture_output=True, text=True) 210*0e16a31dSJim Jagielski return {"ok": out.returncode == 0, "output": out.stdout.strip().splitlines()} 211*0e16a31dSJim Jagielski 212*0e16a31dSJim Jagielski def jobs(self): 213*0e16a31dSJim Jagielski if self.a.jobs: 214*0e16a31dSJim Jagielski return read_jobs(self.a.jobs) 215*0e16a31dSJim Jagielski pj = self.prev_path("jobs.tsv", "ext/jobs.tsv") 216*0e16a31dSJim Jagielski if pj: 217*0e16a31dSJim Jagielski return read_jobs(pj) 218*0e16a31dSJim Jagielski out = subprocess.run([os.path.join(D, "make-jobs.sh"), "OUT"], capture_output=True, text=True, check=True).stdout 219*0e16a31dSJim Jagielski return [tuple(l.split("\t")) for l in out.splitlines()] 220*0e16a31dSJim Jagielski 221*0e16a31dSJim Jagielski def corpus(self): 222*0e16a31dSJim Jagielski jobs = self.jobs() 223*0e16a31dSJim Jagielski if self.a.limit: 224*0e16a31dSJim Jagielski jobs = jobs[:self.a.limit] 225*0e16a31dSJim Jagielski with open(os.path.join(self.run, "jobs.tsv"), "w") as f: 226*0e16a31dSJim Jagielski f.writelines("%s\t%s\n" % j for j in jobs) 227*0e16a31dSJim Jagielski procs, res = {}, {} 228*0e16a31dSJim Jagielski for i, mode in enumerate(self.modes): 229*0e16a31dSJim Jagielski md = os.path.join(self.run, mode) 230*0e16a31dSJim Jagielski os.makedirs(os.path.join(md, "out"), exist_ok=True) 231*0e16a31dSJim Jagielski with open(os.path.join(md, "jobs.tsv"), "w") as f: 232*0e16a31dSJim Jagielski f.writelines("%s\t%s\n" % j for j in rewrite_jobs(jobs, os.path.join(md, "out"))) 233*0e16a31dSJim Jagielski open(os.path.join(md, "done.tsv"), "a").close() 234*0e16a31dSJim Jagielski procs[mode] = subprocess.Popen([os.path.join(D, "drive.sh"), mode, str(self.a.port + i), os.path.join(md, "jobs.tsv"), os.path.join(md, "done.tsv")]) 235*0e16a31dSJim Jagielski for mode, p in procs.items(): 236*0e16a31dSJim Jagielski p.wait() 237*0e16a31dSJim Jagielski md = os.path.join(self.run, mode) 238*0e16a31dSJim Jagielski ok, fail = count_done(os.path.join(md, "done.tsv")) 239*0e16a31dSJim Jagielski res[mode] = {"rc": p.returncode, "ok": ok, "fail": fail, "jobs": len(jobs), "packages": len(os.listdir(os.path.join(md, "out")))} 240*0e16a31dSJim Jagielski pd = self.prev_path(mode + "/done.tsv") 241*0e16a31dSJim Jagielski if pd and not self.a.limit: 242*0e16a31dSJim Jagielski res[mode]["prev_ok_fail"] = count_done(pd) 243*0e16a31dSJim Jagielski res["ok"] = all(r["rc"] == 0 and r["ok"] + r["fail"] == r["jobs"] and r["ok"] > 0 for r in res.values() if isinstance(r, dict)) 244*0e16a31dSJim Jagielski if not self.a.limit: 245*0e16a31dSJim Jagielski res["ok"] = res["ok"] and all(tuple(r["prev_ok_fail"]) == (r["ok"], r["fail"]) for r in res.values() if isinstance(r, dict) and "prev_ok_fail" in r) 246*0e16a31dSJim Jagielski return res 247*0e16a31dSJim Jagielski 248*0e16a31dSJim Jagielski def prev_validate(self, *keys): 249*0e16a31dSJim Jagielski """The validate-result dict of --prev at keys, or None when it has none.""" 250*0e16a31dSJim Jagielski pf = self.prev_path("results.json") 251*0e16a31dSJim Jagielski d = json.load(open(pf)) if pf else {} 252*0e16a31dSJim Jagielski for k in keys: 253*0e16a31dSJim Jagielski d = d.get(k) if isinstance(d, dict) else None 254*0e16a31dSJim Jagielski return d if isinstance(d, dict) else None 255*0e16a31dSJim Jagielski 256*0e16a31dSJim Jagielski def shall_problems(self, r, prev): 257*0e16a31dSJim Jagielski """Checker results that must fail the gate: a checker that did not run, a Part 1/3 finding, a formula finding that grew.""" 258*0e16a31dSJim Jagielski bad = [] 259*0e16a31dSJim Jagielski for n in ("ids", "pkg", "doctype", "formulas"): 260*0e16a31dSJim Jagielski if r.get("shall_" + n) is None: 261*0e16a31dSJim Jagielski bad.append("%s checker did not run" % n) 262*0e16a31dSJim Jagielski for n in ("ids", "pkg", "doctype"): 263*0e16a31dSJim Jagielski if r.get("shall_" + n): 264*0e16a31dSJim Jagielski bad.append("%d shall findings in %s" % (r["shall_" + n], n)) 265*0e16a31dSJim Jagielski pf = (prev or {}).get("shall_formulas") 266*0e16a31dSJim Jagielski if not self.a.limit and r.get("shall_formulas") is not None and pf is not None and r["shall_formulas"] > pf: 267*0e16a31dSJim Jagielski bad.append("shall_formulas %d > %d in --prev" % (r["shall_formulas"], pf)) 268*0e16a31dSJim Jagielski return bad 269*0e16a31dSJim Jagielski 270*0e16a31dSJim Jagielski def validate_dir(self, md, out, kinds_base, label): 271*0e16a31dSJim Jagielski """validate.py + report.py + the checkers on out/; returns a result dict.""" 272*0e16a31dSJim Jagielski r = {} 273*0e16a31dSJim Jagielski log = os.path.join(md, "validate.log") 274*0e16a31dSJim Jagielski run(PY + [os.path.join(D, "validate.py"), out, os.path.join(md, "val.json")], log) 275*0e16a31dSJim Jagielski val = json.load(open(os.path.join(md, "val.json"))) 276*0e16a31dSJim Jagielski r["errors"] = {v: len(e) for v, e in val["errors"].items()} 277*0e16a31dSJim Jagielski r["packages"] = len(val["packages"]) 278*0e16a31dSJim Jagielski for v in ("12", "14", "12x", "13", "13x"): 279*0e16a31dSJim Jagielski kp = os.path.join(md, "kinds%s.json" % ("" if v == "12" else v)) 280*0e16a31dSJim Jagielski run(PY + [os.path.join(D, "report.py"), os.path.join(md, "val.json"), kp, v], os.path.join(md, "report%s.log" % v)) 281*0e16a31dSJim Jagielski r["kinds" + v] = len(json.load(open(kp))) 282*0e16a31dSJim Jagielski for name, script, pat in (("ids", "idcheck.py", r"(\d+) errors"), ("pkg", "pkgcheck.py", r"(\d+) shall"), 283*0e16a31dSJim Jagielski ("doctype", "doctypecheck.py", r"(\d+) shall"), ("formulas", "formulacheck.py", r"(\d+) shall")): 284*0e16a31dSJim Jagielski o = subprocess.run(PY + [os.path.join(D, script), out, os.path.join(md, name + ".json")], capture_output=True, text=True) 285*0e16a31dSJim Jagielski m = re.search(pat, o.stdout) 286*0e16a31dSJim Jagielski r["shall_" + name] = int(m.group(1)) if m and o.returncode == 0 else None 287*0e16a31dSJim Jagielski base = kinds_base 288*0e16a31dSJim Jagielski if base and os.path.exists(base): 289*0e16a31dSJim Jagielski r["vs_baseline"] = run(PY + [os.path.join(D, "compare.py"), base, os.path.join(md, "kinds.json")], os.path.join(md, "compare_base.log")) 290*0e16a31dSJim Jagielski return r 291*0e16a31dSJim Jagielski 292*0e16a31dSJim Jagielski def validate(self): 293*0e16a31dSJim Jagielski res = {} 294*0e16a31dSJim Jagielski for mode in self.modes: 295*0e16a31dSJim Jagielski md = os.path.join(self.run, mode) 296*0e16a31dSJim Jagielski r = self.validate_dir(md, os.path.join(md, "out"), os.path.join(D, "baseline", mode + ".kinds.json"), mode) 297*0e16a31dSJim Jagielski pk = self.prev_path(mode + "/kinds.json") 298*0e16a31dSJim Jagielski r["problems"] = self.shall_problems(r, self.prev_validate("validate", mode)) 299*0e16a31dSJim Jagielski if not self.a.limit and not pk and "vs_baseline" not in r: 300*0e16a31dSJim Jagielski r["problems"].append("no baseline/%s.kinds.json and no --prev kinds to compare with" % mode) 301*0e16a31dSJim Jagielski if pk and not self.a.limit: 302*0e16a31dSJim Jagielski r["vs_prev"] = run(PY + [os.path.join(D, "compare.py"), pk, os.path.join(md, "kinds.json")], os.path.join(md, "compare_prev.log")) 303*0e16a31dSJim Jagielski r["vs_prev_reverse"] = run(PY + [os.path.join(D, "compare.py"), os.path.join(md, "kinds.json"), pk], os.path.join(md, "compare_prev_rev.log")) 304*0e16a31dSJim Jagielski res[mode] = r 305*0e16a31dSJim Jagielski res["ok"] = all(r.get("vs_baseline", 0) == 0 and r.get("vs_prev", 0) == 0 and r.get("vs_prev_reverse", 0) == 0 and not r["problems"] for r in res.values()) 306*0e16a31dSJim Jagielski return res 307*0e16a31dSJim Jagielski 308*0e16a31dSJim Jagielski def dump(self): 309*0e16a31dSJim Jagielski res, threads = {}, [] 310*0e16a31dSJim Jagielski for i, mode in enumerate(self.modes): 311*0e16a31dSJim Jagielski def one(mode=mode, i=i): 312*0e16a31dSJim Jagielski md = os.path.join(self.run, mode) 313*0e16a31dSJim Jagielski res[mode] = dump_dir(os.path.join(md, "out"), os.path.join(md, "dump"), "gate_dump_%s_%s" % (self.a.tag, mode), 314*0e16a31dSJim Jagielski self.a.port + 10 + i, self.a.stall, self.log("dump_" + mode)) 315*0e16a31dSJim Jagielski t = threading.Thread(target=one); t.start(); threads.append(t) 316*0e16a31dSJim Jagielski for t in threads: 317*0e16a31dSJim Jagielski t.join() 318*0e16a31dSJim Jagielski res["ok"] = all("error" not in r for r in res.values()) 319*0e16a31dSJim Jagielski for mode in self.modes: 320*0e16a31dSJim Jagielski md = os.path.join(self.run, mode, "dump") 321*0e16a31dSJim Jagielski res[mode]["bridge_errors"] = sorted(f for f in os.listdir(md) if f.endswith(".json") and bridge_dead(os.path.join(md, f))) 322*0e16a31dSJim Jagielski res["ok"] = res["ok"] and not res[mode]["bridge_errors"] 323*0e16a31dSJim Jagielski res[mode]["hang_markers"] = sorted(f for f in os.listdir(md) if f.endswith(".json") and "error" in json.load(open(os.path.join(md, f))) and 324*0e16a31dSJim Jagielski str(json.load(open(os.path.join(md, f)))["error"]).startswith(("HANG", "CRASH"))) 325*0e16a31dSJim Jagielski return res 326*0e16a31dSJim Jagielski 327*0e16a31dSJim Jagielski def diff(self): 328*0e16a31dSJim Jagielski if not self.prev: 329*0e16a31dSJim Jagielski return {"skipped": "no --prev"} 330*0e16a31dSJim Jagielski volatile, res = read_volatile(), {} 331*0e16a31dSJim Jagielski for mode in self.modes: 332*0e16a31dSJim Jagielski md = os.path.join(self.run, mode) 333*0e16a31dSJim Jagielski old = self.prev_path(mode + "/dump", "dump_" + mode) 334*0e16a31dSJim Jagielski oldrun = self.prev_path(mode + "/out") 335*0e16a31dSJim Jagielski r = {} 336*0e16a31dSJim Jagielski if old: 337*0e16a31dSJim Jagielski # compare only the documents this run saved (a --limit run saves fewer) 338*0e16a31dSJim Jagielski sub = os.path.join(md, "prev-dump") 339*0e16a31dSJim Jagielski shutil.rmtree(sub, ignore_errors=True); os.makedirs(sub) 340*0e16a31dSJim Jagielski for f in os.listdir(os.path.join(md, "dump")): 341*0e16a31dSJim Jagielski if f.endswith(".json") and os.path.exists(os.path.join(old, f)): 342*0e16a31dSJim Jagielski os.symlink(os.path.join(old, f), os.path.join(sub, f)) 343*0e16a31dSJim Jagielski dj = os.path.join(md, "diff_dump.json") 344*0e16a31dSJim Jagielski run(PY + [os.path.join(D, "diff7.py"), sub, os.path.join(md, "dump"), dj], os.path.join(md, "diff_dump.log")) 345*0e16a31dSJim Jagielski same, vol, unexplained = classify(json.load(open(dj)), volatile) 346*0e16a31dSJim Jagielski r["dump"] = {"compared": len(os.listdir(sub)), "identical": same, "volatile": vol, "unexplained": unexplained} 347*0e16a31dSJim Jagielski if oldrun: 348*0e16a31dSJim Jagielski stage = os.path.join(self.run, "cmp-" + mode) 349*0e16a31dSJim Jagielski shutil.rmtree(stage, ignore_errors=True); os.makedirs(os.path.join(stage, "out")) 350*0e16a31dSJim Jagielski for f in os.listdir(os.path.join(md, "out")): 351*0e16a31dSJim Jagielski if os.path.exists(os.path.join(oldrun, f)): 352*0e16a31dSJim Jagielski os.symlink(os.path.join(oldrun, f), os.path.join(stage, "out", f)) 353*0e16a31dSJim Jagielski # cmp7 reads <run>/out for both sides: the old side is the subset linked above, the new side this run 354*0e16a31dSJim Jagielski cj = os.path.join(md, "cmp7.json") 355*0e16a31dSJim Jagielski cp = subprocess.run(PY + [os.path.join(D, "cmp7.py"), stage, md, mode, cj], capture_output=True, text=True) 356*0e16a31dSJim Jagielski open(os.path.join(md, "cmp7.log"), "w").write(cp.stdout + cp.stderr) 357*0e16a31dSJim Jagielski m = re.search(r"differing after env masks: (\d+); differing after intended-edit normalisation: (\d+)", cp.stdout) 358*0e16a31dSJim Jagielski if m: 359*0e16a31dSJim Jagielski cmp = json.load(open(cj)) 360*0e16a31dSJim Jagielski differing = sorted(k for k, v in cmp.items() if v.get("norm_diff_streams") or v.get("names_diff") or v.get("missing_in_new")) 361*0e16a31dSJim Jagielski r["packages"] = {"after_masks": int(m.group(1)), "volatile": [k for k in differing if k in volatile], 362*0e16a31dSJim Jagielski "unexplained": [k for k in differing if k not in volatile]} 363*0e16a31dSJim Jagielski else: 364*0e16a31dSJim Jagielski r["packages"] = {"error": (cp.stderr or cp.stdout)[-200:], "unexplained": ["cmp7.py failed"]} 365*0e16a31dSJim Jagielski res[mode] = r 366*0e16a31dSJim Jagielski res["ok"] = all(("dump" not in r or not r["dump"]["unexplained"]) and ("packages" not in r or not r["packages"]["unexplained"]) for r in res.values()) 367*0e16a31dSJim Jagielski return res 368*0e16a31dSJim Jagielski 369*0e16a31dSJim Jagielski def fixtures(self): 370*0e16a31dSJim Jagielski entries = [l.rstrip("\n").split("\t") for l in open(os.path.join(D, "gate-fixtures.tsv")) if l.strip() and not l.startswith("#")] 371*0e16a31dSJim Jagielski def src_path(s): 372*0e16a31dSJim Jagielski return os.path.join(D, "..", "..", "main", s[4:]) if s.startswith("EXT:") else os.path.join(D, "fixtures", s) 373*0e16a31dSJim Jagielski def out_name(s): 374*0e16a31dSJim Jagielski b = os.path.splitext(os.path.basename(s))[0] 375*0e16a31dSJim Jagielski ext = {"doc": "odt", "docx": "odt", "odt": "odt", "sxw": "odt", "ods": "ods", "sxc": "ods", "odg": "odg", "sxd": "odg", "odp": "odp", "sxi": "odp"}[s.rsplit(".", 1)[1]] 376*0e16a31dSJim Jagielski return ("pf13-" if s.startswith("page-fill-1_3/") else "") + b + "." + ext 377*0e16a31dSJim Jagielski sources = sorted({e[0] for e in entries}) 378*0e16a31dSJim Jagielski res, threads = {}, [] 379*0e16a31dSJim Jagielski for i, mode in enumerate(self.fix_modes): 380*0e16a31dSJim Jagielski def one(mode=mode, i=i): 381*0e16a31dSJim Jagielski md = os.path.join(self.run, "fixtures", mode) 382*0e16a31dSJim Jagielski out = os.path.join(md, "out") 383*0e16a31dSJim Jagielski os.makedirs(out, exist_ok=True) 384*0e16a31dSJim Jagielski missing = [s for s in sources if not os.path.isfile(src_path(s))] 385*0e16a31dSJim Jagielski with open(os.path.join(md, "jobs.tsv"), "w") as f: 386*0e16a31dSJim Jagielski f.writelines("%s\t%s\n" % (os.path.abspath(src_path(s)), os.path.join(out, out_name(s))) for s in sources if s not in missing) 387*0e16a31dSJim Jagielski open(os.path.join(md, "done.tsv"), "a").close() 388*0e16a31dSJim Jagielski rc = run([os.path.join(D, "drive.sh"), mode, str(self.a.port + 20 + i), os.path.join(md, "jobs.tsv"), os.path.join(md, "done.tsv")]) 389*0e16a31dSJim Jagielski ok, fail = count_done(os.path.join(md, "done.tsv")) 390*0e16a31dSJim Jagielski r = {"drive_rc": rc, "saved": ok, "failed": fail, "missing": missing, "checks": {}} 391*0e16a31dSJim Jagielski prof, port = "gate_rt_%s_%s" % (self.a.tag, mode), self.a.port + 30 + i 392*0e16a31dSJim Jagielski run([os.path.join(D, "start.sh"), prof, str(port)], os.path.join(md, "rt.log")) 393*0e16a31dSJim Jagielski for s, ck in (e for e in entries if e[1] != "-"): 394*0e16a31dSJim Jagielski doc = os.path.join(out, out_name(s)) 395*0e16a31dSJim Jagielski key = "%s|%s" % (os.path.basename(s), ck) 396*0e16a31dSJim Jagielski if not os.path.isfile(doc): 397*0e16a31dSJim Jagielski r["checks"][key] = {"error": "no saved copy"}; continue 398*0e16a31dSJim Jagielski p = subprocess.run([os.path.join(D, "runpy.sh"), os.path.join(D, "roundtrip.py"), str(port), doc, os.path.join(D, "fixtures", ck)], capture_output=True, text=True, timeout=600) 399*0e16a31dSJim Jagielski m = re.search(r"roundtrip: (\d+)/(\d+) match", p.stdout) 400*0e16a31dSJim Jagielski r["checks"][key] = {"mismatches": sorted(re.findall(r"^MISMATCH (\S+?):", p.stdout, re.M)), "match": int(m.group(1)) if m else None, "total": int(m.group(2)) if m else None} 401*0e16a31dSJim Jagielski kill_office(prof) 402*0e16a31dSJim Jagielski r["validate"] = self.validate_dir(md, out, None, mode) 403*0e16a31dSJim Jagielski r["problems"] = self.shall_problems(r["validate"], self.prev_validate("fixtures", mode, "validate")) 404*0e16a31dSJim Jagielski res[mode] = r 405*0e16a31dSJim Jagielski t = threading.Thread(target=one); t.start(); threads.append(t) 406*0e16a31dSJim Jagielski for t in threads: 407*0e16a31dSJim Jagielski t.join() 408*0e16a31dSJim Jagielski pf = self.prev_path("results.json") 409*0e16a31dSJim Jagielski changed = [] 410*0e16a31dSJim Jagielski if pf: 411*0e16a31dSJim Jagielski prev = json.load(open(pf)).get("fixtures", {}) 412*0e16a31dSJim Jagielski for mode in self.fix_modes: 413*0e16a31dSJim Jagielski for k, c in res.get(mode, {}).get("checks", {}).items(): 414*0e16a31dSJim Jagielski pc = prev.get(mode, {}).get("checks", {}).get(k) 415*0e16a31dSJim Jagielski if pc is not None and pc != c: 416*0e16a31dSJim Jagielski changed.append("%s %s" % (mode, k)) 417*0e16a31dSJim Jagielski res["changed_vs_prev"] = changed if pf else "no previous fixtures results" 418*0e16a31dSJim Jagielski res["ok"] = all(res[m]["drive_rc"] == 0 and res[m]["failed"] == 0 and not res[m]["missing"] and 419*0e16a31dSJim Jagielski all("error" not in c and c["total"] for c in res[m]["checks"].values()) and not res[m]["problems"] for m in self.fix_modes) and not changed 420*0e16a31dSJim Jagielski return res 421*0e16a31dSJim Jagielski 422*0e16a31dSJim Jagielski 423*0e16a31dSJim Jagielskidef main(): 424*0e16a31dSJim Jagielski ap = argparse.ArgumentParser() 425*0e16a31dSJim Jagielski ap.add_argument("tag") 426*0e16a31dSJim Jagielski ap.add_argument("--stages", default="refresh,corpus,validate,dump,diff,fixtures") 427*0e16a31dSJim Jagielski ap.add_argument("--modes", default="ext,strict") 428*0e16a31dSJim Jagielski ap.add_argument("--fixture-modes", default=",".join(FIX_MODES)) 429*0e16a31dSJim Jagielski ap.add_argument("--prev") 430*0e16a31dSJim Jagielski ap.add_argument("--jobs") 431*0e16a31dSJim Jagielski ap.add_argument("--limit", type=int, default=0) 432*0e16a31dSJim Jagielski ap.add_argument("--port", type=int, default=2013) 433*0e16a31dSJim Jagielski ap.add_argument("--build", default="") 434*0e16a31dSJim Jagielski ap.add_argument("--libs", default=DEFAULT_LIBS) 435*0e16a31dSJim Jagielski ap.add_argument("--stall", type=int, default=300) 436*0e16a31dSJim Jagielski ap.add_argument("--force", action="store_true") 437*0e16a31dSJim Jagielski a = ap.parse_args() 438*0e16a31dSJim Jagielski g = Gate(a) 439*0e16a31dSJim Jagielski stages = [s for s in STAGES if s in a.stages.split(",") or (s == "build" and a.build)] 440*0e16a31dSJim Jagielski failed = [] 441*0e16a31dSJim Jagielski for s in stages: 442*0e16a31dSJim Jagielski if s in g.res and not a.force: 443*0e16a31dSJim Jagielski print("%-9s already done (--force to rerun)" % s) 444*0e16a31dSJim Jagielski else: 445*0e16a31dSJim Jagielski t0 = time.time() 446*0e16a31dSJim Jagielski g.res[s] = getattr(g, s)() 447*0e16a31dSJim Jagielski g.res[s]["seconds"] = int(time.time() - t0) 448*0e16a31dSJim Jagielski g.save() 449*0e16a31dSJim Jagielski ok = g.res[s].get("ok", True) 450*0e16a31dSJim Jagielski print("%-9s %s %s" % (s, "PASS" if ok else "FAIL", json.dumps({k: v for k, v in g.res[s].items() if k != "ok"})[:300])) 451*0e16a31dSJim Jagielski if not ok: 452*0e16a31dSJim Jagielski failed.append(s) 453*0e16a31dSJim Jagielski print("gate %s: %s" % (a.tag, "PASS" if not failed else "FAIL in " + ", ".join(failed))) 454*0e16a31dSJim Jagielski sys.exit(1 if failed else 0) 455*0e16a31dSJim Jagielski 456*0e16a31dSJim Jagielski 457*0e16a31dSJim Jagielskiif __name__ == "__main__": 458*0e16a31dSJim Jagielski main() 459