mosaic-headless 1.3.0 → 1.5.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.
@@ -0,0 +1,324 @@
1
+ #!/usr/bin/env python3
2
+ """Assert a page-load animation PLAYS, and - the part that matters - that it ENDS.
3
+
4
+ pip install playwright && playwright install chromium
5
+ python tools/verify_intro.py --config c.json --site sites/moksa.json
6
+ python tools/verify_intro.py --config c.json --site sites/moksa.json \
7
+ --csv data/intro-verification.csv --frames shots/intro/
8
+
9
+ An entrance animation is invisible to every other check in this skill, and not by
10
+ oversight - by construction. `verify_rwd.py` reads the stylesheet the site served.
11
+ `sweep_style_properties.py` reads the compiled CSS. Even `verify_browser.py`, which
12
+ does open a real browser, waits for the page to settle before it reads anything,
13
+ because a computed value taken mid-transition is noise. An intro sequence exists only
14
+ in the first two seconds of a document's life, and then never again.
15
+
16
+ That window is also where the worst failure on this whole list lives:
17
+
18
+ an overlay that covers the document and never leaves
19
+
20
+ It fails silently and completely. The commit succeeds, the stylesheet is correct,
21
+ every declaration is present, the responsive pass is green, and a visitor gets a
22
+ blank screen forever. No check that reads text can see it, and a browser check that
23
+ waits for the page to settle will wait for a page that never does.
24
+
25
+ So this one samples. It opens the URL, takes readings at a series of timestamps
26
+ across the intro's life, and then asserts four things:
27
+
28
+ PLAYS something actually changed between the first and last sample -
29
+ otherwise the sequence is dead code and the delay is just a stall
30
+ ENDS by the deadline the overlay is gone from hit-testing, and every
31
+ element that started hidden is fully opaque
32
+ CLEARS a real click at the centre of the viewport reaches the document,
33
+ not the veil: `visibility:hidden` and `pointer-events:none` are
34
+ different promises and only one of them is usually kept
35
+ DEGRADES with `prefers-reduced-motion: reduce` the content is readable
36
+ immediately, and the veil never appears at all
37
+
38
+ The fourth is the one worth designing for rather than merely testing. The overlay in
39
+ `sites/_moksa.py` is `display:none` in its base rule and is switched on only inside
40
+ the motion query that also carries the animation removing it - so if the animation
41
+ cannot run, the overlay does not exist. The failure direction is "no intro", never
42
+ "no page".
43
+ """
44
+ from __future__ import annotations
45
+
46
+ import argparse
47
+ import csv
48
+ import json
49
+ import os
50
+ import sys
51
+ import time
52
+
53
+ # When to read. Dense through the sequence, then two well past its end - the last
54
+ # two are what turn "it looked right" into "it finished".
55
+ SAMPLES_MS = [120, 350, 700, 1100, 1500, 1750, 2050, 2400, 3200, 4500]
56
+
57
+ # What to read at each sample. Anything whose id starts with the veil prefix is
58
+ # treated as part of the intro; everything else is content that must end up visible.
59
+ PROBE = r"""
60
+ (ids) => {
61
+ const out = {t: performance.now(), nodes: {}};
62
+ for (const id of ids) {
63
+ const el = document.getElementById(id);
64
+ if (!el) { out.nodes[id] = null; continue; }
65
+ const cs = getComputedStyle(el);
66
+ const r = el.getBoundingClientRect();
67
+ out.nodes[id] = {
68
+ opacity: cs.opacity,
69
+ visibility: cs.visibility,
70
+ display: cs.display,
71
+ pointerEvents: cs.pointerEvents,
72
+ clipPath: cs.clipPath,
73
+ transform: cs.transform,
74
+ counter: cs.getPropertyValue('--mk-n').trim(),
75
+ text: (el.textContent || '').trim().slice(0, 24),
76
+ w: Math.round(r.width), h: Math.round(r.height),
77
+ };
78
+ }
79
+ // What is actually under the middle of the screen? This is the question a visitor
80
+ // asks by clicking, and no declared value answers it.
81
+ const mid = document.elementFromPoint(innerWidth / 2, innerHeight / 2);
82
+ out.hitCentre = mid ? (mid.id || mid.tagName.toLowerCase() + '.' +
83
+ (mid.className || '').toString().split(' ')[0]) : null;
84
+ out.veilCoversCentre = !!(mid && mid.closest('#mk-boot'));
85
+ // Anything left invisible after the intro should be deliberate, so count it -
86
+ // but ONLY inside the viewport. Below the fold this page is full of elements
87
+ // deliberately at opacity 0, waiting for a view() timeline to bring them in as
88
+ // you scroll, and counting those as trapped content reports 34 failures on a
89
+ // page that is working exactly as designed.
90
+ let hidden = [], offscreen = 0, blinking = 0;
91
+ for (const el of document.querySelectorAll('#mk-doc *')) {
92
+ const cs = getComputedStyle(el);
93
+ if (cs.display === 'none' || cs.visibility === 'hidden') continue;
94
+ if (parseFloat(cs.opacity) >= 0.02) continue;
95
+ const r = el.getBoundingClientRect();
96
+ if (r.width === 0 || r.height === 0) continue;
97
+ if (r.top >= innerHeight || r.bottom <= 0) { offscreen++; continue; }
98
+ // An element in the middle of an INFINITE animation is not trapped, it is
99
+ // blinking. A caret sampled on its off beat reads exactly like content that
100
+ // never arrived, and the difference is `animation-iteration-count`.
101
+ if (cs.animationIterationCount.split(',').some(v => v.trim() === 'infinite')) {
102
+ blinking++; continue;
103
+ }
104
+ hidden.push(el.id || el.tagName.toLowerCase() + '.' +
105
+ (el.className || '').toString().split(' ')[0]);
106
+ }
107
+ out.invisibleContent = hidden.length;
108
+ out.invisibleIds = hidden.slice(0, 8);
109
+ out.invisibleBelowFold = offscreen;
110
+ out.blinking = blinking;
111
+ return out;
112
+ }
113
+ """
114
+
115
+
116
+ def read_ids(spec, slug):
117
+ """Every attrID on the page, split into veil and content."""
118
+ ids = []
119
+
120
+ def walk(node):
121
+ if isinstance(node, dict):
122
+ data = node.get("data")
123
+ if isinstance(data, dict) and data.get("attrID"):
124
+ ids.append(data["attrID"])
125
+ for v in node.values():
126
+ walk(v)
127
+ elif isinstance(node, list):
128
+ for v in node:
129
+ walk(v)
130
+
131
+ for page in spec["pages"]:
132
+ if page["slug"] == slug:
133
+ walk(page.get("tree"))
134
+ return ids
135
+
136
+
137
+ def run(url, ids, reduced, frames_dir):
138
+ try:
139
+ from playwright.sync_api import sync_playwright
140
+ except ImportError:
141
+ sys.exit("verify_intro.py needs Playwright:\n"
142
+ " pip install playwright && playwright install chromium")
143
+
144
+ shots = []
145
+ with sync_playwright() as pw:
146
+ browser = pw.chromium.launch()
147
+ ctx = browser.new_context(
148
+ viewport={"width": 1440, "height": 900},
149
+ reduced_motion="reduce" if reduced else "no-preference")
150
+ page = ctx.new_page()
151
+ # `commit` rather than `load`: the clock has to start when the document
152
+ # starts, not when it has finished settling, or the whole sequence is over
153
+ # before the first reading is taken.
154
+ # Cache-busted, like every other check here. Without it this tool read a
155
+ # Varnish copy of the page from before the intro existed and reported, with
156
+ # complete confidence, that the sequence did not play. Third time this
157
+ # failure has been made in this repo; the query string is not optional.
158
+ sep = "&" if "?" in url else "?"
159
+ page.goto("%s%s_v=%d" % (url, sep, int(time.time() * 1000)),
160
+ wait_until="commit", timeout=60000)
161
+ readings = []
162
+ for ms in SAMPLES_MS:
163
+ page.wait_for_timeout(max(0, ms - (readings[-1]["t_ms"]
164
+ if readings else 0)))
165
+ rec = page.evaluate(PROBE, ids)
166
+ rec["t_ms"] = ms
167
+ readings.append(rec)
168
+ if frames_dir:
169
+ os.makedirs(frames_dir, exist_ok=True)
170
+ path = os.path.join(frames_dir, "t%04d.png" % ms)
171
+ page.screenshot(path=path)
172
+ shots.append(path)
173
+ # One real click, at the end, at the centre of the page.
174
+ try:
175
+ page.mouse.click(720, 450)
176
+ clicked = page.evaluate(
177
+ "() => { const e = document.elementFromPoint(720, 450);"
178
+ "return e ? (e.id || e.tagName) : null; }")
179
+ except Exception as exc: # noqa: BLE001
180
+ clicked = "click failed: %s" % exc
181
+ ctx.close()
182
+ browser.close()
183
+ return readings, clicked, shots
184
+
185
+
186
+ def changed(readings, ids):
187
+ """Which probes moved at all across the sequence."""
188
+ moved = set()
189
+ for i in ids:
190
+ seen = set()
191
+ for r in readings:
192
+ n = r["nodes"].get(i)
193
+ if n:
194
+ seen.add((n["opacity"], n["visibility"], n["clipPath"],
195
+ n["transform"], n["counter"]))
196
+ if len(seen) > 1:
197
+ moved.add(i)
198
+ return moved
199
+
200
+
201
+ def main():
202
+ ap = argparse.ArgumentParser()
203
+ ap.add_argument("--config", required=True)
204
+ ap.add_argument("--site", required=True)
205
+ ap.add_argument("--page")
206
+ ap.add_argument("--veil-prefix", default="mk-boot",
207
+ help="ids under this prefix are the intro, not the content")
208
+ ap.add_argument("--deadline-ms", type=int, default=3200,
209
+ help="by this point the intro must be over")
210
+ ap.add_argument("--csv")
211
+ ap.add_argument("--frames")
212
+ a = ap.parse_args()
213
+
214
+ cfg = json.load(open(a.config, encoding="utf-8"))
215
+ spec = json.load(open(a.site, encoding="utf-8"))
216
+ slug = a.page or spec["pages"][0]["slug"]
217
+ url = "%s/%s/" % (cfg["base"].rstrip("/"), slug)
218
+
219
+ ids = read_ids(spec, slug)
220
+ veil = [i for i in ids if i.startswith(a.veil_prefix)]
221
+ if not veil:
222
+ sys.exit("no ids under %r - nothing here is an intro" % a.veil_prefix)
223
+
224
+ print("%s\n %d ids, %d of them the intro, %d samples\n"
225
+ % (url, len(ids), len(veil), len(SAMPLES_MS)))
226
+
227
+ readings, clicked, shots = run(url, ids, False, a.frames)
228
+ moved = changed(readings, ids)
229
+
230
+ print(" %-7s %-11s %-9s %-8s %-7s %s"
231
+ % ("t", "counter", "veil vis", "clip", "covers", "under the centre"))
232
+ for r in readings:
233
+ v = r["nodes"].get(a.veil_prefix) or {}
234
+ num = (r["nodes"].get(a.veil_prefix + "-num") or {}).get("counter", "")
235
+ print(" %-7s %-11s %-9s %-8s %-7s %s"
236
+ % ("%dms" % r["t_ms"], num or "-", v.get("visibility", "-"),
237
+ (v.get("clipPath") or "-")[:8],
238
+ "YES" if r["veilCoversCentre"] else "no", r["hitCentre"]))
239
+
240
+ last = readings[-1]
241
+ at_deadline = [r for r in readings if r["t_ms"] >= a.deadline_ms]
242
+ checks = []
243
+
244
+ # PLAYS
245
+ veil_moved = moved & set(veil)
246
+ checks.append(("PLAYS", bool(veil_moved),
247
+ "%d of %d intro elements changed state" % (len(veil_moved),
248
+ len(veil))))
249
+ # the counter is an animated integer, so it can be read rather than admired
250
+ counters = [(r["t_ms"], (r["nodes"].get(a.veil_prefix + "-num") or {})
251
+ .get("counter", "")) for r in readings]
252
+ nums = [int(c) for _t, c in counters if c.isdigit()]
253
+ checks.append(("COUNTS", bool(nums) and max(nums) >= 99,
254
+ "--mk-n reached %s" % (max(nums) if nums else "nothing")))
255
+
256
+ # ENDS
257
+ ended = all(not r["veilCoversCentre"] for r in at_deadline)
258
+ checks.append(("ENDS", ended,
259
+ "veil is out of hit-testing from %dms" % a.deadline_ms))
260
+ checks.append(("NO_TRAP", last["invisibleContent"] == 0,
261
+ "%d content elements still at opacity 0 in the viewport%s "
262
+ "(%d below the fold awaiting their own scroll timeline, "
263
+ "%d mid-blink)"
264
+ % (last["invisibleContent"],
265
+ (": " + ", ".join(last["invisibleIds"]))
266
+ if last["invisibleIds"] else "",
267
+ last["invisibleBelowFold"], last["blinking"])))
268
+
269
+ # CLEARS
270
+ cleared = bool(clicked) and not str(clicked).startswith(a.veil_prefix)
271
+ checks.append(("CLEARS", cleared, "a real click landed on %r" % clicked))
272
+
273
+ # DEGRADES
274
+ red, red_click, _ = run(url, ids, True, None)
275
+ first = red[0]
276
+ # A node that has not been parsed yet reads as absent, which is not a failure -
277
+ # the requirement is that wherever the veil EXISTS under reduced motion, it is
278
+ # display:none. Treating "not in the DOM at 120ms" as a broken veil is the tool
279
+ # marking its own sampling as a defect in the page.
280
+ seen_veil = [r["nodes"].get(a.veil_prefix) for r in red
281
+ if r["nodes"].get(a.veil_prefix)]
282
+ veil_absent = bool(seen_veil) and all(v.get("display") == "none"
283
+ for v in seen_veil)
284
+ checks.append(("DEGRADES", veil_absent and not first["veilCoversCentre"],
285
+ "with reduced motion the veil is display:none throughout"))
286
+ checks.append(("READABLE", red[0]["invisibleContent"] == 0,
287
+ "%d elements invisible at %dms with reduced motion"
288
+ % (red[0]["invisibleContent"], red[0]["t_ms"])))
289
+
290
+ print()
291
+ for name, ok, note in checks:
292
+ print(" %-9s %-4s %s" % (name, "PASS" if ok else "FAIL", note))
293
+ failed = [c for c in checks if not c[1]]
294
+
295
+ if a.csv:
296
+ with open(a.csv, "w", newline="", encoding="utf-8") as fh:
297
+ w = csv.writer(fh)
298
+ w.writerow(["check", "result", "detail"])
299
+ for name, ok, note in checks:
300
+ w.writerow([name, "PASS" if ok else "FAIL", note])
301
+ w.writerow([])
302
+ w.writerow(["t_ms", "counter", "veil_visibility", "veil_clip",
303
+ "veil_covers_centre", "hit_centre", "invisible_content"])
304
+ for r in readings:
305
+ v = r["nodes"].get(a.veil_prefix) or {}
306
+ w.writerow([r["t_ms"],
307
+ (r["nodes"].get(a.veil_prefix + "-num") or {})
308
+ .get("counter", ""),
309
+ v.get("visibility", ""), v.get("clipPath", ""),
310
+ r["veilCoversCentre"], r["hitCentre"],
311
+ r["invisibleContent"]])
312
+ print("\nwrote", a.csv)
313
+ if shots:
314
+ print("wrote %d frames to %s" % (len(shots), a.frames))
315
+
316
+ print("\n%s" % ("PASS - the sequence plays, finishes, hands the page back, and "
317
+ "does not exist at all when motion is reduced"
318
+ if not failed else
319
+ "FAIL - %s" % ", ".join(c[0] for c in failed)))
320
+ sys.exit(1 if failed else 0)
321
+
322
+
323
+ if __name__ == "__main__":
324
+ main()