mosaic-headless 1.25.1 → 1.26.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.
@@ -42,6 +42,11 @@ AUDIT = r"""() => {
42
42
  const t = txt(el);
43
43
 
44
44
  const inClip = clipped(el);
45
+ // `left:-9999px` is the standard way to put something off screen for
46
+ // assistive tech only - a form's honeypot, a skip link, a visually-hidden
47
+ // label. It is deliberate, so it is not "outside the viewport" in the sense
48
+ // this audit means.
49
+ if (r.right < -1000 || r.left > vw + 1000) continue;
45
50
 
46
51
  // 1. anything wider than the viewport
47
52
  if (r.width > vw + 1 && !inClip)
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env python3
2
+ """Drive the interactive components on a page and record what they actually did.
3
+
4
+ python tools/verify_components.py --url https://site/components/ \
5
+ --csv data/component-wall-verification.csv
6
+
7
+ A page that says "these all work" is worth exactly as much as the last time
8
+ someone pressed them. Each component here is operated the way a visitor would and
9
+ asserted on its RESULT, not on its markup:
10
+
11
+ tabs clicking tab 3 hides pane 1 and shows pane 3
12
+ accordion clicking a title opens that item and leaves the others shut; the
13
+ title is keyboard-operable, which is the plugin's doing, not ours
14
+ form the named fields exist, required is on them, the success screen is
15
+ hidden until it is needed, and the honeypot Mosaic adds is present
16
+ map the OSM frame carries the coordinates that were authored - a wrong
17
+ property NAME is dropped in silence and the map centres on its
18
+ default, which is Times Square and looks deliberate
19
+
20
+ Each check names the attrIDs it needs, so a page that renames something fails
21
+ here rather than quietly losing a demonstration.
22
+ """
23
+ import argparse
24
+ import csv
25
+ import re
26
+ import sys
27
+
28
+ from playwright.sync_api import sync_playwright
29
+
30
+ VISIBLE = """(id) => {
31
+ const e = document.getElementById(id);
32
+ if (!e) return null;
33
+ const r = e.getBoundingClientRect(), s = getComputedStyle(e);
34
+ return r.height > 2 && s.display !== 'none' && s.visibility !== 'hidden'
35
+ && parseFloat(s.opacity) > 0.05;
36
+ }"""
37
+
38
+
39
+ def main():
40
+ ap = argparse.ArgumentParser(description=__doc__,
41
+ formatter_class=argparse.RawDescriptionHelpFormatter)
42
+ ap.add_argument("--url", required=True)
43
+ ap.add_argument("--csv")
44
+ ap.add_argument("--tabs", default="cp-tab-%d/cp-pane-%d:4",
45
+ help="tab/pane attrID patterns and how many")
46
+ ap.add_argument("--faq", default="cp-faq-q-%d/cp-faq-a-%d:4")
47
+ ap.add_argument("--form", default="cp-form")
48
+ ap.add_argument("--map", default="cp-map")
49
+ a = ap.parse_args()
50
+
51
+ rows, fails = [], []
52
+
53
+ def check(name, ok, detail=""):
54
+ rows.append([name, "PASS" if ok else "FAIL", detail])
55
+ print(" %-28s %-5s %s" % (name, "PASS" if ok else "FAIL", detail))
56
+ if not ok:
57
+ fails.append(name)
58
+
59
+ def split(spec):
60
+ pat, n = spec.rsplit(":", 1)
61
+ tab, pane = pat.split("/")
62
+ return tab, pane, int(n)
63
+
64
+ tab_pat, pane_pat, n_tabs = split(a.tabs)
65
+ q_pat, ans_pat, n_faq = split(a.faq)
66
+
67
+ with sync_playwright() as pw:
68
+ b = pw.chromium.launch()
69
+ p = b.new_context(viewport={"width": 1280, "height": 900}).new_page()
70
+ p.goto(a.url, wait_until="load")
71
+ p.wait_for_timeout(2200)
72
+
73
+ # ---- 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))
93
+
94
+ # ---- 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)
106
+
107
+ # ---- 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)
121
+
122
+ # ---- 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 ""))
132
+ b.close()
133
+
134
+ print("\n%d checks, %d FAIL" % (len(rows), len(fails)))
135
+ if a.csv:
136
+ with open(a.csv, "w", newline="", encoding="utf-8") as fh:
137
+ w = csv.writer(fh)
138
+ w.writerow(["check", "result", "detail"])
139
+ w.writerows(rows)
140
+ print("wrote %s" % a.csv)
141
+ sys.exit(1 if fails else 0)
142
+
143
+
144
+ if __name__ == "__main__":
145
+ main()