1################################################################ 2# 3# Licensed to the Apache Software Foundation (ASF) under one 4# or more contributor license agreements. See the NOTICE file 5# distributed with this work for additional information 6# regarding copyright ownership. The ASF licenses this file 7# to you under the Apache License, Version 2.0 (the 8# "License"); you may not use this file except in compliance 9# with the License. You may obtain a copy of the License at 10# 11# http://www.apache.org/licenses/LICENSE-2.0 12# 13# Unless required by applicable law or agreed to in writing, 14# software distributed under the License is distributed on an 15# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16# KIND, either express or implied. See the License for the 17# specific language governing permissions and limitations 18# under the License. 19 20"""The documents every save mode is run over. None of them is stored in this repository: they come from the office under 21test (the templates and documents its install ships under share/), from documents the office creates itself, and from a 22directory the user names with --documents.""" 23import json 24import os 25from collections import namedtuple 26from pathlib import Path 27 28from aootest import install 29from aootest.client import WorkerFailure, WorkerHang 30from aootest.paths import DATA 31 32# Source: id is the stable name used in test ids; path is None for a document the office creates (`app` names a new empty one, 33# `builder` is a (builder name, file name) pair for one the worker's generate op makes); kind is the ODF type it is saved as 34# (odt, ods, odp, odg); checks are the data/checks files with the values a reload must read back. 35Source = namedtuple("Source", "id path kind app builder checks", defaults=(None, None, ())) 36SHIPPED_KIND = {"odt": "odt", "ott": "odt", "ods": "ods", "ots": "ods", "odp": "odp", "otp": "odp", "odg": "odg", "otg": "odg"} 37GENERATED = (("writer", "odt"), ("calc", "ods"), ("impress", "odp"), ("draw", "odg")) 38# builder, file it makes, kind, checks. The para-fill-foreign builder is not here: it crashes the trunk dev app 39# (tests/odf/test_known_crashes.py), and a crash slows the office starts that follow. 40BUILT = (("page-fill", "page-fill.odt", "odt", ("page-fill/checks.json", "page-fill/bitmap.json")), 41 ("fill-others", "others-writer.odt", "odt", ("fill-others/writer.json",)), 42 ("fill-others", "others-calc.ods", "ods", ("fill-others/calc.json",)), 43 ("fill-others", "others-draw.odg", "odg", ("fill-others/draw.json",)), 44 ("fill-others", "others-impress.odp", "odp", ("fill-others/impress.json",)), 45 ("t13", "dropdown.odt", "odt", ("t13/checks-dropdown.json",)), 46 ("t13", "annot.odt", "odt", ("t13/checks-annot.json",)), 47 ("t13", "tabcolor.ods", "ods", ("t13/checks-tabcolor.json",)), 48 ("t13", "scaleto.ods", "ods", ("t13/checks-scaleto.json",)), 49 ("t13", "chart.ods", "ods", ("t13/checks-chart.json",)), 50 ("page-fill-1_3", "aoo-src.odt", "odt", ("page-fill-1_3/checks.json",)), 51 ("page-fill-1_3", "calc-src.ods", "ods", ("page-fill-1_3/checks-calc.json",)), 52 ("page-fill-1_3", "calc-drawing-page.ods", "ods", ("page-fill-1_3/checks-calc.json",)), 53 ("row-split", "row-split.odt", "odt", ("row-split/checks.json",)), 54 ("grid-snap", "grid-snap.odt", "odt", ("grid-snap/checks.json",)), 55 ("ole-text", "ole-text.odg", "odg", ("ole-text/checks.json",)), 56 ("ole-text-edge", "ole-text-edge.odg", "odg", ("ole-text-edge/checks.json",)), 57 ("chart-shapes", "chart-shapes.ods", "ods", ("chart-shapes/checks.json",)), 58 ("normal-values5b", "normal5b.ods", "ods", ("normal-values5b/checks-image.json",)), 59 ("connector-viewbox", "conn.odg", "odg", ("connector-viewbox/checks.json",)), 60 ("connector-viewbox", "conn.odp", "odp", ("connector-viewbox/checks.json",)), 61 ("para-fill", "para-fill.odt", "odt", ("para-fill/checks.json", "para-fill/bitmap.json")), 62 ("alien-attrs", "alien-writer.odt", "odt", ("alien-attrs/writer.json", "alien-attrs/writer-cell.json", "alien-attrs/writer-foreign.json")), 63 ("alien-attrs", "alien-draw.odg", "odg", ("alien-attrs/draw.json",)), 64 ("alien-attrs", "alien-calc.ods", "ods", ("alien-attrs/calc.json",)), 65 ("boolean-writer", "boolean-writer.odt", "odt", ("boolean-writer/checks.json",)), 66 ("normal-values", "normal-values.odt", "odt", ("normal-values/checks.json",)), 67 ("note-ref", "note-ref.odt", "odt", ("note-ref/checks-seq.json",))) 68# The files of the script builders (the ODF harness's large make.py scripts), generated from a run: builder<TAB>file. They carry no 69# reload checks, so they only add the save-mode items. Types the four above do not cover get their own filter in FILTERS. 70BUILT_KIND = dict(SHIPPED_KIND, odm="odm", odf="odf", odb="odb") 71BUILD_TIMEOUT, BUILD_TIMEOUTS = 300, {"base": 180, "calc": 900, "chart": 900, "draw": 900, "styles-gfx": 900, "styles-text": 900, "writer": 900} 72DEFAULT_LIMIT = 40 73SHIPPED_DIRS = ("template", "samples") 74 75 76def _tsv(name): 77 return [line.split("\t") for line in (DATA / name).read_text(encoding="utf-8").splitlines() if line and not line.startswith("#")] 78 79 80def script_excluded(): 81 """(builder, file, reason) rows of data/built-excluded.tsv: files of the script builders that are not in the default run.""" 82 return [tuple(r) for r in _tsv("built-excluded.tsv")] 83 84 85def script_built(): 86 """(builder, file, kind, checks) rows for the files in data/built-files.tsv, without the excluded ones.""" 87 left_out = {(b, f) for b, f, _ in script_excluded()} 88 return [(b, f, BUILT_KIND[f.rsplit(".", 1)[1]], ()) for b, f in _tsv("built-files.tsv") if (b, f) not in left_out] 89 90 91def share_dir(layout): 92 """share/ sits next to program/ in every layout (macOS: Contents/share).""" 93 return layout.program.parent / "share" 94 95 96def _walk(base): 97 for root, dirs, files in os.walk(base): 98 dirs.sort() 99 for name in sorted(files): 100 ext = name.rsplit(".", 1)[-1].lower() if "." in name else "" 101 if ext in SHIPPED_KIND: 102 yield Path(root) / name, ext 103 104 105def _interleave(groups): 106 """Takes one item from each group in turn, so a limit still covers every type and many directories.""" 107 iters = [iter(g) for g in groups if g] 108 while iters: 109 for it in list(iters): 110 try: 111 yield next(it) 112 except StopIteration: 113 iters.remove(it) 114 115 116def _spread(items): 117 """Orders (id, path, ext) items so that consecutive ones come from different directories: one from each in turn.""" 118 by_dir = {} 119 for item in items: 120 by_dir.setdefault(item[1].parent, []).append(item) 121 return list(_interleave([by_dir[d] for d in sorted(by_dir)])) 122 123 124def shipped(layout, limit=DEFAULT_LIMIT): 125 """Documents and templates under share/template and share/samples of the install, at most `limit`: types and directories 126 are interleaved, so the same install always gives the same list.""" 127 share = share_dir(layout) 128 groups = {} 129 for sub in SHIPPED_DIRS: 130 if (share / sub).is_dir(): 131 for path, ext in _walk(share / sub): 132 groups.setdefault(ext, []).append(("shipped/" + path.relative_to(share).as_posix(), path, ext)) 133 ordered = _interleave([_spread(groups[e]) for e in sorted(groups)]) 134 return [Source(i, p, SHIPPED_KIND[e]) for i, p, e in list(ordered)[:limit]] 135 136 137def generated(): 138 """One small document per application, and the documents the worker builds, all created by the office under test.""" 139 return ([Source("generated/" + app, None, kind, app) for app, kind in GENERATED] + 140 [Source("built/%s/%s" % (b, f), None, kind, None, (b, f), checks) for b, f, kind, checks in BUILT + tuple(script_built())]) 141 142 143def load_checks(name): 144 """The list of {"name", "expr", "expect"} in data/checks/<name>.""" 145 return json.load(open(DATA / "checks" / name, encoding="utf-8")) 146 147 148def extra(directory): 149 """Every ODF document under a directory named by the user.""" 150 if not directory: 151 return [] 152 base = Path(directory) 153 return [Source("documents/" + p.relative_to(base).as_posix(), p, SHIPPED_KIND[e]) for p, e in _walk(base)] 154 155 156def collect(office=None, limit=DEFAULT_LIMIT, documents=None): 157 """The sources for a run: [] when no usable office is named (the tests then skip with a reason).""" 158 if not office: 159 return [] 160 try: 161 layout = install.discover(office) 162 except install.LayoutError: 163 return [] 164 return generated() + shipped(layout, limit) + extra(documents) 165 166 167def out_name(source): 168 """Name of the saved copy: the id with separators and dots flattened (so x.ott and x.odt stay apart), in the extension of 169 the ODF type it is saved as.""" 170 return source.id.replace("/", "__").replace("\\", "__").replace(".", "_") + "." + source.kind 171 172 173def from_config(config, environ=None): 174 """The sources for the pytest run `config` describes (--office or AOO_OFFICE, --shipped-limit, --documents).""" 175 environ = os.environ if environ is None else environ 176 return collect(config.getoption("--office") or environ.get("AOO_OFFICE"), config.getoption("--shipped-limit"), config.getoption("--documents")) 177 178 179class Prepared: 180 """The file of each source, made once: a shipped or user document is used where it is, anything else is created by `session` 181 in `directory`. A script builder runs in the office that `own_office(builder)` returns, and that office is closed afterwards: 182 those scripts fail or stall depending on what the office did before.""" 183 184 def __init__(self, session, directory, own_office=None): 185 self.session, self.dir, self._made, self._built, self._failed = session, Path(directory), {}, set(), {} 186 self._outdir = {} 187 self.own_office = own_office 188 self.script_builders = {b for b, _ in _tsv("built-files.tsv")} 189 190 def file(self, source): 191 """The file of `source`. A source that could not be made raises the same error every time, without trying again.""" 192 if source.path is not None: 193 return str(source.path) 194 if source.id in self._failed: 195 raise self._failed[source.id] 196 if source.id not in self._made: 197 try: 198 self._make(source) 199 except WorkerFailure as e: 200 self._failed[source.id] = e 201 raise 202 return self._made[source.id] 203 204 def _build(self, name): 205 """Runs builder `name`. A script builder that stalls is run once more in a fresh office and a directory of its own: the base 206 script stalled in its first variant in two of about six runs, and left a lock file behind.""" 207 scripted = self.own_office and name in self.script_builders 208 for attempt in (1, 2): 209 outdir = self.dir / name / ("out" if attempt == 1 else "out-retry") 210 own = self.own_office(name) if scripted else None 211 try: 212 (own or self.session).call("generate", {"builder": name, "outdir": str(outdir)}, 213 timeout=BUILD_TIMEOUTS.get(name, BUILD_TIMEOUT)) 214 break 215 except WorkerHang as e: 216 if not scripted or attempt == 2: 217 self._failed[name] = e 218 raise 219 except WorkerFailure as e: 220 self._failed[name] = e 221 raise 222 finally: 223 if own: 224 own.close() 225 self._outdir[name] = outdir 226 self._built.add(name) 227 228 def _make(self, source): 229 if source.builder: 230 name, filename = source.builder 231 if name in self._failed: 232 raise self._failed[name] 233 if name not in self._built: 234 self._build(name) 235 self._made[source.id] = str(self._outdir[name] / filename) 236 else: 237 path = self.dir / ("%s.%s" % (source.app, source.kind)) 238 self.dir.mkdir(parents=True, exist_ok=True) 239 self.session.call("new_and_store", {"app": source.app, "dst": str(path), "filter": FILTERS[source.kind], 240 "text": "Hello %s" % source.app}, timeout=180) 241 self._made[source.id] = str(path) 242 243 244FILTERS = {"odt": "writer8", "ods": "calc8", "odp": "impress8", "odg": "draw8", "odm": "writerglobal8", "odf": "math8", 245 "odb": "StarOffice XML (Base)"} 246