mosaic-headless 1.2.1 → 1.4.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,352 @@
1
+ #!/usr/bin/env python3
2
+ """Find out what shape an interaction animation has to be, by committing candidates.
3
+
4
+ python tools/sweep_interactions.py --config c.json --post 20 --slug probe-lab
5
+ python tools/sweep_interactions.py --config c.json --post 20 --slug probe-lab \
6
+ --csv data/interaction-verification.csv
7
+
8
+ Mosaic has two animation systems. The CSS path - a `transition` plus a state - is
9
+ fully verified elsewhere. This is the other one: keyframes driven by Mosaic's own
10
+ JavaScript, which is the only way to get scroll-linked motion, and which this skill
11
+ called UNSOLVED because `propertyMetas` and per-keyframe `properties` never survived
12
+ into the page.
13
+
14
+ The reason they never survived is legible in the source rather than guessable:
15
+
16
+ AnimationActionOptionsDataSubAbstract::createProperties()
17
+ $this->createDataArray('propertyMetas', KeyframePropertyMetasDataSub::class);
18
+
19
+ DataArray::initValues($arrayRawItems)
20
+ foreach ($arrayRawItems as $arrayRawItem) {
21
+ $arrayItem = $this->_createArrayItem($arrayRawItem->uuid); // <-- here
22
+ unset($arrayRawItem->uuid);
23
+
24
+ **A data array is a JSON list whose every item carries its own `uuid` field.** An
25
+ item without one cannot be constructed, so the list is silently emptied - which
26
+ looks exactly like "arrays are the wrong shape" and sends you off trying objects.
27
+
28
+ And a keyframe's `properties` is a `DataGroup` whose descriptors are built at sync
29
+ time, one per property meta, under `KeyframePropertyMetaDataSub::getName()`. For a
30
+ `predefined` meta that name is the property itself (`PredefinedKeyframePropertyMeta\\
31
+ OptionAbstract::getName() { return $this->getProperty(); }`), so `properties` is
32
+ keyed by `"opacity"`, not by the meta's uuid.
33
+
34
+ Both of those are readings of the source, which is exactly the kind of claim this
35
+ skill refuses to make on its own. So each candidate below is committed to a live
36
+ page and judged against `var mosaicInteractions` in the delivered HTML - the payload
37
+ Mosaic's own frontend consumes - **including negative controls**, because a shape
38
+ that works proves nothing about WHY unless the shape that differs by one field fails.
39
+
40
+ Statuses
41
+ --------
42
+ BOUND the payload carries the animated properties: solved
43
+ TIMING_ONLY keyframes arrived with timing and nothing to animate
44
+ NO_PAYLOAD the interaction did not reach `var mosaicInteractions` at all
45
+ DROPPED the row stored, but the interaction was pruned out of the node data
46
+ REJECTED the validator refused the commit
47
+ """
48
+ from __future__ import annotations
49
+
50
+ import argparse
51
+ import csv
52
+ import json
53
+ import os
54
+ import re
55
+ import sys
56
+ import time
57
+ import urllib.request
58
+ import uuid
59
+
60
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
61
+ from build_page import Surface # noqa: E402
62
+ from build_site import bind_page, build_page_document, build_shell # noqa: E402
63
+ from sweep_node_types import Client # noqa: E402
64
+
65
+ # Read off PredefinedKeyframePropertyMetaTypeFactory's constructor. `data/animatable-
66
+ # properties.csv` carries the full list; these are the ones each candidate drives.
67
+ OPACITY, MOVE_Y = "opacity", "translateY"
68
+
69
+
70
+ def meta(name, uid=None, with_uuid=True):
71
+ m = {"type": "predefined", "timelineKey": "_", "predefinedOptions": {"name": name}}
72
+ if with_uuid:
73
+ m["uuid"] = uid or str(uuid.uuid4())
74
+ return m
75
+
76
+
77
+ def interaction(options, trigger="scrollIntoView"):
78
+ """The envelope that is already verified: type, options, action slot, actions."""
79
+ ix = str(uuid.uuid4())
80
+ return [{
81
+ "type": trigger, "uuid": ix,
82
+ "%sOptions" % trigger: {
83
+ "name": "Probe", "ID": ix,
84
+ "actionSlots": {trigger: {"actions": [
85
+ {"type": "animation", "uuid": str(uuid.uuid4()),
86
+ "animationOptions": options}]}},
87
+ }}]
88
+
89
+
90
+ def candidates():
91
+ """Each case is (label, note, interactions). Negative controls included on
92
+ purpose: a candidate that works is only informative next to the one that does
93
+ not, and the pair is what identifies the field that matters."""
94
+ mid = str(uuid.uuid4())
95
+ cases = []
96
+
97
+ cases.append(("metas-with-uuid",
98
+ "the hypothesis: DataArray items carry their own uuid",
99
+ interaction({
100
+ "propertyMetas": [meta(OPACITY, mid)],
101
+ "initial": {OPACITY: 0},
102
+ "keyframes": [{"uuid": str(uuid.uuid4()),
103
+ "progressData": {"delay": 0, "duration": 100},
104
+ "properties": {OPACITY: 1}}]})))
105
+
106
+ cases.append(("metas-without-uuid",
107
+ "NEGATIVE CONTROL - identical but the meta has no uuid",
108
+ interaction({
109
+ "propertyMetas": [meta(OPACITY, with_uuid=False)],
110
+ "initial": {OPACITY: 0},
111
+ "keyframes": [{"uuid": str(uuid.uuid4()),
112
+ "progressData": {"delay": 0, "duration": 100},
113
+ "properties": {OPACITY: 1}}]})))
114
+
115
+ kid = str(uuid.uuid4())
116
+ cases.append(("properties-keyed-by-meta-uuid",
117
+ "NEGATIVE CONTROL - properties keyed by the meta's uuid, not name",
118
+ interaction({
119
+ "propertyMetas": [meta(OPACITY, kid)],
120
+ "initial": {kid: 0},
121
+ "keyframes": [{"uuid": str(uuid.uuid4()),
122
+ "progressData": {"delay": 0, "duration": 100},
123
+ "properties": {kid: 1}}]})))
124
+
125
+ cases.append(("no-initial",
126
+ "does the initial block have to be there?",
127
+ interaction({
128
+ "propertyMetas": [meta(OPACITY)],
129
+ "keyframes": [{"uuid": str(uuid.uuid4()),
130
+ "progressData": {"delay": 0, "duration": 100},
131
+ "properties": {OPACITY: 1}}]})))
132
+
133
+ cases.append(("two-properties",
134
+ "more than one meta, and a transform among them",
135
+ interaction({
136
+ "propertyMetas": [meta(OPACITY), meta(MOVE_Y)],
137
+ "initial": {OPACITY: 0, MOVE_Y: "40px"},
138
+ "keyframes": [{"uuid": str(uuid.uuid4()),
139
+ "progressData": {"delay": 0, "duration": 100},
140
+ "properties": {OPACITY: 1, MOVE_Y: "0px"}}]})))
141
+
142
+ cases.append(("two-keyframes",
143
+ "a real timeline rather than a single step",
144
+ interaction({
145
+ "propertyMetas": [meta(OPACITY)],
146
+ "initial": {OPACITY: 0},
147
+ "keyframes": [
148
+ {"uuid": str(uuid.uuid4()),
149
+ "progressData": {"delay": 0, "duration": 50},
150
+ "properties": {OPACITY: 1}},
151
+ {"uuid": str(uuid.uuid4()),
152
+ "progressData": {"delay": 50, "duration": 50},
153
+ "properties": {OPACITY: 0.35}}]})))
154
+
155
+ cases.append(("timed-trigger",
156
+ "the timed family rather than the progress family",
157
+ interaction({
158
+ "propertyMetas": [meta(OPACITY)],
159
+ "initial": {OPACITY: 0},
160
+ "keyframes": [{"uuid": str(uuid.uuid4()),
161
+ "progressData": {"delay": 0, "duration": 400},
162
+ "properties": {OPACITY: 1}}]},
163
+ trigger="pointerEnter")))
164
+ return cases
165
+
166
+
167
+ def probe_tree(cases):
168
+ """One div per case, each carrying its candidate and nothing else that could
169
+ explain a difference."""
170
+ return {
171
+ "type": "div", "data": {"attrID": "ix-root"},
172
+ "style": {"&": {"_": {"paddingTop": "40px", "paddingBottom": "600px",
173
+ "display": "grid", "rowGap": "300px"}}},
174
+ "children": [
175
+ {"type": "div",
176
+ "data": {"attrID": "ix-%s" % label, "interactions": ix},
177
+ "style": {"&": {"_": {"height": "80px",
178
+ "backgroundColor": "rgb(9,99,199)"}}},
179
+ "children": [{"type": "text",
180
+ "data": {"tagName": "p", "attrID": "ix-%s-t" % label},
181
+ "text": label}]}
182
+ for label, _note, ix in cases],
183
+ }
184
+
185
+
186
+ def fetch(url):
187
+ sep = "&" if "?" in url else "?"
188
+ req = urllib.request.Request(
189
+ "%s%s_v=%d" % (url, sep, int(time.time() * 1000)),
190
+ headers={"User-Agent": "Mozilla/5.0", "Cache-Control": "no-cache"})
191
+ with urllib.request.urlopen(req, timeout=90) as r:
192
+ return r.read().decode("utf-8", "replace")
193
+
194
+
195
+ def payload_of(html):
196
+ m = re.search(r"var mosaicInteractions\s*=\s*(\[.*?\]);", html, re.S)
197
+ if not m:
198
+ return None
199
+ try:
200
+ return json.loads(m.group(1))
201
+ except ValueError:
202
+ return None
203
+
204
+
205
+ def judge(entry):
206
+ """(status, evidence). The question is only ever whether a property arrived."""
207
+ if entry is None:
208
+ return "NO_PAYLOAD", "no entry for this trigger selector"
209
+ actions = []
210
+ for slot in (entry.get("action") or {}).values():
211
+ actions += (slot or {}).get("actions") or []
212
+ if not actions:
213
+ return "NO_PAYLOAD", "entry present but carries no actions"
214
+ props, timing = set(), 0
215
+ for act in actions:
216
+ opts = act.get("animationOptions") or {}
217
+ for tl in (opts.get("timelines") or {}).values():
218
+ for kf in tl.get("keyframes") or []:
219
+ timing += 1
220
+ props |= set((kf.get("properties") or {}).keys())
221
+ props |= set((tl.get("initial") or {}).keys())
222
+ if props:
223
+ return "BOUND", "%d keyframes driving %s" % (timing, ",".join(sorted(props)))
224
+ return "TIMING_ONLY", "%d keyframes, no properties" % timing
225
+
226
+
227
+ def stored_interactions(client, master, template):
228
+ """The rows as the database holds them.
229
+
230
+ Without this the tool cannot tell a candidate REJECTED AT PARSE from one that
231
+ stored perfectly and was dropped on export, and those have opposite fixes. It is
232
+ also how the two-pass finding was made: `propertyMetas` turned out to be stored
233
+ exactly as sent while `initial` and the keyframes' `properties` were not."""
234
+ from sweep_node_types import unwrap # noqa: E402
235
+ doc = unwrap(client.get("templateDocumentInstance/%s/%s" % (master, template)),
236
+ "templateDocumentInstance")
237
+ out = {}
238
+ for node in doc.get("node/template/%s" % template, []):
239
+ data = node.get("data") or {}
240
+ if str(data.get("attrID", "")).startswith("ix-"):
241
+ out[data["attrID"]] = {"node": node, "interactions": data.get("interactions")}
242
+ return out
243
+
244
+
245
+ def second_pass(client, master, template, cases):
246
+ """Re-commit the same nodes, unchanged, as an UPDATE.
247
+
248
+ The reason this exists is the whole finding. A keyframe's `properties` and the
249
+ `initial` block are not statically declared: their descriptors are created during
250
+ sync, one per property meta, by `syncAttachedPropertyMetas()`. On the commit that
251
+ first introduces the metas, the raw values are parsed BEFORE those descriptors
252
+ exist, so every property is an unknown key and is dropped - silently, with the
253
+ metas themselves stored perfectly, which is what makes it look like the metas are
254
+ the problem.
255
+
256
+ On a SECOND commit the stored metas are loaded and synced first, the descriptors
257
+ are therefore already in place, and the same values are accepted. So the payload
258
+ is not a shape problem at all. It is an ordering one."""
259
+ from sweep_node_types import envelopes, exceptions_of, unwrap # noqa: E402
260
+ instance = "templateDocumentInstance/%s/%s" % (master, template)
261
+ doc = unwrap(client.get(instance), "templateDocumentInstance")
262
+ key = "node/template/%s" % template
263
+ want = {"ix-%s" % label: ix for label, _n, ix in cases}
264
+
265
+ revisions = []
266
+ for node in doc.get(key, []):
267
+ attr = (node.get("data") or {}).get("attrID")
268
+ if attr in want:
269
+ fresh = dict(node["data"], interactions=want[attr])
270
+ revisions.append({"newRevisionRecord": dict(node, data=fresh),
271
+ "originalRevisionRecord": node})
272
+ if not revisions:
273
+ return
274
+ resp = client.commit(instance, envelopes(doc), {key: revisions})
275
+ err = exceptions_of(resp)
276
+ print("second pass: re-sent %d nodes%s"
277
+ % (len(revisions), (" exceptions=%s" % err) if err else ""))
278
+
279
+
280
+ def main():
281
+ ap = argparse.ArgumentParser()
282
+ ap.add_argument("--config", required=True)
283
+ ap.add_argument("--post", type=int, required=True)
284
+ ap.add_argument("--slug", required=True)
285
+ ap.add_argument("--csv")
286
+ ap.add_argument("--second-pass", action="store_true",
287
+ help="re-commit the same nodes once more before judging")
288
+ a = ap.parse_args()
289
+
290
+ cfg = json.load(open(a.config, encoding="utf-8"))
291
+ client, surface = Client(cfg), Surface()
292
+ cases = candidates()
293
+
294
+ site = {"pages": [], "shell": {}}
295
+ master = build_shell(client, cfg, site, surface)
296
+ template = bind_page(client, cfg, master, a.slug, a.post)
297
+ n = build_page_document(client, cfg, master, template, probe_tree(cases), surface)
298
+
299
+ if a.second_pass:
300
+ second_pass(client, master, template, cases)
301
+
302
+ url = "%s/%s/" % (cfg["base"].rstrip("/"), a.slug)
303
+ html = fetch(url)
304
+ print("%s %d bytes %d nodes\n" % (url, len(html), n))
305
+
306
+ stored = stored_interactions(client, master, template)
307
+
308
+ payload = payload_of(html)
309
+ if payload is None:
310
+ sys.exit("no `var mosaicInteractions` on the page at all - nothing to judge")
311
+
312
+ # The frontend keys each entry by the trigger's generated class, so map each probe
313
+ # id to its class the same way verify_rwd does, then match entries by that class.
314
+ by_class = dict(re.findall(r'id="(ix-[^"]+)"[^>]*class="(M_EL\d+)', html))
315
+ entries = {e.get("triggerSelector", "").lstrip("."): e for e in payload}
316
+
317
+ rows, solved = [], 0
318
+ for label, note, _ix in cases:
319
+ cls = by_class.get("ix-%s" % label)
320
+ status, evidence = judge(entries.get(cls))
321
+ if cls is None:
322
+ status, evidence = "DROPPED", "probe element not in the delivered HTML"
323
+ kept = (stored.get("ix-%s" % label) or {}).get("interactions")
324
+ metas, props = 0, 0
325
+ for item in (kept if isinstance(kept, list) else []):
326
+ for opt in item.values():
327
+ if not isinstance(opt, dict):
328
+ continue
329
+ for slot in (opt.get("actionSlots") or {}).values():
330
+ for act in (slot or {}).get("actions") or []:
331
+ ao = act.get("animationOptions") or {}
332
+ metas += len(ao.get("propertyMetas") or [])
333
+ props += len(ao.get("initial") or {})
334
+ for kf in ao.get("keyframes") or []:
335
+ props += len(kf.get("properties") or {})
336
+ kept_note = "stored: %d metas, %d property values" % (metas, props)
337
+ solved += status == "BOUND"
338
+ rows.append([label, note, status, evidence, kept_note])
339
+ print(" %-30s %-12s %-32s %s" % (label, status, evidence, kept_note))
340
+
341
+ print("\n%d of %d candidates bound their properties" % (solved, len(cases)))
342
+ if a.csv:
343
+ with open(a.csv, "w", newline="", encoding="utf-8") as fh:
344
+ w = csv.writer(fh)
345
+ w.writerow(["candidate", "note", "status", "evidence", "stored"])
346
+ w.writerows(rows)
347
+ print("wrote", a.csv)
348
+ sys.exit(0 if solved else 1)
349
+
350
+
351
+ if __name__ == "__main__":
352
+ main()
@@ -28,6 +28,19 @@ Statuses
28
28
  APPLIED the property changed the delivered HTML in the way it claims to
29
29
  NO_EFFECT correctly shaped value, committed, nothing changed in the markup
30
30
  SKIPPED no value could be derived from the validator chain; NOT a pass
31
+ INSTRUMENT this sweep USES the property to run itself, so it cannot also be the
32
+ subject - but another pass covers it, named per row. NOT a pass here
33
+
34
+ `INSTRUMENT` exists because the alternative is a lie in either direction. This sweep
35
+ finds each probe node by its `attrID` and reads its `style` back out of the compiled
36
+ CSS; asking it to probe those two is asking a ruler to measure itself, and it
37
+ returned `SKIPPED` for both. But `SKIPPED` reads as "nobody checked", and `attrID`
38
+ and `style` are in fact the two most heavily asserted properties in the skill: every
39
+ row of `style-verification.csv` is a `style` assertion and every row of
40
+ `rwd-verification.csv` is a `style` assertion located by `attrID`. Relabelling them
41
+ as passes would be the blind-spot-scored-as-success failure this file was written to
42
+ avoid; leaving them as SKIPPED understates the evidence by nearly seven hundred rows.
43
+ So they get their own status, and it carries the count and the file that holds it.
31
44
  """
32
45
  import argparse
33
46
  import csv
@@ -163,8 +176,32 @@ def main():
163
176
  for r in types.values():
164
177
  by_class.setdefault(r["data_class"], []).append(r["type"])
165
178
 
179
+ # The count is read out of the covering CSV at run time rather than typed in, so
180
+ # the claim cannot drift away from the evidence it points at.
181
+ def covering(files):
182
+ parts = []
183
+ for name in files:
184
+ path = os.path.join(here, "..", "data", name + ".csv")
185
+ if not os.path.exists(path):
186
+ continue
187
+ with open(path, encoding="utf-8", newline="") as fh:
188
+ n = sum(1 for _ in csv.DictReader(fh))
189
+ parts.append("%s (%d rows)" % (name + ".csv", n))
190
+ return "; ".join(parts)
191
+
192
+ INSTRUMENT = {
193
+ "attrID": ["style-verification", "rwd-verification"],
194
+ "style": ["style-verification", "rwd-verification"],
195
+ }
196
+
166
197
  plan = []
167
198
  for row in props:
199
+ if row["property"] in INSTRUMENT:
200
+ plan.append((None, row["property"], None,
201
+ ("INSTRUMENT", "this sweep locates and reads nodes THROUGH "
202
+ "it; asserted instead by " +
203
+ covering(INSTRUMENT[row["property"]]))))
204
+ continue
168
205
  value = probe_value(row)
169
206
  if value is None:
170
207
  plan.append((None, row["property"], None, "SKIPPED"))
@@ -245,7 +282,9 @@ def main():
245
282
  status = "EDITOR_ONLY"
246
283
  rows.append([prop, host, json.dumps(value, ensure_ascii=False), status, evidence])
247
284
  for host, prop, value, pre in plan:
248
- if pre in ("SKIPPED", "NO_HOST"):
285
+ if isinstance(pre, tuple):
286
+ rows.append([prop, "", "", pre[0], pre[1]])
287
+ elif pre in ("SKIPPED", "NO_HOST"):
249
288
  rows.append([prop, "", "", pre, ""])
250
289
 
251
290
  counts = {}
@@ -253,7 +292,7 @@ def main():
253
292
  counts[r[3]] = counts.get(r[3], 0) + 1
254
293
  print()
255
294
  for k in ("APPLIED", "NO_EFFECT", "EDITOR_ONLY", "NO_ELEMENT", "NO_HOST",
256
- "SKIPPED"):
295
+ "INSTRUMENT", "SKIPPED"):
257
296
  if counts.get(k):
258
297
  print(" %-12s %d" % (k, counts[k]))
259
298
  applied = sorted({r[0] for r in rows if r[3] == "APPLIED"})
@@ -0,0 +1,225 @@
1
+ #!/usr/bin/env python3
2
+ """Write a declaration under every style state and check the selector it compiled to.
3
+
4
+ python tools/sweep_style_states.py --config c.json --post 20 --slug probe-lab
5
+ python tools/sweep_style_states.py --config c.json --post 20 --slug probe-lab \
6
+ --csv data/style-state-verification.csv
7
+
8
+ `data/style-states.csv` lists 53 states, each with the selector template Mosaic
9
+ claims it produces. Exactly one of them - `hover` - had ever been written to a page.
10
+ The other 52 were a table read off the source, which is the kind of claim this skill
11
+ is supposed to refuse.
12
+
13
+ The states are not interchangeable. Eight are `global` and go on anything; the
14
+ remaining 45 are `node-type` scoped and only exist on one type - `___tab--active` is
15
+ meaningless on a div. So each state is probed on a host derived from the factory file
16
+ it was declared in, and where that host is a type the node sweep measured as unsafe
17
+ to commit, the state is `NO_HOST` rather than a failure: **the state was not tested,
18
+ and saying so is the whole point.**
19
+
20
+ The probe value is `letterSpacing`, which the style sweep measured as COMPILED and
21
+ ungrouped, at a pixel value unique to each state - so a match proves *that* state
22
+ produced *that* rule, rather than proving some rule exists somewhere.
23
+
24
+ Statuses
25
+ --------
26
+ COMPILED a rule with this state's selector carries the declaration
27
+ SELECTOR the declaration compiled, but under a selector that is not the one
28
+ `style-states.csv` promises - the table is wrong for this state
29
+ ABSENT committed, and no rule carrying the value exists at all
30
+ NO_HOST the state belongs to a node type that cannot be safely committed
31
+ SKIPPED no host could be derived from the source path; NOT a pass
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import argparse
36
+ import csv
37
+ import json
38
+ import os
39
+ import re
40
+ import sys
41
+ import time
42
+ import urllib.error
43
+ import urllib.request
44
+
45
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
46
+ from build_page import Surface # noqa: E402
47
+ from build_site import bind_page, build_page_document, build_shell # noqa: E402
48
+ from sweep_node_types import Client # noqa: E402
49
+
50
+ DATA = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data")
51
+ UNSAFE = {"COMMIT_500", "COMMIT_502", "BROKE_PAGE"}
52
+
53
+
54
+ def load(name):
55
+ with open(os.path.join(DATA, name), encoding="utf-8", newline="") as fh:
56
+ return list(csv.DictReader(fh))
57
+
58
+
59
+ def hosts_by_factory():
60
+ """type -> factory file, so a state's `declared_in` can name its host.
61
+
62
+ A node-type state is declared inside the factory of the type it belongs to, which
63
+ is the only link between the two tables. `StatesMeta.php` declares the global
64
+ ones and belongs to no type."""
65
+ return {r["file"]: r["type"] for r in load("node-types.csv")}
66
+
67
+
68
+ def plan():
69
+ types = hosts_by_factory()
70
+ outcome = {r["type"]: r["outcome"] for r in load("node-verification.csv")}
71
+ out = []
72
+ for i, row in enumerate(load("style-states.csv")):
73
+ state, tpl, scope = row["state"], row["selector_template"], row["scope"]
74
+ if state == "&":
75
+ continue # the base state is what everything else is
76
+ if scope == "global": # measured against 200-1000 elsewhere; any
77
+ host, why = "div", "" # element will do
78
+ else:
79
+ host = types.get(row["declared_in"], "")
80
+ why = "" if host else "no node type declares %s" % row["declared_in"]
81
+ if host and outcome.get(host) in UNSAFE:
82
+ why = "host %s is %s - committing it breaks the page" % (
83
+ host, outcome[host])
84
+ out.append({"state": state, "template": tpl, "scope": scope, "host": host,
85
+ "blocked": why, "value": "%dpx" % (301 + i)})
86
+ return out
87
+
88
+
89
+ def tree_for(cases):
90
+ """One node per testable state, each carrying that state and nothing else."""
91
+ kids = []
92
+ for c in cases:
93
+ if c["blocked"] or not c["host"]:
94
+ continue
95
+ c.pop("attr", None)
96
+ kids.append({
97
+ "type": c["host"],
98
+ "data": {"attrID": "st-%d" % len(kids)},
99
+ "style": {"&": {"_": {"paddingTop": "2px"}},
100
+ c["state"]: {"_": {"letterSpacing": c["value"]}}},
101
+ "children": [],
102
+ })
103
+ c["attr"] = "st-%d" % (len(kids) - 1)
104
+ return {"type": "div", "data": {"attrID": "st-root"},
105
+ "style": {"&": {"_": {"paddingTop": "20px", "paddingBottom": "20px"}}},
106
+ "children": kids}
107
+
108
+
109
+ def fetch(url):
110
+ sep = "&" if "?" in url else "?"
111
+ req = urllib.request.Request("%s%s_v=%d" % (url, sep, int(time.time() * 1000)),
112
+ headers={"User-Agent": "Mozilla/5.0",
113
+ "Cache-Control": "no-cache"})
114
+ # Never raise on an HTTP error. A probe page that 500s is a RESULT - it means one
115
+ # of the hosts cannot live where it was put - and a tool that dies on it reports
116
+ # nothing about the other fifty states.
117
+ try:
118
+ with urllib.request.urlopen(req, timeout=90) as r:
119
+ return r.read().decode("utf-8", "replace")
120
+ except urllib.error.HTTPError as e:
121
+ return e.read().decode("utf-8", "replace")
122
+
123
+
124
+ def norm(text):
125
+ return re.sub(r"\s+", "", (text or "")).lower()
126
+
127
+
128
+ def main():
129
+ ap = argparse.ArgumentParser()
130
+ ap.add_argument("--config", required=True)
131
+ ap.add_argument("--post", type=int, required=True)
132
+ ap.add_argument("--slug", required=True)
133
+ ap.add_argument("--csv")
134
+ a = ap.parse_args()
135
+
136
+ cfg = json.load(open(a.config, encoding="utf-8"))
137
+ client, surface = Client(cfg), Surface()
138
+ cases = plan()
139
+ testable = [c for c in cases if not c["blocked"] and c["host"]]
140
+
141
+ # ONE BATCH PER HOST TYPE. The first version of this tool put every host on a
142
+ # single page, and one type that cannot live under a plain div took the whole
143
+ # page to HTTP 500 - so all 37 probed states came back ABSENT, including the
144
+ # seven on a div that were certainly fine. A sweep whose failures are contagious
145
+ # measures its worst host rather than its surface.
146
+ batches, order = {}, []
147
+ for c in testable:
148
+ if c["host"] not in batches:
149
+ batches[c["host"]], _ = [], order.append(c["host"])
150
+ batches[c["host"]].append(c)
151
+
152
+ url = "%s/%s/" % (cfg["base"].rstrip("/"), a.slug)
153
+ site = {"pages": [], "shell": {}}
154
+ cls_of, rules_of, page_ok = {}, {}, {}
155
+ for host in order:
156
+ master = build_shell(client, cfg, site, surface)
157
+ template = bind_page(client, cfg, master, a.slug, a.post)
158
+ build_page_document(client, cfg, master, template,
159
+ tree_for(batches[host]), surface)
160
+ html = fetch(url)
161
+ # Health is decided by DIRECT EVIDENCE, not by a byte count. The node sweep's
162
+ # 2,000-byte threshold is too low for this shell: a host whose page came back
163
+ # at 2,645 bytes - Mosaic's error string wrapped in the theme - passed it, and
164
+ # its state was then reported ABSENT when in truth it was never tested. The
165
+ # probe root either rendered or it did not, and that is the same question.
166
+ healthy = 'id="st-root"' in html
167
+ page_ok[host] = healthy
168
+ print(" host %-28s %-7s %6d bytes %d states"
169
+ % (host, "ok" if healthy else "BROKEN", len(html), len(batches[host])))
170
+ for attr, cls in re.findall(r'id="(st-\d+)"[^>]*class="(M_EL\d+)', html):
171
+ cls_of[(host, attr)] = cls
172
+ rules_of[host] = re.findall(r"([^{}]+)\{([^{}]*)\}", html)
173
+ print()
174
+
175
+ rows, counts = [], {}
176
+ for c in cases:
177
+ host = c.get("host")
178
+ rules = rules_of.get(host, [])
179
+ state, value = c["state"], c["value"]
180
+ if c["blocked"]:
181
+ status, evidence = ("NO_HOST" if c["host"] else "SKIPPED"), c["blocked"]
182
+ elif (host, c.get("attr")) not in cls_of:
183
+ status, evidence = (("BROKE_PAGE", "this host's page did not render")
184
+ if not page_ok.get(host, True)
185
+ else ("ABSENT", "probe element never reached the page"))
186
+ else:
187
+ cls = cls_of[(host, c["attr"])]
188
+ hit = [sel for sel, body in rules
189
+ if "letter-spacing:%s" % value in norm(body)]
190
+ if not hit:
191
+ status, evidence = "ABSENT", "no rule anywhere carries %s" % value
192
+ else:
193
+ want = norm(c["template"].replace("&", "." + cls))
194
+ exact = [s for s in hit if want in norm(s)]
195
+ if exact:
196
+ status, evidence = "COMPILED", exact[0].strip()[:90]
197
+ else:
198
+ status, evidence = "SELECTOR", hit[0].strip()[:90]
199
+ counts[status] = counts.get(status, 0) + 1
200
+ rows.append([state, c["scope"], c["host"], c["template"], status, evidence])
201
+
202
+ for state, scope, host, tpl, status, evidence in rows:
203
+ if status != "COMPILED":
204
+ print(" %-44s %-9s %s" % (state, status, evidence[:60]))
205
+ print()
206
+ for k in ("COMPILED", "SELECTOR", "ABSENT", "BROKE_PAGE", "NO_HOST",
207
+ "SKIPPED"):
208
+ if counts.get(k):
209
+ print(" %-10s %d" % (k, counts[k]))
210
+ print("\n%d of %d probed states compiled to the selector the table promises."
211
+ % (counts.get("COMPILED", 0), len(testable)))
212
+ print("NO_HOST and SKIPPED are states that were NOT tested; they are not passes.")
213
+
214
+ if a.csv:
215
+ with open(a.csv, "w", newline="", encoding="utf-8") as fh:
216
+ w = csv.writer(fh)
217
+ w.writerow(["state", "scope", "host", "selector_template", "status",
218
+ "evidence"])
219
+ w.writerows(rows)
220
+ print("\nwrote", a.csv)
221
+ sys.exit(1 if counts.get("ABSENT") or counts.get("SELECTOR") else 0)
222
+
223
+
224
+ if __name__ == "__main__":
225
+ main()