mosaic-headless 1.26.1 → 1.27.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.
@@ -95,7 +95,7 @@ class Surface:
95
95
  self.default_children = {r["type"]: r["default_children"].split("|")
96
96
  for r in load_csv("default-children.csv")}
97
97
 
98
- def check(self, parent_type, node, force):
98
+ def check(self, parent_type, node, force, ancestors=()):
99
99
  t = node["type"]
100
100
  problems = []
101
101
  if t not in self.types:
@@ -157,12 +157,23 @@ class Surface:
157
157
  # registered types - slider/slider-slides, accordion/accordion-item,
158
158
  # form/form-input - and which actually carries one of them is not the thing
159
159
  # that was measured, and the verdict does not apply to it.
160
+ rule_for_t = self.rules.get(t, {})
160
161
  family = [x for x in self.types if x.startswith(t + "-")]
161
162
  kids = [c.get("type") for c in (node.get("children") or []) if isinstance(c, dict)]
162
163
  nested_as_family = bool(family) and any(k in family for k in kids)
163
164
 
165
+ # `nested_under` is what `canBeNestedChildFor()` walks the parent chain
166
+ # looking for: `radio-input` needs a `choice` above it, `label` needs a
167
+ # `field`, `loop-pagination` needs a `loop`. The names often share no
168
+ # prefix, so this is the only way to tell a correct placement from the
169
+ # bare one that was measured.
170
+ needs = (rule_for_t.get("nested_under") or "").split("|") if rule_for_t else []
171
+ nested_ok = bool(needs and needs != [""] and
172
+ any(x in needs for x in (ancestors + ((parent_type,)
173
+ if parent_type else ()))))
174
+
164
175
  outcome = self.outcome.get(t)
165
- if (not declared_child and not nested_as_family
176
+ if (not declared_child and not nested_as_family and not nested_ok
166
177
  and (outcome == "BROKE_PAGE" or (outcome or "").startswith("COMMIT_5"))):
167
178
  problems.append("%s is measured %s under a plain container, and %s is not its declared parent"
168
179
  % (t, outcome, parent_type or "<root>"))
@@ -312,12 +323,13 @@ def refresh_node_ids(tree):
312
323
  return json.loads(blob)
313
324
 
314
325
 
315
- def flatten(node, parent_id, master_id, surface, force, ordering="a0", parent_type=None, out=None):
326
+ def flatten(node, parent_id, master_id, surface, force, ordering="a0",
327
+ parent_type=None, out=None, ancestors=()):
316
328
  """Depth-first walk producing revision records, checking placement as it goes."""
317
329
  out = out if out is not None else []
318
330
  if node.get("component"):
319
331
  node = dict(node, type="div") # placement is checked as a plain element
320
- problems = surface.check(parent_type, node, force)
332
+ problems = surface.check(parent_type, node, force, ancestors)
321
333
  if problems:
322
334
  raise SystemExit("refusing to build:\n " + "\n ".join(problems))
323
335
 
@@ -360,7 +372,8 @@ def flatten(node, parent_id, master_id, surface, force, ordering="a0", parent_ty
360
372
  if node.get("text") is not None:
361
373
  children.insert(0, {"type": "wysiwyg-text", "data": {"text": node["text"]}})
362
374
  for i, child in enumerate(children):
363
- flatten(child, nid, master_id, surface, force, ordering_for(i), node["type"], out)
375
+ flatten(child, nid, master_id, surface, force, ordering_for(i),
376
+ node["type"], out, ancestors + (node["type"],))
364
377
  return out
365
378
 
366
379
 
@@ -69,7 +69,13 @@ def extract(plugin_root, out_dir):
69
69
  sys.exit("no Mosaic/NodeTypes under %s" % plugin_root)
70
70
 
71
71
  types = []
72
- for path in sorted(walk(node_root, ("TypeFactory.php",))):
72
+ # `*MResourceFactory.php` as well as `*TypeFactory.php`: two node types -
73
+ # `fieldset-legend` and `fieldset-content` - are declared only by the former,
74
+ # so a walk of TypeFactory alone leaves them out of the catalog entirely. A
75
+ # page that needs them is then refused by the placement guard as an "unknown
76
+ # node type", and a reader of this table is told the fieldset has no inner
77
+ # structure, which is how a form came back with its fields dropped.
78
+ for path in sorted(walk(node_root, ("TypeFactory.php", "MResourceFactory.php"))):
73
79
  src = read(path)
74
80
  cls = RE_CLASS.search(src)
75
81
  ctor = RE_CTOR.search(src)
@@ -57,7 +57,8 @@ def read(path):
57
57
  def walk(root, suffix):
58
58
  for dirpath, _dirs, files in os.walk(root):
59
59
  for fn in sorted(files):
60
- if fn.endswith(suffix):
60
+ if fn.endswith(suffix) if isinstance(suffix, str) else any(
61
+ fn.endswith(x) for x in suffix):
61
62
  yield os.path.join(dirpath, fn)
62
63
 
63
64
 
@@ -68,7 +69,7 @@ def extract(plugin_root, out_dir):
68
69
 
69
70
  # factory class name -> type slug, so instanceof targets can be named as types
70
71
  class_to_type, sources = {}, {}
71
- for path in walk(node_root, "TypeFactory.php"):
72
+ for path in walk(node_root, ("TypeFactory.php", "MResourceFactory.php")):
72
73
  src = read(path)
73
74
  cls = RE_CLASS.search(src)
74
75
  if not cls:
@@ -95,6 +96,18 @@ def extract(plugin_root, out_dir):
95
96
  body, defined_in = resolve(cls_name)
96
97
  path, src, _parent = sources[cls_name]
97
98
  nested = "yes" if "function canBeNestedChildFor" in src else ""
99
+ # `yes` alone says a runtime ancestry rule exists and not what it wants.
100
+ # These methods walk the parent chain looking for one MResource class -
101
+ # `radio-input` wants a `ChoiceElementMResource` above it - and that is
102
+ # the one fact a caller needs to know whether a placement is legal.
103
+ nested_under = []
104
+ if nested:
105
+ mbody = re.search(
106
+ r"function\s+canBeNestedChildFor\s*\([^)]*\)\s*:\s*bool\s*\{(.*?)\n \}",
107
+ src, re.S)
108
+ if mbody:
109
+ for cls in re.findall(r"instanceof\s+(\w+)ElementMResource\b", mbody.group(1)):
110
+ nested_under.append(cls)
98
111
 
99
112
  if body is None:
100
113
  rule, allowed = "none", [] # inherited default on the abstract base
@@ -121,6 +134,12 @@ def extract(plugin_root, out_dir):
121
134
  "allowed_children": "|".join(allowed),
122
135
  "nested_rule": nested,
123
136
  "takes_children": "yes" if RE_TAKES_CHILDREN.search(src) else "",
137
+ # `class_to_type` is keyed by the FACTORY class, and what the
138
+ # method names is the MResource - `ChoiceElementMResource` is
139
+ # declared by `ChoiceElementTypeFactory`.
140
+ "nested_under": "|".join(sorted(set(
141
+ class_to_type.get(c + "ElementTypeFactory", "")
142
+ for c in nested_under) - {""})),
124
143
  "declared_in": os.path.relpath(defined_in or path, plugin_root).replace(os.sep, "/"),
125
144
  }
126
145
  )
@@ -128,7 +147,8 @@ def extract(plugin_root, out_dir):
128
147
  os.makedirs(out_dir, exist_ok=True)
129
148
  with open(os.path.join(out_dir, "placement-rules.csv"), "w", newline="", encoding="utf-8") as fh:
130
149
  w = csv.DictWriter(fh, fieldnames=["type", "rule", "allowed_children", "nested_rule",
131
- "takes_children", "declared_in"])
150
+ "takes_children", "nested_under",
151
+ "declared_in"])
132
152
  w.writeheader()
133
153
  w.writerows(rows)
134
154
 
@@ -56,6 +56,18 @@ def main():
56
56
  if not ok:
57
57
  fails.append(name)
58
58
 
59
+ def skip(name, why):
60
+ """A page that does not carry a component is not a page that broke it.
61
+
62
+ The same tool runs against every showcase page, so "absent" has to be a
63
+ third outcome - otherwise either the form page fails on tabs it never
64
+ had, or the checks get a flag per component and nobody runs them."""
65
+ rows.append([name, "SKIPPED", why])
66
+ print(" %-28s %-5s %s" % (name, "SKIP", why))
67
+
68
+ def present(el_id):
69
+ return p.evaluate("(id) => !!document.getElementById(id)", el_id)
70
+
59
71
  def split(spec):
60
72
  pat, n = spec.rsplit(":", 1)
61
73
  tab, pane = pat.split("/")
@@ -71,64 +83,95 @@ def main():
71
83
  p.wait_for_timeout(2200)
72
84
 
73
85
  # ---- tabs
74
- shown = [i for i in range(n_tabs) if p.evaluate(VISIBLE, pane_pat % i)]
75
- check("TABS/one_pane_at_rest", shown == [0],
76
- "visible panes at rest: %s" % shown)
77
- last = n_tabs - 1
78
- p.click("#" + tab_pat % last)
79
- p.wait_for_timeout(600)
80
- shown = [i for i in range(n_tabs) if p.evaluate(VISIBLE, pane_pat % i)]
81
- check("TABS/click_switches", shown == [last],
82
- "clicked tab %d -> panes %s" % (last, shown))
83
- active = p.evaluate("""() => {
84
- const t = document.querySelector('.m-tab--active');
85
- if (!t) return null;
86
- const ps = [...t.querySelectorAll('p')].map(e => getComputedStyle(e).color);
87
- return {bg: getComputedStyle(t).backgroundColor, labels: ps};
88
- }""")
89
- # the active tab is darkened by the plugin; if the label was not restyled
90
- # with ___tab--active___descendants it is ink on ink and unreadable
91
- readable = bool(active) and active["bg"] != (active["labels"] or [""])[0]
92
- check("TABS/active_label_readable", readable, str(active))
86
+ if not present(tab_pat % 0):
87
+ skip("TABS", "not on this page")
88
+ else:
89
+ shown = [i for i in range(n_tabs) if p.evaluate(VISIBLE, pane_pat % i)]
90
+ check("TABS/one_pane_at_rest", shown == [0],
91
+ "visible panes at rest: %s" % shown)
92
+ last = n_tabs - 1
93
+ p.click("#" + tab_pat % last)
94
+ p.wait_for_timeout(600)
95
+ shown = [i for i in range(n_tabs) if p.evaluate(VISIBLE, pane_pat % i)]
96
+ check("TABS/click_switches", shown == [last],
97
+ "clicked tab %d -> panes %s" % (last, shown))
98
+ active = p.evaluate("""() => {
99
+ const t = document.querySelector('.m-tab--active');
100
+ if (!t) return null;
101
+ const ps = [...t.querySelectorAll('p')].map(e => getComputedStyle(e).color);
102
+ return {bg: getComputedStyle(t).backgroundColor, labels: ps};
103
+ }""")
104
+ # the plugin darkens the active tab; a label that was not restyled
105
+ # with ___tab--active___descendants is then ink on ink
106
+ readable = bool(active) and active["bg"] != (active["labels"] or [""])[0]
107
+ check("TABS/active_label_readable", readable, str(active))
93
108
 
94
109
  # ---- accordion
95
- open_now = [i for i in range(n_faq) if p.evaluate(VISIBLE, ans_pat % i)]
96
- check("ACCORDION/shut_at_rest", open_now == [], "open: %s" % open_now)
97
- p.click("#" + q_pat % 1)
98
- p.wait_for_timeout(700)
99
- open_now = [i for i in range(n_faq) if p.evaluate(VISIBLE, ans_pat % i)]
100
- check("ACCORDION/click_opens_one", open_now == [1],
101
- "clicked 1 -> open %s" % open_now)
102
- tabindex = p.evaluate("(id) => document.getElementById(id).getAttribute('tabindex')",
103
- q_pat % 1)
104
- check("ACCORDION/keyboard_reachable", tabindex == "0",
105
- "title tabindex=%r - the plugin puts it in the tab order" % tabindex)
110
+ if not present(q_pat % 0):
111
+ skip("ACCORDION", "not on this page")
112
+ else:
113
+ open_now = [i for i in range(n_faq) if p.evaluate(VISIBLE, ans_pat % i)]
114
+ check("ACCORDION/shut_at_rest", open_now == [], "open: %s" % open_now)
115
+ p.click("#" + q_pat % 1)
116
+ p.wait_for_timeout(700)
117
+ open_now = [i for i in range(n_faq) if p.evaluate(VISIBLE, ans_pat % i)]
118
+ check("ACCORDION/click_opens_one", open_now == [1],
119
+ "clicked 1 -> open %s" % open_now)
120
+ ti = p.evaluate("(id) => document.getElementById(id).getAttribute('tabindex')",
121
+ q_pat % 1)
122
+ check("ACCORDION/keyboard_reachable", ti == "0",
123
+ "title tabindex=%r - the plugin puts it in the tab order" % ti)
106
124
 
107
125
  # ---- form
108
- fields = p.evaluate("""(id) => [...document.querySelectorAll(
109
- '#' + id + ' input, #' + id + ' textarea')].map(
110
- e => ({name: e.getAttribute('name'), req: e.required,
111
- tag: e.tagName.toLowerCase()}))""", a.form)
112
- named = [f["name"] for f in fields if f["name"]]
113
- honeypot = [n for n in named if n.startswith("mosaic_hp")]
114
- real = [n for n in named if not n.startswith("mosaic_hp")]
115
- check("FORM/fields", len(real) >= 3, "named fields: %s" % real)
116
- check("FORM/required", all(f["req"] for f in fields
117
- if f["name"] in real),
118
- "every author field is required")
119
- check("FORM/honeypot", bool(honeypot),
120
- "Mosaic adds its own spam trap: %s" % honeypot)
126
+ if not present(a.form):
127
+ skip("FORM", "not on this page")
128
+ else:
129
+ fields = p.evaluate("""(id) => [...document.querySelectorAll(
130
+ '#' + id + ' input, #' + id + ' select, #' + id + ' textarea')].map(
131
+ e => ({name: e.getAttribute('name'), req: e.required,
132
+ tag: e.tagName.toLowerCase(), type: e.type}))""", a.form)
133
+ named = [f["name"] for f in fields if f["name"]]
134
+ honeypot = [n for n in named if n.startswith("mosaic_hp")]
135
+ real = [f for f in fields if f["name"] and not f["name"].startswith("mosaic_hp")]
136
+ kinds = sorted({f["type"] for f in real})
137
+ check("FORM/fields", len(real) >= 3,
138
+ "%d controls, types: %s" % (len(real), ", ".join(kinds)))
139
+ check("FORM/required", any(f["req"] for f in real),
140
+ "required is set on %d of them" % sum(1 for f in real if f["req"]))
141
+ check("FORM/honeypot", bool(honeypot),
142
+ "Mosaic adds its own spam trap: %s" % honeypot)
143
+ # a <select> whose options have no text renders as a blank dropdown:
144
+ # `select-option` is a LEAF and its label is the `text` PROPERTY
145
+ opts = p.evaluate("""(id) => [...document.querySelectorAll('#' + id + ' option')]
146
+ .map(o => o.textContent.trim())""", a.form)
147
+ if opts:
148
+ check("FORM/option_labels", all(opts),
149
+ "%d options, all labelled" % len(opts) if all(opts)
150
+ else "blank labels - `text` is a property, not a child")
151
+ else:
152
+ skip("FORM/option_labels", "no select on this form")
153
+ # the browser's own validation has to stop an empty required form
154
+ p.evaluate("(id) => document.getElementById(id).reset()", a.form)
155
+ invalid = p.evaluate("""(id) => [...document.getElementById(id)
156
+ .querySelectorAll(':invalid')].filter(e => e.name).map(e => e.name)""",
157
+ a.form)
158
+ check("FORM/required_blocks", bool(invalid),
159
+ "empty form reports invalid: %s" % invalid[:4])
121
160
 
122
161
  # ---- map
123
- src = p.evaluate("""(id) => { const f = document.querySelector('#' + id + ' iframe');
124
- return f ? f.getAttribute('src') : null; }""", a.map)
125
- m = re.search(r"marker=([-\d.]+),([-\d.]+)", src or "")
126
- times_square = bool(m) and m.group(1).startswith("40.75")
127
- check("MAP/frame", bool(src), "renders an openstreetmap iframe")
128
- check("MAP/coordinates_applied", bool(m) and not times_square,
129
- ("marker=%s,%s" % m.groups()) if m else "no marker in %r" % (src or "")[:60]
130
- + (" - this is the default, so the property name was wrong"
131
- if times_square else ""))
162
+ if not present(a.map):
163
+ skip("MAP", "not on this page")
164
+ else:
165
+ src = p.evaluate("""(id) => { const f = document.querySelector('#' + id + ' iframe');
166
+ return f ? f.getAttribute('src') : null; }""", a.map)
167
+ m = re.search(r"marker=([-\d.]+),([-\d.]+)", src or "")
168
+ default_centre = bool(m) and m.group(1).startswith("40.75")
169
+ check("MAP/frame", bool(src), "renders an openstreetmap iframe")
170
+ check("MAP/coordinates_applied", bool(m) and not default_centre,
171
+ ("marker=%s,%s" % m.groups()) if m and not default_centre
172
+ else "Times Square - the property name was wrong "
173
+ "(it is latitude/longitude, not lat/lon)")
174
+
132
175
  b.close()
133
176
 
134
177
  print("\n%d checks, %d FAIL" % (len(rows), len(fails)))