mosaic-headless 1.2.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/LICENSE +21 -0
- package/README.ja.md +94 -0
- package/README.md +133 -0
- package/README.zh-CN.md +87 -0
- package/README.zh-TW.md +87 -0
- package/SKILL.md +353 -0
- package/assets/templates/platforms/claude-ai.json +34 -0
- package/assets/templates/platforms/claude-code.json +28 -0
- package/assets/templates/platforms/codex-cli.json +33 -0
- package/assets/templates/platforms/continue.json +31 -0
- package/assets/templates/platforms/copilot.json +46 -0
- package/assets/templates/platforms/cursor.json +36 -0
- package/assets/templates/platforms/gemini-cli.json +33 -0
- package/assets/templates/platforms/windsurf.json +34 -0
- package/bin/check-release.mjs +143 -0
- package/bin/install.mjs +452 -0
- package/bin/sync-version.mjs +63 -0
- package/data/animatable-properties.csv +23 -0
- package/data/condition-comparators.csv +13 -0
- package/data/condition-subjects.csv +60 -0
- package/data/db-columns.csv +207 -0
- package/data/default-children.csv +11 -0
- package/data/dynamic-variables.csv +75 -0
- package/data/element-classes.csv +152 -0
- package/data/evaluator-functions.csv +20 -0
- package/data/interaction-types.csv +13 -0
- package/data/node-properties.csv +182 -0
- package/data/node-property-verification.csv +182 -0
- package/data/node-types.csv +123 -0
- package/data/node-verification.csv +123 -0
- package/data/placement-rules.csv +123 -0
- package/data/pluggables.csv +208 -0
- package/data/property-verification.csv +171 -0
- package/data/rest-routes.csv +115 -0
- package/data/rwd-verification.csv +570 -0
- package/data/style-properties.csv +99 -0
- package/data/style-states.csv +54 -0
- package/data/style-value-shapes.csv +23 -0
- package/data/style-verification.csv +99 -0
- package/package.json +59 -0
- package/references/data-model.md +95 -0
- package/references/design-system.md +118 -0
- package/references/dynamic-content.md +113 -0
- package/references/failure-modes.md +182 -0
- package/references/interactions.md +126 -0
- package/references/placement.md +117 -0
- package/references/responsive.md +174 -0
- package/references/styling.md +172 -0
- package/references/templates-and-conditions.md +122 -0
- package/references/vs-elementor-gutenberg.md +73 -0
- package/references/write-protocol.md +79 -0
- package/sites/_moksa.py +1165 -0
- package/sites/moksa.json +8685 -0
- package/tools/bootstrap_probe_theme.php +68 -0
- package/tools/build_all.py +55 -0
- package/tools/build_page.py +352 -0
- package/tools/build_report.py +221 -0
- package/tools/build_site.py +174 -0
- package/tools/capture_live.py +130 -0
- package/tools/check_placement_predicts.py +72 -0
- package/tools/copy_styles.py +204 -0
- package/tools/extract_default_children.py +94 -0
- package/tools/extract_dynamic_variables.py +104 -0
- package/tools/extract_interactions.py +98 -0
- package/tools/extract_node_types.py +165 -0
- package/tools/extract_placement.py +135 -0
- package/tools/extract_pluggables.py +90 -0
- package/tools/extract_style_properties.py +163 -0
- package/tools/mint_session.php +52 -0
- package/tools/probe.py +144 -0
- package/tools/sweep_node_properties.py +271 -0
- package/tools/sweep_node_types.py +318 -0
- package/tools/sweep_properties.py +215 -0
- package/tools/sweep_style_properties.py +254 -0
- package/tools/theme_export.php +91 -0
- package/tools/theme_import.php +113 -0
- package/tools/verify_rwd.py +278 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Re-probe node properties with a value shaped by their own validator chain.
|
|
3
|
+
|
|
4
|
+
python sweep_node_properties.py --config sweep.json --page moksa --csv out.csv
|
|
5
|
+
|
|
6
|
+
Why this exists
|
|
7
|
+
---------------
|
|
8
|
+
`data/property-verification.csv` reports 91 of 170 probes as NO_EFFECT. That number
|
|
9
|
+
is not a finding about Mosaic - it is an artefact of the probe. Every property in
|
|
10
|
+
that run was sent the same string, `MPROP0000X`, regardless of what it wanted:
|
|
11
|
+
|
|
12
|
+
cssClasses ValidatorArray -> wanted a list
|
|
13
|
+
locked ValidatorBoolean -> wanted true/false
|
|
14
|
+
required ValidatorInteger -> wanted a number
|
|
15
|
+
target ValidatorAccepted… -> wanted one of _self|_blank|_parent|_top
|
|
16
|
+
tagName ValidatorTagName -> wanted a tag name
|
|
17
|
+
|
|
18
|
+
The tell is that `tagName` and `attrID` are both in the NO_EFFECT list while the
|
|
19
|
+
entire demo site is built on them. Same signature as the style sweep, where `color`
|
|
20
|
+
and `paddingTop` came back ABSENT because the probe nodes were orphaned: when a
|
|
21
|
+
sweep says something you know to be false, the sweep is what is broken.
|
|
22
|
+
|
|
23
|
+
So this one derives the probe value from the declared validator chain, and asserts
|
|
24
|
+
against the delivered markup rather than against a marker in the text.
|
|
25
|
+
|
|
26
|
+
Statuses
|
|
27
|
+
--------
|
|
28
|
+
APPLIED the property changed the delivered HTML in the way it claims to
|
|
29
|
+
NO_EFFECT correctly shaped value, committed, nothing changed in the markup
|
|
30
|
+
SKIPPED no value could be derived from the validator chain; NOT a pass
|
|
31
|
+
"""
|
|
32
|
+
import argparse
|
|
33
|
+
import csv
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import re
|
|
37
|
+
import sys
|
|
38
|
+
|
|
39
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
40
|
+
from build_page import Surface, flatten # noqa: E402
|
|
41
|
+
from sweep_node_types import Client, envelopes, exceptions_of, unwrap # noqa: E402
|
|
42
|
+
|
|
43
|
+
MARK = "mprobe37"
|
|
44
|
+
# 7 collided with the attrID (np-037) and the generated class (M_EL37), turning
|
|
45
|
+
# substring checks into false APPLIEDs. Use a number that cannot occur by chance.
|
|
46
|
+
MARK_INT = 371337
|
|
47
|
+
|
|
48
|
+
# The base class every element data class extends. Properties declared on it apply
|
|
49
|
+
# to every node type, so there is no concrete type to look them up by.
|
|
50
|
+
ABSTRACT = "ElementMResourceDataAbstract"
|
|
51
|
+
|
|
52
|
+
# Properties that are editor state by definition - they describe the node to the
|
|
53
|
+
# builder UI and are not meant to reach the page. Probed anyway, but a NO_EFFECT on
|
|
54
|
+
# these is the correct answer rather than a gap.
|
|
55
|
+
EDITOR_ONLY = {"name", "locked", "currentStatus", "override"}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
ALPHA = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def ordering(i):
|
|
62
|
+
"""A monotonic fractional index with room for every probe.
|
|
63
|
+
|
|
64
|
+
build_page.ordering_for() wraps after 62 entries, so `i % 62` hands two siblings
|
|
65
|
+
the SAME ordering - and Mosaic drops the collisions silently. That is how a run
|
|
66
|
+
of 79 probes left 39 nodes in the database and still reported a result.
|
|
67
|
+
"""
|
|
68
|
+
return "a" + ALPHA[i // 62] + ALPHA[i % 62]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def kebab(name):
|
|
72
|
+
return re.sub(r"(?<!^)(?=[A-Z])", "-", name).lower()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def probe_value(row):
|
|
76
|
+
"""Derive a value from the validator chain, or None if we cannot."""
|
|
77
|
+
prop = row["property"]
|
|
78
|
+
chain = row.get("validators") or ""
|
|
79
|
+
enum = [v for v in (row.get("accepted_values") or "").split("|") if v]
|
|
80
|
+
|
|
81
|
+
if prop == "attrID":
|
|
82
|
+
return None # the sweep's own handle; cannot also test it
|
|
83
|
+
if prop == "style":
|
|
84
|
+
return None # a whole subsystem, swept separately
|
|
85
|
+
if prop == "cssClasses":
|
|
86
|
+
return [MARK + "-cls"]
|
|
87
|
+
if prop == "attributes":
|
|
88
|
+
return [{"name": "data-" + MARK, "value": "yes"}]
|
|
89
|
+
if prop == "tagName":
|
|
90
|
+
return "h4"
|
|
91
|
+
if enum:
|
|
92
|
+
return enum[-1] if enum[-1] != "inPlace" else enum[0]
|
|
93
|
+
if "ValidatorBoolean" in chain:
|
|
94
|
+
return True
|
|
95
|
+
if "ValidatorArray" in chain:
|
|
96
|
+
return [MARK]
|
|
97
|
+
if "ValidatorInteger" in chain:
|
|
98
|
+
return MARK_INT
|
|
99
|
+
if "ValidatorString" in chain or "ValidatorName" in chain:
|
|
100
|
+
return MARK
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def opening_tag(html, attr):
|
|
105
|
+
m = re.search(r"<([a-zA-Z][\w-]*)\s[^>]*id=\"%s\"[^>]*>" % re.escape(attr), html)
|
|
106
|
+
return m.group(0) if m else None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def searchable(tag):
|
|
110
|
+
"""The tag with `id` and `class` removed.
|
|
111
|
+
|
|
112
|
+
Both carry the probe's own index and the generated M_EL number, so leaving them
|
|
113
|
+
in lets a value like `7` match itself and score a false APPLIED.
|
|
114
|
+
"""
|
|
115
|
+
tag = re.sub(r'\sid="[^"]*"', "", tag)
|
|
116
|
+
return re.sub(r'\sclass="[^"]*"', "", tag)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def judge(prop, value, tag):
|
|
120
|
+
"""Did this property change the delivered markup the way it claims to?"""
|
|
121
|
+
if tag is None:
|
|
122
|
+
return "NO_ELEMENT", ""
|
|
123
|
+
body = searchable(tag)
|
|
124
|
+
if prop == "cssClasses":
|
|
125
|
+
return ("APPLIED" if MARK + "-cls" in tag else "NO_EFFECT"), tag[:110]
|
|
126
|
+
if prop == "attributes":
|
|
127
|
+
return ("APPLIED" if "data-" + MARK in body else "NO_EFFECT"), tag[:110]
|
|
128
|
+
if prop == "tagName":
|
|
129
|
+
return ("APPLIED" if tag.startswith("<h4") else "NO_EFFECT"), tag[:60]
|
|
130
|
+
if isinstance(value, str) and value and value in body:
|
|
131
|
+
return "APPLIED", tag[:110]
|
|
132
|
+
if isinstance(value, bool):
|
|
133
|
+
return ("APPLIED" if re.search(r"\b(hidden|disabled|readonly|required)\b", tag)
|
|
134
|
+
else "NO_EFFECT"), tag[:110]
|
|
135
|
+
if isinstance(value, int) and str(value) in body:
|
|
136
|
+
return "APPLIED", tag[:110]
|
|
137
|
+
if isinstance(value, list) and MARK in body:
|
|
138
|
+
return "APPLIED", tag[:110]
|
|
139
|
+
return "NO_EFFECT", tag[:110]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def main():
|
|
143
|
+
ap = argparse.ArgumentParser()
|
|
144
|
+
ap.add_argument("--config", required=True)
|
|
145
|
+
ap.add_argument("--page", required=True)
|
|
146
|
+
ap.add_argument("--csv")
|
|
147
|
+
a = ap.parse_args()
|
|
148
|
+
|
|
149
|
+
cfg = json.load(open(a.config, encoding="utf-8"))
|
|
150
|
+
here = os.path.dirname(os.path.abspath(__file__))
|
|
151
|
+
props = list(csv.DictReader(
|
|
152
|
+
open(os.path.join(here, "..", "data", "node-properties.csv"), encoding="utf-8")))
|
|
153
|
+
types = {r["type"]: r for r in csv.DictReader(
|
|
154
|
+
open(os.path.join(here, "..", "data", "node-types.csv"), encoding="utf-8"))}
|
|
155
|
+
|
|
156
|
+
# A property is probed on a node type that actually declares it: owner_class in
|
|
157
|
+
# node-properties.csv matches data_class in node-types.csv. Properties on the
|
|
158
|
+
# abstract base apply to everything, so those go on a div.
|
|
159
|
+
rendered = {r["type"] for r in csv.DictReader(
|
|
160
|
+
open(os.path.join(here, "..", "data", "node-verification.csv"),
|
|
161
|
+
encoding="utf-8")) if r["outcome"] == "RENDERED"}
|
|
162
|
+
by_class = {}
|
|
163
|
+
for r in types.values():
|
|
164
|
+
by_class.setdefault(r["data_class"], []).append(r["type"])
|
|
165
|
+
|
|
166
|
+
plan = []
|
|
167
|
+
for row in props:
|
|
168
|
+
value = probe_value(row)
|
|
169
|
+
if value is None:
|
|
170
|
+
plan.append((None, row["property"], None, "SKIPPED"))
|
|
171
|
+
continue
|
|
172
|
+
if row["owner_class"] == ABSTRACT:
|
|
173
|
+
hosts = ["div"]
|
|
174
|
+
else:
|
|
175
|
+
hosts = [t for t in by_class.get(row["owner_class"], []) if t in rendered]
|
|
176
|
+
if not hosts:
|
|
177
|
+
plan.append((None, row["property"], None, "NO_HOST"))
|
|
178
|
+
continue
|
|
179
|
+
plan.append((hosts[0], row["property"], value, None))
|
|
180
|
+
|
|
181
|
+
client = Client(cfg)
|
|
182
|
+
tei = unwrap(client.get("adminTemplateEditorInstance"), "adminTemplateEditorInstance")
|
|
183
|
+
master = tei["template"][-1]["masterID"]
|
|
184
|
+
instance = "masterDocumentInstance/%s" % master
|
|
185
|
+
doc = unwrap(client.get(instance), "masterDocumentInstance")
|
|
186
|
+
key = "node/master/%s" % master
|
|
187
|
+
body = next(n for n in doc[key] if n["type"] == "body")
|
|
188
|
+
host_div = next(n for n in doc[key]
|
|
189
|
+
if n["parentID"] == body["ID"] and n["type"] == "div")
|
|
190
|
+
surface = Surface()
|
|
191
|
+
|
|
192
|
+
records, live, moved = [], [], set()
|
|
193
|
+
for i, (host, prop, value, pre) in enumerate(plan):
|
|
194
|
+
if pre is not None or host is None:
|
|
195
|
+
continue
|
|
196
|
+
attr = "np-%03d" % i
|
|
197
|
+
data = {"attrID": attr, prop: value}
|
|
198
|
+
# target and rel are anchor attributes, and button/menu-link only render an
|
|
199
|
+
# <a> when they carry a url - without one they are a <span> and the probe
|
|
200
|
+
# measures the missing companion rather than the property.
|
|
201
|
+
if prop in ("target", "rel"):
|
|
202
|
+
data["url"] = "https://example.com/" + MARK
|
|
203
|
+
node = {"type": host, "data": data}
|
|
204
|
+
if host == "text":
|
|
205
|
+
node["children"] = [{"type": "wysiwyg-text", "data": {"text": MARK}}]
|
|
206
|
+
live.append((attr, host, prop, value))
|
|
207
|
+
# `insertLocation` moves a code node's output out of the tree, so the probe
|
|
208
|
+
# correctly leaves no element where it was written. Absence IS the evidence.
|
|
209
|
+
if prop == "insertLocation" and value != "inPlace":
|
|
210
|
+
moved.add(attr)
|
|
211
|
+
for rec in flatten(node, host_div["ID"], master, surface, True,
|
|
212
|
+
ordering=ordering(i), parent_type="div"):
|
|
213
|
+
records.append({"newRevisionRecord": rec, "originalRevisionRecord": None})
|
|
214
|
+
|
|
215
|
+
print("%d properties, %d probes, %d skipped"
|
|
216
|
+
% (len(props), len(live),
|
|
217
|
+
sum(1 for p in plan if p[3] in ("SKIPPED", "NO_HOST"))))
|
|
218
|
+
resp = client.commit(instance, envelopes(doc), {key: records})
|
|
219
|
+
err = exceptions_of(resp)
|
|
220
|
+
if err:
|
|
221
|
+
sys.exit("commit rejected: %s" % err[:400])
|
|
222
|
+
|
|
223
|
+
html = client.page(a.page)
|
|
224
|
+
rendered = len(set(re.findall(r'id="(np-\d+)"', html)))
|
|
225
|
+
print("probes rendered: %d of %d" % (rendered, len(live)))
|
|
226
|
+
if rendered != len(live) - len(moved):
|
|
227
|
+
# Contamination is the failure mode this sweep is most exposed to: probes
|
|
228
|
+
# from an earlier run share the id space, and two runs can give the same id
|
|
229
|
+
# to different node types. If the page does not carry exactly the probes
|
|
230
|
+
# this run planned, nothing measured from it means anything.
|
|
231
|
+
sys.exit("expected %d probes on the page, found %d - clear previous probes "
|
|
232
|
+
"and re-run before believing any measurement"
|
|
233
|
+
% (len(live) - len(moved), rendered))
|
|
234
|
+
|
|
235
|
+
rows = []
|
|
236
|
+
for attr, host, prop, value in live:
|
|
237
|
+
if attr in moved:
|
|
238
|
+
gone = opening_tag(html, attr) is None
|
|
239
|
+
rows.append([prop, host, json.dumps(value, ensure_ascii=False),
|
|
240
|
+
"APPLIED" if gone else "NO_EFFECT",
|
|
241
|
+
"output relocated out of the tree" if gone else "still in place"])
|
|
242
|
+
continue
|
|
243
|
+
status, evidence = judge(prop, value, opening_tag(html, attr))
|
|
244
|
+
if status == "NO_EFFECT" and prop in EDITOR_ONLY:
|
|
245
|
+
status = "EDITOR_ONLY"
|
|
246
|
+
rows.append([prop, host, json.dumps(value, ensure_ascii=False), status, evidence])
|
|
247
|
+
for host, prop, value, pre in plan:
|
|
248
|
+
if pre in ("SKIPPED", "NO_HOST"):
|
|
249
|
+
rows.append([prop, "", "", pre, ""])
|
|
250
|
+
|
|
251
|
+
counts = {}
|
|
252
|
+
for r in rows:
|
|
253
|
+
counts[r[3]] = counts.get(r[3], 0) + 1
|
|
254
|
+
print()
|
|
255
|
+
for k in ("APPLIED", "NO_EFFECT", "EDITOR_ONLY", "NO_ELEMENT", "NO_HOST",
|
|
256
|
+
"SKIPPED"):
|
|
257
|
+
if counts.get(k):
|
|
258
|
+
print(" %-12s %d" % (k, counts[k]))
|
|
259
|
+
applied = sorted({r[0] for r in rows if r[3] == "APPLIED"})
|
|
260
|
+
print("\n APPLIED:", ", ".join(applied) or "-")
|
|
261
|
+
|
|
262
|
+
if a.csv:
|
|
263
|
+
with open(a.csv, "w", newline="", encoding="utf-8") as fh:
|
|
264
|
+
w = csv.writer(fh)
|
|
265
|
+
w.writerow(["property", "probed_on", "sent", "status", "evidence"])
|
|
266
|
+
w.writerows(sorted(rows))
|
|
267
|
+
print("\nwrote", a.csv)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
if __name__ == "__main__":
|
|
271
|
+
main()
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Place every node type on a real Mosaic document, ONE AT A TIME, and record what happened.
|
|
3
|
+
|
|
4
|
+
python sweep_node_types.py --config sweep.json --setup # build master + template
|
|
5
|
+
python sweep_node_types.py --config sweep.json --sweep # run the isolated sweep
|
|
6
|
+
|
|
7
|
+
sweep.json needs: base, version, cookie, nonce, themeID. --setup fills in masterID,
|
|
8
|
+
templateID and parentNodeID and writes them back.
|
|
9
|
+
|
|
10
|
+
## Why one at a time
|
|
11
|
+
|
|
12
|
+
A batch sweep is worthless here. Mosaic accepts structurally impossible placements at
|
|
13
|
+
commit time - a second `master-root` nested inside a `div` commits fine - and then the
|
|
14
|
+
*whole page* fails to render, emitting a bare string like
|
|
15
|
+
`NodeMResourceFilterFunctionInterface parent is missing` as a 200 with a ~54 byte body.
|
|
16
|
+
One bad type therefore destroys the evidence for every other type in the same batch.
|
|
17
|
+
|
|
18
|
+
So each type gets the document to itself: commit -> render -> classify -> delete. A type
|
|
19
|
+
that breaks the page only reports its own failure, and the next type starts from a clean
|
|
20
|
+
tree. This costs one page load per type and is the only way the numbers mean anything.
|
|
21
|
+
|
|
22
|
+
## Outcomes
|
|
23
|
+
|
|
24
|
+
RENDERED committed, and its attrID appeared in the delivered HTML
|
|
25
|
+
COMMITTED the row exists but nothing reached the page (usually needs content
|
|
26
|
+
or a specific parent to produce output)
|
|
27
|
+
BROKE_PAGE committed, and the page stopped rendering - the silent-failure case
|
|
28
|
+
REJECTED the commit came back with an `exceptions` body (HTTP 200)
|
|
29
|
+
"""
|
|
30
|
+
import argparse
|
|
31
|
+
import csv
|
|
32
|
+
import json
|
|
33
|
+
import re
|
|
34
|
+
import sys
|
|
35
|
+
import urllib.error
|
|
36
|
+
import urllib.parse
|
|
37
|
+
import urllib.request
|
|
38
|
+
import uuid
|
|
39
|
+
|
|
40
|
+
MARKER = "MOSAICSWEEP"
|
|
41
|
+
# types whose job is to hold text get a wysiwyg-text child, so the assertion has
|
|
42
|
+
# something to find beyond the wrapper element
|
|
43
|
+
TEXT_HOLDERS = {"text", "button", "menu-link", "label", "submit-label", "accordion-title"}
|
|
44
|
+
# a healthy render of the probe page is far bigger than this; anything smaller is
|
|
45
|
+
# Mosaic's bare error string rather than a page
|
|
46
|
+
MIN_HEALTHY_BYTES = 2000
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Client:
|
|
50
|
+
def __init__(self, cfg):
|
|
51
|
+
self.cfg = cfg
|
|
52
|
+
self.base = cfg["base"].rstrip("/")
|
|
53
|
+
self.api = "%s/wp-json/mosaic/v%s" % (self.base, cfg["version"])
|
|
54
|
+
|
|
55
|
+
def _call(self, url, form=None):
|
|
56
|
+
data = urllib.parse.urlencode(form).encode() if form else None
|
|
57
|
+
req = urllib.request.Request(url, data=data, method="POST" if form else "GET")
|
|
58
|
+
req.add_header("Cookie", self.cfg["cookie"])
|
|
59
|
+
req.add_header("X-WP-Nonce", self.cfg["nonce"])
|
|
60
|
+
if form:
|
|
61
|
+
req.add_header("Content-Type", "application/x-www-form-urlencoded")
|
|
62
|
+
try:
|
|
63
|
+
with urllib.request.urlopen(req, timeout=120) as r:
|
|
64
|
+
return json.loads(r.read().decode("utf-8"))
|
|
65
|
+
except urllib.error.HTTPError as e:
|
|
66
|
+
return {"_httperror": e.code, "_body": e.read().decode("utf-8", "replace")[:400]}
|
|
67
|
+
|
|
68
|
+
def theme_url(self, tail):
|
|
69
|
+
return "%s/theme/%s/%s" % (self.api, self.cfg["themeID"], tail)
|
|
70
|
+
|
|
71
|
+
def get(self, tail):
|
|
72
|
+
return self._call(self.theme_url(tail))
|
|
73
|
+
|
|
74
|
+
def commit(self, tail, sync, revisions):
|
|
75
|
+
return self._call(
|
|
76
|
+
self.theme_url(tail + "/commit"),
|
|
77
|
+
{"syncCheckEnvelopes": json.dumps(sync), "revisionEnvelopes": json.dumps(revisions)},
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def page(self, path=""):
|
|
81
|
+
"""Fetch a public page, cache-busted. `path` picks a page other than the home page."""
|
|
82
|
+
req = urllib.request.Request("%s/%s?sweep=%s" % (self.base, path.strip("/") + "/" if path else "",
|
|
83
|
+
uuid.uuid4().hex[:8]))
|
|
84
|
+
try:
|
|
85
|
+
with urllib.request.urlopen(req, timeout=120) as r:
|
|
86
|
+
return r.read().decode("utf-8", "replace")
|
|
87
|
+
except urllib.error.HTTPError as e:
|
|
88
|
+
return e.read().decode("utf-8", "replace")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def envelopes(doc):
|
|
92
|
+
return {k: [[x["ID"], x["revision"]] for x in v] for k, v in doc.items()}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def exceptions_of(resp):
|
|
96
|
+
"""Mosaic returns validator rejections as HTTP 200 with an `exceptions` body."""
|
|
97
|
+
if "_httperror" in resp:
|
|
98
|
+
return "http%s: %s" % (resp["_httperror"], resp["_body"][:200])
|
|
99
|
+
for holder in (resp, resp.get("response") or {}):
|
|
100
|
+
if isinstance(holder, dict) and holder.get("exceptions"):
|
|
101
|
+
return json.dumps(holder["exceptions"], ensure_ascii=False)[:300]
|
|
102
|
+
return ""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def unwrap(resp, what):
|
|
106
|
+
if "_httperror" in resp or "response" not in resp:
|
|
107
|
+
sys.exit("%s failed: %s" % (what, json.dumps(resp)[:400]))
|
|
108
|
+
return resp["response"]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def setup(client, cfg, config_path):
|
|
112
|
+
"""Create the master (which heals into a default node tree) and the template."""
|
|
113
|
+
tei = unwrap(client.get("adminMasterEditorInstance"), "adminMasterEditorInstance")
|
|
114
|
+
master_id = str(uuid.uuid4())
|
|
115
|
+
resp = client.commit(
|
|
116
|
+
"adminMasterEditorInstance",
|
|
117
|
+
envelopes(tei),
|
|
118
|
+
{
|
|
119
|
+
"master": [
|
|
120
|
+
{
|
|
121
|
+
"newRevisionRecord": {
|
|
122
|
+
"ID": master_id, "parentType": "", "parentID": "", "ordering": "a0",
|
|
123
|
+
"status": "publish", "revision": "", "version": "", "name": "Sweep Master",
|
|
124
|
+
},
|
|
125
|
+
"originalRevisionRecord": None,
|
|
126
|
+
}
|
|
127
|
+
]
|
|
128
|
+
},
|
|
129
|
+
)
|
|
130
|
+
err = exceptions_of(resp)
|
|
131
|
+
if err:
|
|
132
|
+
sys.exit("master commit rejected: %s" % err)
|
|
133
|
+
|
|
134
|
+
# opening the document instance is what heals the default tree into existence
|
|
135
|
+
doc = unwrap(client.get("masterDocumentInstance/%s" % master_id), "masterDocumentInstance")
|
|
136
|
+
nodes = doc["node/master/%s" % master_id]
|
|
137
|
+
divs = sorted([n for n in nodes if n["type"] == "div"], key=lambda n: n["ordering"])
|
|
138
|
+
if not divs:
|
|
139
|
+
sys.exit("healed master has no div to hang probes on")
|
|
140
|
+
parent = divs[0]["ID"]
|
|
141
|
+
|
|
142
|
+
tei = unwrap(client.get("adminTemplateEditorInstance"), "adminTemplateEditorInstance")
|
|
143
|
+
template_id = str(uuid.uuid4())
|
|
144
|
+
resp = client.commit(
|
|
145
|
+
"adminTemplateEditorInstance",
|
|
146
|
+
envelopes(tei),
|
|
147
|
+
{
|
|
148
|
+
"template": [
|
|
149
|
+
{
|
|
150
|
+
"newRevisionRecord": {
|
|
151
|
+
"ID": template_id, "parentType": "", "parentID": "", "ordering": "a0",
|
|
152
|
+
"status": "publish", "revision": "", "version": "", "name": "Sweep Home",
|
|
153
|
+
"masterID": master_id, "assign": "auto", "path": "archive-post.php",
|
|
154
|
+
"conditions": [],
|
|
155
|
+
},
|
|
156
|
+
"originalRevisionRecord": None,
|
|
157
|
+
}
|
|
158
|
+
]
|
|
159
|
+
},
|
|
160
|
+
)
|
|
161
|
+
err = exceptions_of(resp)
|
|
162
|
+
if err:
|
|
163
|
+
sys.exit("template commit rejected: %s" % err)
|
|
164
|
+
|
|
165
|
+
cfg.update({"masterID": master_id, "templateID": template_id, "parentNodeID": parent})
|
|
166
|
+
with open(config_path, "w", encoding="utf-8") as fh:
|
|
167
|
+
json.dump(cfg, fh, indent=1)
|
|
168
|
+
print("masterID=%s\ntemplateID=%s\nparentNodeID=%s" % (master_id, template_id, parent))
|
|
169
|
+
|
|
170
|
+
html = client.page()
|
|
171
|
+
print("baseline page: %d bytes%s" % (len(html), "" if len(html) >= MIN_HEALTHY_BYTES else " <-- ALREADY BROKEN"))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def probe_records(node_type, parent_id, master_id):
|
|
175
|
+
nid = str(uuid.uuid4())
|
|
176
|
+
attr = "sweep-%s" % node_type
|
|
177
|
+
base = {
|
|
178
|
+
"parentType": "node", "parentID": parent_id, "ordering": "a0", "status": "publish",
|
|
179
|
+
"revision": "", "version": "", "documentType": "master", "documentID": master_id,
|
|
180
|
+
}
|
|
181
|
+
recs = [{"newRevisionRecord": dict(base, ID=nid, type=node_type, data={"attrID": attr}),
|
|
182
|
+
"originalRevisionRecord": None}]
|
|
183
|
+
if node_type in TEXT_HOLDERS:
|
|
184
|
+
recs.append(
|
|
185
|
+
{"newRevisionRecord": dict(base, ID=str(uuid.uuid4()), parentID=nid, type="wysiwyg-text",
|
|
186
|
+
data={"text": "%s_%s" % (MARKER, node_type)}),
|
|
187
|
+
"originalRevisionRecord": None}
|
|
188
|
+
)
|
|
189
|
+
return attr, nid, recs
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def delete_subtree(client, master_id, keep_ids, parent_id=None):
|
|
193
|
+
"""Remove the probe nodes, leaving the healed skeleton alone.
|
|
194
|
+
|
|
195
|
+
Deleting "everything not in the original baseline" does not work: heal() rebuilds
|
|
196
|
+
the master-root > document > body skeleton with FRESH ids whenever it decides one
|
|
197
|
+
is missing, so a fixed baseline id set marks those rebuilds as garbage, deletes
|
|
198
|
+
them, and heal makes new ones again - the document grows every round and later
|
|
199
|
+
types get measured on a dirty tree. Scope the delete to the probe parent's own
|
|
200
|
+
descendants instead, which the skeleton is never part of.
|
|
201
|
+
"""
|
|
202
|
+
doc = unwrap(client.get("masterDocumentInstance/%s" % master_id), "masterDocumentInstance")
|
|
203
|
+
key = "node/master/%s" % master_id
|
|
204
|
+
if parent_id:
|
|
205
|
+
by_parent = {}
|
|
206
|
+
for n in doc[key]:
|
|
207
|
+
by_parent.setdefault(n["parentID"], []).append(n)
|
|
208
|
+
doomed, stack = [], list(by_parent.get(parent_id, []))
|
|
209
|
+
while stack:
|
|
210
|
+
n = stack.pop()
|
|
211
|
+
doomed.append(n)
|
|
212
|
+
stack.extend(by_parent.get(n["ID"], []))
|
|
213
|
+
else:
|
|
214
|
+
doomed = [n for n in doc[key] if n["ID"] not in keep_ids]
|
|
215
|
+
if not doomed:
|
|
216
|
+
return
|
|
217
|
+
# delete deepest-first so a parent never disappears out from under its child
|
|
218
|
+
depth = {n["ID"]: n for n in doc[key]}
|
|
219
|
+
|
|
220
|
+
def rank(n):
|
|
221
|
+
d, cur = 0, n
|
|
222
|
+
while cur and cur.get("parentType") == "node" and cur["parentID"] in depth:
|
|
223
|
+
cur = depth[cur["parentID"]]
|
|
224
|
+
d += 1
|
|
225
|
+
return -d
|
|
226
|
+
|
|
227
|
+
revisions = [
|
|
228
|
+
{"newRevisionRecord": dict(n, status="delete"), "originalRevisionRecord": n}
|
|
229
|
+
for n in sorted(doomed, key=rank)
|
|
230
|
+
]
|
|
231
|
+
client.commit("masterDocumentInstance/%s" % master_id, envelopes(doc), {key: revisions})
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def sweep(client, cfg, types, out_path):
|
|
235
|
+
master_id = cfg["masterID"]
|
|
236
|
+
doc = unwrap(client.get("masterDocumentInstance/%s" % master_id), "masterDocumentInstance")
|
|
237
|
+
key = "node/master/%s" % master_id
|
|
238
|
+
baseline_ids = {n["ID"] for n in doc[key]}
|
|
239
|
+
|
|
240
|
+
baseline_html = client.page()
|
|
241
|
+
if len(baseline_html) < MIN_HEALTHY_BYTES:
|
|
242
|
+
sys.exit("baseline page is already broken (%d bytes); reset the theme first" % len(baseline_html))
|
|
243
|
+
print("baseline: %d bytes, %d nodes\n" % (len(baseline_html), len(baseline_ids)))
|
|
244
|
+
|
|
245
|
+
rows = []
|
|
246
|
+
for node_type in types:
|
|
247
|
+
doc = unwrap(client.get("masterDocumentInstance/%s" % master_id), "masterDocumentInstance")
|
|
248
|
+
attr, _nid, recs = probe_records(node_type, cfg["parentNodeID"], master_id)
|
|
249
|
+
resp = client.commit("masterDocumentInstance/%s" % master_id, envelopes(doc), {key: recs})
|
|
250
|
+
err = exceptions_of(resp)
|
|
251
|
+
|
|
252
|
+
if err:
|
|
253
|
+
# a 5xx is a PHP fatal on the server, not a graceful validator rejection -
|
|
254
|
+
# the two say very different things about whether the type is usable
|
|
255
|
+
code = resp.get("_httperror")
|
|
256
|
+
outcome = "COMMIT_%d" % code if code and code >= 500 else "REJECTED"
|
|
257
|
+
if code:
|
|
258
|
+
err = "PHP fatal / gateway error during commit"
|
|
259
|
+
tag, classes, echoed, size = "", "", "", ""
|
|
260
|
+
else:
|
|
261
|
+
html = client.page()
|
|
262
|
+
size = len(html)
|
|
263
|
+
if size < MIN_HEALTHY_BYTES:
|
|
264
|
+
outcome, tag, classes, echoed = "BROKE_PAGE", "", "", ""
|
|
265
|
+
err = html.strip()[:200]
|
|
266
|
+
else:
|
|
267
|
+
m = re.search(r'<([a-zA-Z0-9-]+)([^>]*\bid="%s"[^>]*)>' % re.escape(attr), html)
|
|
268
|
+
if m:
|
|
269
|
+
outcome, tag = "RENDERED", m.group(1)
|
|
270
|
+
c = re.search(r'class="([^"]*)"', m.group(2))
|
|
271
|
+
classes = c.group(1) if c else ""
|
|
272
|
+
else:
|
|
273
|
+
outcome, tag, classes = "COMMITTED", "", ""
|
|
274
|
+
echoed = "yes" if ("%s_%s" % (MARKER, node_type)) in html else ""
|
|
275
|
+
|
|
276
|
+
rows.append({"type": node_type, "outcome": outcome, "rendered_tag": tag,
|
|
277
|
+
"rendered_classes": classes, "text_echoed": echoed,
|
|
278
|
+
"page_bytes": size, "detail": err})
|
|
279
|
+
print("%-40s %-11s %s" % (node_type, outcome, tag or err[:60]), flush=True)
|
|
280
|
+
delete_subtree(client, master_id, baseline_ids, cfg["parentNodeID"])
|
|
281
|
+
|
|
282
|
+
with open(out_path, "w", newline="", encoding="utf-8") as fh:
|
|
283
|
+
w = csv.DictWriter(fh, fieldnames=list(rows[0].keys()))
|
|
284
|
+
w.writeheader()
|
|
285
|
+
w.writerows(rows)
|
|
286
|
+
|
|
287
|
+
tally = {}
|
|
288
|
+
for r in rows:
|
|
289
|
+
tally[r["outcome"]] = tally.get(r["outcome"], 0) + 1
|
|
290
|
+
print("\n" + " ".join("%s=%d" % kv for kv in sorted(tally.items())) + " -> %s" % out_path)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
if __name__ == "__main__":
|
|
294
|
+
ap = argparse.ArgumentParser()
|
|
295
|
+
ap.add_argument("--config", required=True)
|
|
296
|
+
ap.add_argument("--setup", action="store_true")
|
|
297
|
+
ap.add_argument("--sweep", action="store_true")
|
|
298
|
+
ap.add_argument("--types", help="comma-separated subset; overrides --edition")
|
|
299
|
+
ap.add_argument("--edition", default="all", choices=["all", "free", "pro"],
|
|
300
|
+
help="which slice of node-types.csv to sweep (default: all)")
|
|
301
|
+
ap.add_argument("--node-types-csv", default="../data/node-types.csv")
|
|
302
|
+
ap.add_argument("--out", default="../data/node-verification.csv")
|
|
303
|
+
a = ap.parse_args()
|
|
304
|
+
|
|
305
|
+
cfg = json.load(open(a.config, encoding="utf-8"))
|
|
306
|
+
client = Client(cfg)
|
|
307
|
+
if a.setup:
|
|
308
|
+
setup(client, cfg, a.config)
|
|
309
|
+
if a.sweep:
|
|
310
|
+
if a.types:
|
|
311
|
+
wanted = a.types.split(",")
|
|
312
|
+
else:
|
|
313
|
+
with open(a.node_types_csv, encoding="utf-8") as fh:
|
|
314
|
+
wanted = [r["type"] for r in csv.DictReader(fh)
|
|
315
|
+
if a.edition in ("all", r["edition"])]
|
|
316
|
+
sweep(client, cfg, wanted, a.out)
|
|
317
|
+
if not (a.setup or a.sweep):
|
|
318
|
+
ap.error("pass --setup and/or --sweep")
|