xref: /trunk/test/odf-validate/gate.py (revision 0e16a31d78a652c56c5c0fc79c752f48b9048df9)
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