mosaic-headless 1.3.0 → 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.
- package/README.ja.md +4 -1
- package/README.ko.md +4 -1
- package/README.md +4 -1
- package/README.zh-TW.md +4 -1
- package/SKILL.md +37 -7
- package/assets/templates/platforms/claude-ai.json +1 -1
- package/assets/templates/platforms/claude-code.json +1 -1
- package/assets/templates/platforms/codex-cli.json +1 -1
- package/assets/templates/platforms/copilot.json +1 -1
- package/assets/templates/platforms/gemini-cli.json +1 -1
- package/bin/check-release.mjs +3 -1
- package/data/browser-verification.csv +45 -0
- package/data/interaction-verification.csv +8 -0
- package/data/style-state-verification.csv +53 -0
- package/package.json +1 -1
- package/references/interactions.md +53 -8
- package/sites/_moksa.py +29 -5
- package/sites/moksa.json +75 -5
- package/tools/mo.py +35 -6
- package/tools/sweep_interactions.py +352 -0
- package/tools/sweep_style_states.py +225 -0
|
@@ -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()
|
|
@@ -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()
|