mosaic-headless 1.26.1 → 1.28.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/SKILL.md +8 -6
- 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/data/component-wall-verification.csv +4 -2
- package/data/content-loop-verification.csv +11 -0
- package/data/form-surface-verification.csv +9 -0
- package/data/mobile-audit.csv +5 -0
- package/data/navigation-verification.csv +20 -14
- package/data/node-types.csv +2 -0
- package/data/placement-rules.csv +129 -127
- package/package.json +1 -1
- package/references/dynamic-content.md +54 -0
- package/references/failure-modes.md +27 -0
- package/references/placement.md +38 -0
- package/scripts/check-release.mjs +5 -3
- package/sites/_moksa.py +12 -0
- package/sites/moksa.json +3616 -104
- package/tools/build_page.py +31 -5
- package/tools/extract_node_types.py +7 -1
- package/tools/extract_placement.py +23 -3
- package/tools/verify_components.py +155 -52
package/tools/build_page.py
CHANGED
|
@@ -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>"))
|
|
@@ -184,6 +195,19 @@ class Surface:
|
|
|
184
195
|
problems.append("%s is a leaf and accepts no children (tried %s)" % (parent_type, t))
|
|
185
196
|
elif rule.get("rule") == "allow":
|
|
186
197
|
allowed = rule["allowed_children"].split("|") + self.default_children.get(parent_type, [])
|
|
198
|
+
# A loop container stands in for its parent's children: a
|
|
199
|
+
# `slider-loop-slides` sits inside `slider-slides` and repeats a
|
|
200
|
+
# `slider-slide`, so what it accepts is what `slider-slides`
|
|
201
|
+
# accepts. The plugin's own canBeParentFor says `slider-slides`
|
|
202
|
+
# here, which would nest the track inside its own loop - and the
|
|
203
|
+
# factory's default children say `slider-slide`, which is the
|
|
204
|
+
# structure the editor builds. Rather than hard-code the
|
|
205
|
+
# disagreement, take the rule from the grandparent, which is both
|
|
206
|
+
# types' actual authority.
|
|
207
|
+
# ancestors[-1] IS the parent; the grandparent is one further up
|
|
208
|
+
if t not in allowed and "-loop-" in parent_type and len(ancestors) > 1:
|
|
209
|
+
gp = self.rules.get(ancestors[-2], {})
|
|
210
|
+
allowed = allowed + (gp.get("allowed_children") or "").split("|")
|
|
187
211
|
if t not in allowed:
|
|
188
212
|
problems.append("%s accepts only %s, not %s" % (parent_type, "/".join(allowed), t))
|
|
189
213
|
# customStyles is the pre-1.0.8 name of customDeclarations; expand_shorthands maps it
|
|
@@ -312,12 +336,13 @@ def refresh_node_ids(tree):
|
|
|
312
336
|
return json.loads(blob)
|
|
313
337
|
|
|
314
338
|
|
|
315
|
-
def flatten(node, parent_id, master_id, surface, force, ordering="a0",
|
|
339
|
+
def flatten(node, parent_id, master_id, surface, force, ordering="a0",
|
|
340
|
+
parent_type=None, out=None, ancestors=()):
|
|
316
341
|
"""Depth-first walk producing revision records, checking placement as it goes."""
|
|
317
342
|
out = out if out is not None else []
|
|
318
343
|
if node.get("component"):
|
|
319
344
|
node = dict(node, type="div") # placement is checked as a plain element
|
|
320
|
-
problems = surface.check(parent_type, node, force)
|
|
345
|
+
problems = surface.check(parent_type, node, force, ancestors)
|
|
321
346
|
if problems:
|
|
322
347
|
raise SystemExit("refusing to build:\n " + "\n ".join(problems))
|
|
323
348
|
|
|
@@ -360,7 +385,8 @@ def flatten(node, parent_id, master_id, surface, force, ordering="a0", parent_ty
|
|
|
360
385
|
if node.get("text") is not None:
|
|
361
386
|
children.insert(0, {"type": "wysiwyg-text", "data": {"text": node["text"]}})
|
|
362
387
|
for i, child in enumerate(children):
|
|
363
|
-
flatten(child, nid, master_id, surface, force, ordering_for(i),
|
|
388
|
+
flatten(child, nid, master_id, surface, force, ordering_for(i),
|
|
389
|
+
node["type"], out, ancestors + (node["type"],))
|
|
364
390
|
return out
|
|
365
391
|
|
|
366
392
|
|
|
@@ -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
|
-
|
|
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", "
|
|
150
|
+
"takes_children", "nested_under",
|
|
151
|
+
"declared_in"])
|
|
132
152
|
w.writeheader()
|
|
133
153
|
w.writerows(rows)
|
|
134
154
|
|
|
@@ -46,6 +46,13 @@ def main():
|
|
|
46
46
|
ap.add_argument("--faq", default="cp-faq-q-%d/cp-faq-a-%d:4")
|
|
47
47
|
ap.add_argument("--form", default="cp-form")
|
|
48
48
|
ap.add_argument("--map", default="cp-map")
|
|
49
|
+
ap.add_argument("--loop-slider", default="ct-slider",
|
|
50
|
+
help="a slider whose slides come from a loop")
|
|
51
|
+
ap.add_argument("--loop-tabs", default="ct-tabs-menu")
|
|
52
|
+
ap.add_argument("--pagination", default="ct-arch-nums",
|
|
53
|
+
help="a loop-pagination-numbers whose children are page links")
|
|
54
|
+
ap.add_argument("--paged-item", default="ct-arch-title",
|
|
55
|
+
help="the repeated attrID whose text should change per page")
|
|
49
56
|
a = ap.parse_args()
|
|
50
57
|
|
|
51
58
|
rows, fails = [], []
|
|
@@ -56,6 +63,18 @@ def main():
|
|
|
56
63
|
if not ok:
|
|
57
64
|
fails.append(name)
|
|
58
65
|
|
|
66
|
+
def skip(name, why):
|
|
67
|
+
"""A page that does not carry a component is not a page that broke it.
|
|
68
|
+
|
|
69
|
+
The same tool runs against every showcase page, so "absent" has to be a
|
|
70
|
+
third outcome - otherwise either the form page fails on tabs it never
|
|
71
|
+
had, or the checks get a flag per component and nobody runs them."""
|
|
72
|
+
rows.append([name, "SKIPPED", why])
|
|
73
|
+
print(" %-28s %-5s %s" % (name, "SKIP", why))
|
|
74
|
+
|
|
75
|
+
def present(el_id):
|
|
76
|
+
return p.evaluate("(id) => !!document.getElementById(id)", el_id)
|
|
77
|
+
|
|
59
78
|
def split(spec):
|
|
60
79
|
pat, n = spec.rsplit(":", 1)
|
|
61
80
|
tab, pane = pat.split("/")
|
|
@@ -71,64 +90,148 @@ def main():
|
|
|
71
90
|
p.wait_for_timeout(2200)
|
|
72
91
|
|
|
73
92
|
# ---- tabs
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
+
if not present(tab_pat % 0):
|
|
94
|
+
skip("TABS", "not on this page")
|
|
95
|
+
else:
|
|
96
|
+
shown = [i for i in range(n_tabs) if p.evaluate(VISIBLE, pane_pat % i)]
|
|
97
|
+
check("TABS/one_pane_at_rest", shown == [0],
|
|
98
|
+
"visible panes at rest: %s" % shown)
|
|
99
|
+
last = n_tabs - 1
|
|
100
|
+
p.click("#" + tab_pat % last)
|
|
101
|
+
p.wait_for_timeout(600)
|
|
102
|
+
shown = [i for i in range(n_tabs) if p.evaluate(VISIBLE, pane_pat % i)]
|
|
103
|
+
check("TABS/click_switches", shown == [last],
|
|
104
|
+
"clicked tab %d -> panes %s" % (last, shown))
|
|
105
|
+
active = p.evaluate("""() => {
|
|
106
|
+
const t = document.querySelector('.m-tab--active');
|
|
107
|
+
if (!t) return null;
|
|
108
|
+
const ps = [...t.querySelectorAll('p')].map(e => getComputedStyle(e).color);
|
|
109
|
+
return {bg: getComputedStyle(t).backgroundColor, labels: ps};
|
|
110
|
+
}""")
|
|
111
|
+
# the plugin darkens the active tab; a label that was not restyled
|
|
112
|
+
# with ___tab--active___descendants is then ink on ink
|
|
113
|
+
readable = bool(active) and active["bg"] != (active["labels"] or [""])[0]
|
|
114
|
+
check("TABS/active_label_readable", readable, str(active))
|
|
93
115
|
|
|
94
116
|
# ---- accordion
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
117
|
+
if not present(q_pat % 0):
|
|
118
|
+
skip("ACCORDION", "not on this page")
|
|
119
|
+
else:
|
|
120
|
+
open_now = [i for i in range(n_faq) if p.evaluate(VISIBLE, ans_pat % i)]
|
|
121
|
+
check("ACCORDION/shut_at_rest", open_now == [], "open: %s" % open_now)
|
|
122
|
+
p.click("#" + q_pat % 1)
|
|
123
|
+
p.wait_for_timeout(700)
|
|
124
|
+
open_now = [i for i in range(n_faq) if p.evaluate(VISIBLE, ans_pat % i)]
|
|
125
|
+
check("ACCORDION/click_opens_one", open_now == [1],
|
|
126
|
+
"clicked 1 -> open %s" % open_now)
|
|
127
|
+
ti = p.evaluate("(id) => document.getElementById(id).getAttribute('tabindex')",
|
|
128
|
+
q_pat % 1)
|
|
129
|
+
check("ACCORDION/keyboard_reachable", ti == "0",
|
|
130
|
+
"title tabindex=%r - the plugin puts it in the tab order" % ti)
|
|
106
131
|
|
|
107
132
|
# ---- form
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
133
|
+
if not present(a.form):
|
|
134
|
+
skip("FORM", "not on this page")
|
|
135
|
+
else:
|
|
136
|
+
fields = p.evaluate("""(id) => [...document.querySelectorAll(
|
|
137
|
+
'#' + id + ' input, #' + id + ' select, #' + id + ' textarea')].map(
|
|
138
|
+
e => ({name: e.getAttribute('name'), req: e.required,
|
|
139
|
+
tag: e.tagName.toLowerCase(), type: e.type}))""", a.form)
|
|
140
|
+
named = [f["name"] for f in fields if f["name"]]
|
|
141
|
+
honeypot = [n for n in named if n.startswith("mosaic_hp")]
|
|
142
|
+
real = [f for f in fields if f["name"] and not f["name"].startswith("mosaic_hp")]
|
|
143
|
+
kinds = sorted({f["type"] for f in real})
|
|
144
|
+
check("FORM/fields", len(real) >= 3,
|
|
145
|
+
"%d controls, types: %s" % (len(real), ", ".join(kinds)))
|
|
146
|
+
check("FORM/required", any(f["req"] for f in real),
|
|
147
|
+
"required is set on %d of them" % sum(1 for f in real if f["req"]))
|
|
148
|
+
check("FORM/honeypot", bool(honeypot),
|
|
149
|
+
"Mosaic adds its own spam trap: %s" % honeypot)
|
|
150
|
+
# a <select> whose options have no text renders as a blank dropdown:
|
|
151
|
+
# `select-option` is a LEAF and its label is the `text` PROPERTY
|
|
152
|
+
opts = p.evaluate("""(id) => [...document.querySelectorAll('#' + id + ' option')]
|
|
153
|
+
.map(o => o.textContent.trim())""", a.form)
|
|
154
|
+
if opts:
|
|
155
|
+
check("FORM/option_labels", all(opts),
|
|
156
|
+
"%d options, all labelled" % len(opts) if all(opts)
|
|
157
|
+
else "blank labels - `text` is a property, not a child")
|
|
158
|
+
else:
|
|
159
|
+
skip("FORM/option_labels", "no select on this form")
|
|
160
|
+
# the browser's own validation has to stop an empty required form
|
|
161
|
+
p.evaluate("(id) => document.getElementById(id).reset()", a.form)
|
|
162
|
+
invalid = p.evaluate("""(id) => [...document.getElementById(id)
|
|
163
|
+
.querySelectorAll(':invalid')].filter(e => e.name).map(e => e.name)""",
|
|
164
|
+
a.form)
|
|
165
|
+
check("FORM/required_blocks", bool(invalid),
|
|
166
|
+
"empty form reports invalid: %s" % invalid[:4])
|
|
167
|
+
|
|
168
|
+
# ---- components whose children come from a loop
|
|
169
|
+
#
|
|
170
|
+
# One authored template, repeated per row. The assertion is COUNT: a
|
|
171
|
+
# template that failed to loop renders exactly once and looks fine.
|
|
172
|
+
if not present(a.loop_slider):
|
|
173
|
+
skip("LOOP/slider", "not on this page")
|
|
174
|
+
else:
|
|
175
|
+
n = p.evaluate("""(id) => document.querySelectorAll(
|
|
176
|
+
'#' + id + ' mosaic-slider-slide').length""", a.loop_slider)
|
|
177
|
+
check("LOOP/slider", n > 1,
|
|
178
|
+
"%d slides from one authored slider-slide" % n)
|
|
179
|
+
if not present(a.loop_tabs):
|
|
180
|
+
skip("LOOP/tabs", "not on this page")
|
|
181
|
+
else:
|
|
182
|
+
n = p.evaluate("""(id) => document.querySelectorAll(
|
|
183
|
+
'#' + id + ' .m-tab').length""", a.loop_tabs)
|
|
184
|
+
labels = p.evaluate("""(id) => [...document.querySelectorAll(
|
|
185
|
+
'#' + id + ' .m-tab')].map(e => e.textContent.trim())""", a.loop_tabs)
|
|
186
|
+
check("LOOP/tabs", n > 1, "%d tabs from one authored tabs-tab" % n)
|
|
187
|
+
# @substr counts BYTES; a cut through a multi-byte character leaves
|
|
188
|
+
# U+FFFD, which is invisible in a count and obvious to a reader
|
|
189
|
+
check("LOOP/tab_labels_intact",
|
|
190
|
+
all("�" not in x for x in labels),
|
|
191
|
+
"no replacement characters - @substr counts bytes, so a length "
|
|
192
|
+
"that is not a multiple of the encoding cuts a glyph in half")
|
|
193
|
+
|
|
194
|
+
# ---- pagination actually pages
|
|
195
|
+
if not present(a.pagination):
|
|
196
|
+
skip("LOOP/pagination", "not on this page")
|
|
197
|
+
else:
|
|
198
|
+
def titles():
|
|
199
|
+
return p.evaluate("""(id) => [...document.querySelectorAll(
|
|
200
|
+
'[id=' + id + ']')].map(e => e.textContent.trim())""",
|
|
201
|
+
a.paged_item)
|
|
202
|
+
links = p.evaluate("""(id) => [...document.getElementById(id).children]
|
|
203
|
+
.map(e => e.getAttribute('href'))""", a.pagination)
|
|
204
|
+
check("LOOP/pagination_links", len(links) > 1 and all(links),
|
|
205
|
+
"%d page links: %s" % (len(links),
|
|
206
|
+
[l.split("?")[-1] for l in links if l][:3]))
|
|
207
|
+
# `p` is one of WordPress's own query vars - ?p=2 is a 301 to post 2 -
|
|
208
|
+
# so a paginationKey of "p" takes the visitor off the page entirely
|
|
209
|
+
bad_key = [l for l in links if l and ("?p=" in l or "&p=" in l)]
|
|
210
|
+
check("LOOP/pagination_key_is_safe", not bad_key,
|
|
211
|
+
"paginationKey does not collide with a WordPress query var"
|
|
212
|
+
if not bad_key else "uses ?p=, which WordPress reads as a post ID")
|
|
213
|
+
before = titles()
|
|
214
|
+
p.click("#%s > *:nth-child(2)" % a.pagination)
|
|
215
|
+
p.wait_for_timeout(2000)
|
|
216
|
+
after = titles()
|
|
217
|
+
check("LOOP/pagination_changes_rows",
|
|
218
|
+
bool(after) and set(after) != set(before),
|
|
219
|
+
"page 1 %s -> page 2 %s" % (len(before), len(after)))
|
|
121
220
|
|
|
122
221
|
# ---- map
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
222
|
+
if not present(a.map):
|
|
223
|
+
skip("MAP", "not on this page")
|
|
224
|
+
else:
|
|
225
|
+
src = p.evaluate("""(id) => { const f = document.querySelector('#' + id + ' iframe');
|
|
226
|
+
return f ? f.getAttribute('src') : null; }""", a.map)
|
|
227
|
+
m = re.search(r"marker=([-\d.]+),([-\d.]+)", src or "")
|
|
228
|
+
default_centre = bool(m) and m.group(1).startswith("40.75")
|
|
229
|
+
check("MAP/frame", bool(src), "renders an openstreetmap iframe")
|
|
230
|
+
check("MAP/coordinates_applied", bool(m) and not default_centre,
|
|
231
|
+
("marker=%s,%s" % m.groups()) if m and not default_centre
|
|
232
|
+
else "Times Square - the property name was wrong "
|
|
233
|
+
"(it is latitude/longitude, not lat/lon)")
|
|
234
|
+
|
|
132
235
|
b.close()
|
|
133
236
|
|
|
134
237
|
print("\n%d checks, %d FAIL" % (len(rows), len(fails)))
|