mosaic-headless 1.21.2 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -65,6 +65,11 @@ def load_csv(name):
65
65
  return list(csv.DictReader(fh))
66
66
 
67
67
 
68
+ # Tags that may legitimately appear inside wysiwyg-text, which is raw HTML and is
69
+ # where this skill's own pages put their inline emphasis. Everything else in prose
70
+ # is almost certainly a tag NAME being discussed rather than markup intended to run.
71
+ PROSE_TAG = re.compile(r"</?(?!(?:b|i|em|strong|br|span|a|code|sup|sub)\b)"
72
+ r"[a-zA-Z][a-zA-Z0-9-]*\s*[^>]*>")
68
73
  AT_RULE_GLUED = re.compile(
69
74
  r"@(media|supports|container|layer|scope|import|page|starting-style|"
70
75
  r"font-feature-values|counter-style|property|document)\(")
@@ -103,6 +108,21 @@ class Surface:
103
108
  # Measured both ways on the demo site (`@media (` renders, `@media(`
104
109
  # does not). Refused here for the same reason an unsafe type is: the
105
110
  # failure is silent at the only point this tool can still stop it.
111
+ # `wysiwyg-text` is RAW HTML, not escaped text, so a tag NAME written in
112
+ # prose opens an element and swallows the rest of the paragraph. "Mosaic's
113
+ # modal is a real <dialog>" delivered the words up to "real" and nothing
114
+ # after it. The commit succeeds and the page renders - it is just missing
115
+ # most of its sentence, which is why this is worth refusing rather than
116
+ # leaving to proofreading. Inline formatting tags are let through; anything
117
+ # else has to be written as an entity.
118
+ if t == "wysiwyg-text" and not force:
119
+ text = (node.get("data") or {}).get("text") or ""
120
+ hit = PROSE_TAG.search(text)
121
+ if hit:
122
+ problems.append("wysiwyg-text contains `%s` - the text is raw HTML, so "
123
+ "this opens an element and the rest of the paragraph "
124
+ "disappears; write it as &lt;%s&gt;"
125
+ % (hit.group(0), hit.group(0).strip("<>/")))
106
126
  if t == "code" and not force:
107
127
  content = (node.get("data") or {}).get("content") or ""
108
128
  hit = AT_RULE_GLUED.search(content)
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env python3
2
+ """Re-extract every source-derived table and fail if a shipped one differs.
3
+
4
+ python tools/check_tables.py <plugin-root>
5
+
6
+ The failure this exists for is not a wrong value, it is a STALE one, and it ships
7
+ silently. Extracting the tables and then fixing the extractor leaves the shipped
8
+ table describing the old source with no sign anywhere: `node-types.csv` carried an
9
+ empty `default_element_class` for three releases because the column's regex was
10
+ repaired after that run, and nothing compared the two again.
11
+
12
+ Row counts cannot catch it - the count was right the whole time - and neither can
13
+ the release gate, which never sees the plugin source: it is licensed third-party
14
+ code and is not in the repository. So this is a local step, and the right moment
15
+ for it is after ANY change to a tool under `tools/extract_*.py`, not only after a
16
+ plugin upgrade.
17
+ """
18
+ import filecmp
19
+ import os
20
+ import subprocess
21
+ import sys
22
+ import tempfile
23
+
24
+ EXTRACTORS = [
25
+ "extract_node_types.py",
26
+ "extract_style_properties.py",
27
+ "extract_placement.py",
28
+ "extract_default_children.py",
29
+ "extract_dynamic_variables.py",
30
+ "extract_interactions.py",
31
+ "extract_pluggables.py",
32
+ ]
33
+
34
+
35
+ def main():
36
+ if len(sys.argv) != 2:
37
+ sys.exit(__doc__)
38
+ plugin_root = sys.argv[1]
39
+ here = os.path.dirname(os.path.abspath(__file__))
40
+ data = os.path.join(here, "..", "data")
41
+ if not os.path.isdir(plugin_root):
42
+ sys.exit("no plugin source at %s" % plugin_root)
43
+
44
+ with tempfile.TemporaryDirectory() as tmp:
45
+ for name in EXTRACTORS:
46
+ r = subprocess.run([sys.executable, os.path.join(here, name),
47
+ plugin_root, tmp],
48
+ capture_output=True, text=True, encoding="utf-8",
49
+ errors="replace")
50
+ if r.returncode != 0:
51
+ sys.exit("%s failed:\n%s" % (name, (r.stdout + r.stderr)[-600:]))
52
+
53
+ stale, checked = [], 0
54
+ for fname in sorted(os.listdir(tmp)):
55
+ shipped = os.path.join(data, fname)
56
+ if not os.path.exists(shipped):
57
+ stale.append("%s: extracted but not shipped" % fname)
58
+ continue
59
+ checked += 1
60
+ if not filecmp.cmp(shipped, os.path.join(tmp, fname), shallow=False):
61
+ # name the columns that moved, not just the file
62
+ with open(shipped, encoding="utf-8") as a, \
63
+ open(os.path.join(tmp, fname), encoding="utf-8") as b:
64
+ al, bl = a.read().splitlines(), b.read().splitlines()
65
+ first = next((i for i, (x, y) in enumerate(zip(al, bl)) if x != y), None)
66
+ detail = ("%d lines -> %d" % (len(al), len(bl)) if len(al) != len(bl)
67
+ else "first difference at line %d:\n shipped: %s\n fresh : %s"
68
+ % (first + 1, al[first][:110], bl[first][:110]))
69
+ stale.append("%s: %s" % (fname, detail))
70
+
71
+ print("checked %d source-derived tables against a fresh extraction" % checked)
72
+ if stale:
73
+ print("\nSTALE:")
74
+ for s in stale:
75
+ print(" - %s" % s)
76
+ sys.exit(1)
77
+ print("every one matches")
78
+
79
+
80
+ if __name__ == "__main__":
81
+ main()
@@ -0,0 +1,348 @@
1
+ #!/usr/bin/env python3
2
+ """Drive every modal on a dialog-lab page and record what actually happened.
3
+
4
+ python tools/verify_dialog_lab.py --url https://site/dialogs/ --csv data/dialog-form-verification.csv [--viewports 390,768]
5
+
6
+ answers "does the modal mechanism work". This answers the
7
+ question after it: can the three nodes be made into the FORMS a real site needs -
8
+ a bottom sheet, a side drawer, a corner notice, a full takeover, a gate with no
9
+ way out - and does each one still behave at phone width.
10
+
11
+ Each form is checked for the thing that form is FOR, not just "it opened":
12
+ the sheet for sitting at the bottom, the drawer for being at the right edge and
13
+ for Esc being its only way out, the toast for having no overlay and not blocking
14
+ the page, the gate for Esc doing nothing, and the A/B pair for pickOne drawing
15
+ exactly one contender per click.
16
+ """
17
+ import collections
18
+ import csv
19
+ import sys
20
+ from playwright.sync_api import sync_playwright
21
+
22
+ import argparse
23
+
24
+ AP = argparse.ArgumentParser(description=__doc__)
25
+ AP.add_argument("--url", required=True, help="the dialog lab page")
26
+ AP.add_argument("--csv")
27
+ AP.add_argument("--viewports", default="390,768",
28
+ help="widths at which every modal is re-opened and measured")
29
+ ARGS = AP.parse_args()
30
+ URL = ARGS.url
31
+ OUT = ARGS.csv
32
+
33
+ ROWS = []
34
+
35
+
36
+ def row(check, result, detail=""):
37
+ ROWS.append((check, result, detail))
38
+ print("%-34s %-5s %s" % (check, result, detail))
39
+
40
+
41
+ OPEN = "(id) => { const e = document.getElementById(id); return e ? !!e.open : null; }"
42
+ BOX = """(id) => {
43
+ const e = document.getElementById(id);
44
+ if (!e || !e.open) return null;
45
+ const w = e.querySelector('.m-modal-window');
46
+ const r = w.getBoundingClientRect();
47
+ const hr = e.getBoundingClientRect();
48
+ return {x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width),
49
+ h: Math.round(r.height),
50
+ hx: Math.round(hr.x), hy: Math.round(hr.y),
51
+ hw: Math.round(hr.width), hh: Math.round(hr.height),
52
+ vw: document.documentElement.clientWidth, vh: document.documentElement.clientHeight,
53
+ overlay: !!e.querySelector('.m-modal-overlay'),
54
+ ariaModal: e.getAttribute('aria-modal'),
55
+ closedby: e.getAttribute('data-mosaic-modal-closedby'),
56
+ tag: e.tagName.toLowerCase()};
57
+ }"""
58
+
59
+
60
+ def shut_all(p):
61
+ p.evaluate("""() => document.querySelectorAll('dialog.m-modal').forEach(d => {
62
+ if (d.open) { try { d.mosaicDialog ? d.mosaicDialog.close() : d.close(); }
63
+ catch(e) { d.close(); } } })""")
64
+ p.wait_for_timeout(250)
65
+
66
+
67
+ def main():
68
+ with sync_playwright() as pw:
69
+ b = pw.chromium.launch()
70
+ ctx = b.new_context(viewport={"width": 1280, "height": 900})
71
+ p = ctx.new_page()
72
+ p.goto(URL, wait_until="load")
73
+ p.wait_for_timeout(1600)
74
+
75
+ # ---- every modal is a real <dialog> and carries its own closedby
76
+ want = {"centre": "everything", "sheet": "clickOutside", "drawer": "esc",
77
+ "toast": "everything", "takeover": "everything", "gate": "nothing",
78
+ "exit": "everything", "depth": "clickOutside",
79
+ "aba": "everything", "abb": "everything"}
80
+ for key, cb in want.items():
81
+ got = p.evaluate("""(id) => { const e = document.getElementById(id);
82
+ return e ? [e.tagName.toLowerCase(),
83
+ e.getAttribute('data-mosaic-modal-closedby')] : null; }""",
84
+ "dl-" + key)
85
+ ok = got and got[0] == "dialog" and got[1] == cb
86
+ row("HOST/%s" % key, "PASS" if ok else "FAIL", "%s" % (got,))
87
+
88
+ # ---- the toast opened by itself, has no overlay, no aria-modal
89
+ t = p.evaluate(BOX, "dl-toast")
90
+ row("TOAST/pageLoad", "PASS" if t else "FAIL", "opened on load" if t else "shut")
91
+ if t:
92
+ # Authored WITHOUT an overlay. Mosaic's heal pass inserts one anyway -
93
+ # "a required structural part, non-deletable, one per Modal" - so the
94
+ # overlay-less modal is unreachable and aria-modal cannot be shed.
95
+ row("TOAST/overlay-synthesised", "PASS" if t["overlay"] else "FAIL",
96
+ "authored none; heal inserted one")
97
+ row("TOAST/aria-modal-unavoidable",
98
+ "PASS" if t["ariaModal"] == "true" else "FAIL",
99
+ "aria-modal=%r despite no authored overlay" % t["ariaModal"])
100
+ corner = t["x"] + t["w"] > t["vw"] * 0.6 and t["y"] < t["vh"] * 0.4
101
+ row("TOAST/top-right", "PASS" if corner else "FAIL",
102
+ "x=%d y=%d w=%d vw=%d" % (t["x"], t["y"], t["w"], t["vw"]))
103
+ # and because the overlay is there, it blocks like any other modal.
104
+ # Programmatic scrollTo() would sail straight through - this has to be
105
+ # a real wheel event to mean anything.
106
+ p.evaluate("() => scrollTo(0, 0)")
107
+ p.wait_for_timeout(200)
108
+ p.mouse.move(400, 500)
109
+ p.mouse.wheel(0, 700)
110
+ p.wait_for_timeout(600)
111
+ blocked = p.evaluate("() => scrollY") == 0
112
+ row("TOAST/wheel-blocked", "PASS" if blocked else "FAIL",
113
+ "a corner toast is a LAYOUT, not a non-blocking modal")
114
+ shut_all(p)
115
+
116
+ # ---- a closed modal must be INVISIBLE
117
+ #
118
+ # The check that this file exists for. A <dialog>'s open/closed visibility
119
+ # is its `display`, so an author-level `display:flex` on the host beats
120
+ # `dialog:not([open]){display:none}` and every modal on the page sits open
121
+ # forever. Nothing that only inspects the OPEN state can see it - the first
122
+ # version of this verifier passed 36/36 on a page where all ten were
123
+ # permanently on screen.
124
+ p.evaluate("""() => document.querySelectorAll('dialog.m-modal')
125
+ .forEach(d => { if (d.open) { try { d.close(); } catch (e) {} } })""")
126
+ p.wait_for_timeout(400)
127
+ shown = p.evaluate("""() => [...document.querySelectorAll('dialog.m-modal')]
128
+ .filter(d => !d.open)
129
+ .filter(d => { const r = d.getBoundingClientRect();
130
+ return r.width > 0 && r.height > 0; })
131
+ .map(d => d.id + ' (display:' + getComputedStyle(d).display + ')')""")
132
+ row("CLOSED_IS_HIDDEN", "PASS" if not shown else "FAIL",
133
+ "every closed modal is display:none" if not shown
134
+ else "still rendered: %s" % ", ".join(shown))
135
+ # ---- each click-opened form lands where its host put it
136
+ def open_and_box(key):
137
+ p.click("#dl-open-%s" % key)
138
+ p.wait_for_timeout(500)
139
+ return p.evaluate(BOX, "dl-" + key)
140
+
141
+ c = open_and_box("centre")
142
+ if c:
143
+ mid = abs((c["x"] + c["w"] / 2) - c["vw"] / 2) < 30
144
+ row("CENTRE/centred", "PASS" if mid else "FAIL",
145
+ "x=%d w=%d vw=%d" % (c["x"], c["w"], c["vw"]))
146
+ row("CENTRE/aria-modal", "PASS" if c["ariaModal"] == "true" else "FAIL",
147
+ "aria-modal=%r (overlay=%s)" % (c["ariaModal"], c["overlay"]))
148
+ p.keyboard.press("Escape")
149
+ p.wait_for_timeout(400)
150
+ row("CENTRE/esc-closes", "PASS" if not p.evaluate(OPEN, "dl-centre") else "FAIL",
151
+ "closedby=everything")
152
+ else:
153
+ row("CENTRE/centred", "FAIL", "did not open")
154
+ shut_all(p)
155
+
156
+ s = open_and_box("sheet")
157
+ if s:
158
+ bottom = abs((s["y"] + s["h"]) - s["vh"]) < 4
159
+ full = s["w"] > 600
160
+ row("SHEET/at-bottom", "PASS" if bottom else "FAIL",
161
+ "y+h=%d vh=%d" % (s["y"] + s["h"], s["vh"]))
162
+ row("SHEET/wide", "PASS" if full else "FAIL", "w=%d" % s["w"])
163
+ p.keyboard.press("Escape")
164
+ p.wait_for_timeout(400)
165
+ still = p.evaluate(OPEN, "dl-sheet")
166
+ row("SHEET/esc-does-nothing", "PASS" if still else "FAIL",
167
+ "closedby=clickOutside, so Esc must not close it")
168
+ shut_all(p)
169
+
170
+ d = open_and_box("drawer")
171
+ if d:
172
+ right = abs((d["x"] + d["w"]) - (d["hx"] + d["hw"])) < 4
173
+ tall = d["h"] > d["vh"] * 0.9
174
+ row("DRAWER/right-edge", "PASS" if right else "FAIL",
175
+ "x+w=%d layer right=%d" % (d["x"] + d["w"], d["hx"] + d["hw"]))
176
+ row("DRAWER/full-height", "PASS" if tall else "FAIL",
177
+ "h=%d vh=%d" % (d["h"], d["vh"]))
178
+ p.keyboard.press("Escape")
179
+ p.wait_for_timeout(400)
180
+ row("DRAWER/esc-closes", "PASS" if not p.evaluate(OPEN, "dl-drawer") else "FAIL",
181
+ "closedby=esc")
182
+ shut_all(p)
183
+
184
+ k = open_and_box("takeover")
185
+ if k:
186
+ fills = k["w"] >= k["hw"] - 2 and k["h"] >= k["hh"] - 2
187
+ row("TAKEOVER/fills-viewport", "PASS" if fills else "FAIL",
188
+ "%dx%d vs layer %dx%d" % (k["w"], k["h"], k["hw"], k["hh"]))
189
+ shut_all(p)
190
+
191
+ g = open_and_box("gate")
192
+ if g:
193
+ p.keyboard.press("Escape")
194
+ p.wait_for_timeout(400)
195
+ row("GATE/esc-refused", "PASS" if p.evaluate(OPEN, "dl-gate") else "FAIL",
196
+ "closedby=nothing")
197
+ p.mouse.click(8, 8) # the overlay, i.e. outside the window
198
+ p.wait_for_timeout(400)
199
+ row("GATE/outside-refused", "PASS" if p.evaluate(OPEN, "dl-gate") else "FAIL",
200
+ "clicking the overlay must not close it")
201
+ p.click("#dl-close-gate")
202
+ p.wait_for_timeout(400)
203
+ row("GATE/button-closes", "PASS" if not p.evaluate(OPEN, "dl-gate") else "FAIL",
204
+ "the modalClose inside is the only exit")
205
+ shut_all(p)
206
+
207
+ # ---- scrollDepth at 75%
208
+ p.reload(wait_until="load")
209
+ p.wait_for_timeout(1500)
210
+ shut_all(p)
211
+ row("DEPTH/shut-at-rest", "PASS" if not p.evaluate(OPEN, "dl-depth") else "FAIL")
212
+ p.evaluate("() => scrollTo(0, document.body.scrollHeight * 0.85)")
213
+ p.wait_for_timeout(1400)
214
+ row("DEPTH/opens-past-75", "PASS" if p.evaluate(OPEN, "dl-depth") else "FAIL",
215
+ "scrollDepth threshold 75%")
216
+ shut_all(p)
217
+
218
+ # ---- exit intent: pointer leaves through the top
219
+ p.evaluate("() => scrollTo(0, 0)")
220
+ p.mouse.move(640, 400)
221
+ p.wait_for_timeout(300)
222
+ p.mouse.move(640, 0)
223
+ p.evaluate("""() => document.dispatchEvent(
224
+ new MouseEvent('mouseleave', {clientY: -5, bubbles: true}))""")
225
+ p.wait_for_timeout(1600)
226
+ row("EXIT/opens", "PASS" if p.evaluate(OPEN, "dl-exit") else "WARN",
227
+ "exitIntent is desktop-only and synthetic leave is approximate")
228
+ shut_all(p)
229
+
230
+ # ---- the latch: pickOne on a click is NOT a lottery
231
+ #
232
+ # Whichever of the two buttons is clicked FIRST wins the draw and records
233
+ # itself; the other is then suppressed for as long as `remember` holds.
234
+ # Running it both ways is what separates "first evaluated wins" from
235
+ # "first in the payload wins" - only the former flips with the order.
236
+ def latch(order, n=6):
237
+ seen = collections.Counter()
238
+ for i in range(n):
239
+ c2 = b.new_context(viewport={"width": 1280, "height": 900})
240
+ p2 = c2.new_page()
241
+ p2.goto(URL + "?v=%s%d" % (order[0], i), wait_until="load")
242
+ p2.wait_for_timeout(2200)
243
+ p2.evaluate("""() => document.querySelectorAll('dialog.m-modal')
244
+ .forEach(d => { if (d.open) d.close(); })""")
245
+ steps = []
246
+ for which in order:
247
+ p2.click("#dl-open-%s" % which)
248
+ p2.wait_for_timeout(600)
249
+ opened = [k for k in ("aba", "abb") if p2.evaluate(OPEN, "dl-" + k)]
250
+ steps.append("%s>%s" % (which, "+".join(opened) or "none"))
251
+ p2.evaluate("""() => document.querySelectorAll('dialog.m-modal')
252
+ .forEach(d => { if (d.open) d.close(); })""")
253
+ p2.wait_for_timeout(200)
254
+ seen[",".join(steps)] += 1
255
+ if i == 0:
256
+ rec = p2.evaluate("""() => { try { return localStorage['mos:v1:r:dl-ab']
257
+ || null; } catch(e) { return null; } }""")
258
+ row("LATCH/record-%s" % order[0],
259
+ "PASS" if rec and "lastID" in rec else "WARN", (rec or "none")[:80])
260
+ c2.close()
261
+ return seen
262
+
263
+ def score(seen, order):
264
+ """Runs where the FIRST click opened its own modal and the second
265
+ opened nothing. A run where the first click opened nothing at all is
266
+ a lost click, not a counter-example - it is reported, not scored."""
267
+ want = "%s>%s,%s>none" % (order[0], order[0], order[1])
268
+ lost = sum(v for k, v in seen.items() if k.startswith("%s>none" % order[0]))
269
+ return sum(v for k, v in seen.items() if k == want), lost, sum(seen.values())
270
+
271
+ first_b = latch(["abb", "aba"])
272
+ gb, lb, nb = score(first_b, ["abb", "aba"])
273
+ row("LATCH/B-first-wins", "PASS" if gb == nb - lb and gb else "FAIL",
274
+ "%d/%d as predicted, %d lost clicks %s" % (gb, nb, lb, dict(first_b)))
275
+ first_a = latch(["aba", "abb"])
276
+ ga, la, na = score(first_a, ["aba", "abb"])
277
+ row("LATCH/A-first-wins", "PASS" if ga == na - la and ga else "FAIL",
278
+ "%d/%d as predicted, %d lost clicks %s" % (ga, na, la, dict(first_a)))
279
+ row("LATCH/order-decides",
280
+ "PASS" if (gb and ga) else "FAIL",
281
+ "the winner flips with click order, so it is the first EVALUATED, "
282
+ "not the first authored")
283
+
284
+ # ---- and every form again on a phone and a tablet
285
+ #
286
+ # The page passing a width check says nothing about the modals: they are
287
+ # a separate layer that only exists once something opens them, so a
288
+ # takeover with desktop padding can overflow on a phone while the
289
+ # document underneath measures clean.
290
+ FIT = """(id) => {
291
+ const e = document.getElementById(id);
292
+ if (!e || !e.open) return null;
293
+ const w = e.querySelector('.m-modal-window');
294
+ const r = w.getBoundingClientRect(), h = e.getBoundingClientRect();
295
+ let over = 0, worst = null;
296
+ w.querySelectorAll('*').forEach(n => {
297
+ const b = n.getBoundingClientRect();
298
+ if (b.right - r.right > over) { over = b.right - r.right;
299
+ worst = n.id || n.className; } });
300
+ return {fits: r.left >= h.left - 1 && r.right <= h.right + 1,
301
+ scrollW: w.scrollWidth, clientW: w.clientWidth,
302
+ over: Math.round(over), worst: worst,
303
+ doc: document.documentElement.scrollWidth
304
+ > document.documentElement.clientWidth};
305
+ }"""
306
+ for vw in [int(x) for x in ARGS.viewports.split(",") if x.strip()]:
307
+ cm = b.new_context(viewport={"width": vw, "height": 780})
308
+ pm = cm.new_page()
309
+ pm.goto(URL, wait_until="load")
310
+ pm.wait_for_timeout(2000)
311
+ pm.evaluate("() => document.querySelectorAll('dialog')"
312
+ ".forEach(d => d.open && d.close())")
313
+ for key in ("centre", "sheet", "drawer", "takeover", "gate", "aba"):
314
+ try:
315
+ pm.click("#dl-open-%s" % key, timeout=5000)
316
+ pm.wait_for_timeout(450)
317
+ except Exception:
318
+ row("FIT%d/%s" % (vw, key), "FAIL", "opener not clickable")
319
+ continue
320
+ f = pm.evaluate(FIT, "dl-" + key)
321
+ if not f:
322
+ row("FIT%d/%s" % (vw, key), "FAIL", "did not open")
323
+ else:
324
+ bad = (not f["fits"] or f["scrollW"] > f["clientW"] + 1
325
+ or f["over"] > 1 or f["doc"])
326
+ row("FIT%d/%s" % (vw, key), "FAIL" if bad else "PASS",
327
+ "scrollW=%d/%d childOverflow=%d%s pageScrollsSideways=%s"
328
+ % (f["scrollW"], f["clientW"], f["over"],
329
+ (" <%s>" % f["worst"]) if f["over"] > 1 else "", f["doc"]))
330
+ pm.evaluate("() => document.querySelectorAll('dialog')"
331
+ ".forEach(d => d.open && d.close())")
332
+ pm.wait_for_timeout(200)
333
+ cm.close()
334
+
335
+ b.close()
336
+
337
+ if OUT:
338
+ with open(OUT, "w", newline="", encoding="utf-8") as f:
339
+ w = csv.writer(f)
340
+ w.writerow(["check", "result", "detail"])
341
+ w.writerows(ROWS)
342
+ bad = [r for r in ROWS if r[1] == "FAIL"]
343
+ print("\n%d checks, %d FAIL" % (len(ROWS), len(bad)))
344
+ return 1 if bad else 0
345
+
346
+
347
+ if __name__ == "__main__":
348
+ sys.exit(main())