mosaic-headless 1.21.2 → 1.23.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 +10 -1
- 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/dialog-form-verification.csv +50 -0
- package/data/interaction-rules-verification.csv +18 -0
- package/data/value-coercion-verification.csv +13 -0
- package/package.json +1 -1
- package/references/dialog-and-triggers.md +120 -3
- package/references/failure-modes.md +50 -0
- package/references/interactions.md +33 -0
- package/references/styling.md +29 -0
- package/references/write-protocol.md +31 -0
- package/scripts/check-release.mjs +3 -0
- package/sites/_moksa.py +12 -1
- package/sites/moksa.json +12823 -2
- package/tools/build_page.py +20 -0
- package/tools/check_tables.py +81 -0
- package/tools/verify_dialog_lab.py +348 -0
package/tools/build_page.py
CHANGED
|
@@ -65,6 +65,11 @@ def load_csv(name):
|
|
|
65
65
|
return list(csv.DictReader(fh))
|
|
66
66
|
|
|
67
67
|
|
|
68
|
+
# Tags that may legitimately appear inside wysiwyg-text, which is raw HTML and is
|
|
69
|
+
# where this skill's own pages put their inline emphasis. Everything else in prose
|
|
70
|
+
# is almost certainly a tag NAME being discussed rather than markup intended to run.
|
|
71
|
+
PROSE_TAG = re.compile(r"</?(?!(?:b|i|em|strong|br|span|a|code|sup|sub)\b)"
|
|
72
|
+
r"[a-zA-Z][a-zA-Z0-9-]*\s*[^>]*>")
|
|
68
73
|
AT_RULE_GLUED = re.compile(
|
|
69
74
|
r"@(media|supports|container|layer|scope|import|page|starting-style|"
|
|
70
75
|
r"font-feature-values|counter-style|property|document)\(")
|
|
@@ -103,6 +108,21 @@ class Surface:
|
|
|
103
108
|
# Measured both ways on the demo site (`@media (` renders, `@media(`
|
|
104
109
|
# does not). Refused here for the same reason an unsafe type is: the
|
|
105
110
|
# failure is silent at the only point this tool can still stop it.
|
|
111
|
+
# `wysiwyg-text` is RAW HTML, not escaped text, so a tag NAME written in
|
|
112
|
+
# prose opens an element and swallows the rest of the paragraph. "Mosaic's
|
|
113
|
+
# modal is a real <dialog>" delivered the words up to "real" and nothing
|
|
114
|
+
# after it. The commit succeeds and the page renders - it is just missing
|
|
115
|
+
# most of its sentence, which is why this is worth refusing rather than
|
|
116
|
+
# leaving to proofreading. Inline formatting tags are let through; anything
|
|
117
|
+
# else has to be written as an entity.
|
|
118
|
+
if t == "wysiwyg-text" and not force:
|
|
119
|
+
text = (node.get("data") or {}).get("text") or ""
|
|
120
|
+
hit = PROSE_TAG.search(text)
|
|
121
|
+
if hit:
|
|
122
|
+
problems.append("wysiwyg-text contains `%s` - the text is raw HTML, so "
|
|
123
|
+
"this opens an element and the rest of the paragraph "
|
|
124
|
+
"disappears; write it as <%s>"
|
|
125
|
+
% (hit.group(0), hit.group(0).strip("<>/")))
|
|
106
126
|
if t == "code" and not force:
|
|
107
127
|
content = (node.get("data") or {}).get("content") or ""
|
|
108
128
|
hit = AT_RULE_GLUED.search(content)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Re-extract every source-derived table and fail if a shipped one differs.
|
|
3
|
+
|
|
4
|
+
python tools/check_tables.py <plugin-root>
|
|
5
|
+
|
|
6
|
+
The failure this exists for is not a wrong value, it is a STALE one, and it ships
|
|
7
|
+
silently. Extracting the tables and then fixing the extractor leaves the shipped
|
|
8
|
+
table describing the old source with no sign anywhere: `node-types.csv` carried an
|
|
9
|
+
empty `default_element_class` for three releases because the column's regex was
|
|
10
|
+
repaired after that run, and nothing compared the two again.
|
|
11
|
+
|
|
12
|
+
Row counts cannot catch it - the count was right the whole time - and neither can
|
|
13
|
+
the release gate, which never sees the plugin source: it is licensed third-party
|
|
14
|
+
code and is not in the repository. So this is a local step, and the right moment
|
|
15
|
+
for it is after ANY change to a tool under `tools/extract_*.py`, not only after a
|
|
16
|
+
plugin upgrade.
|
|
17
|
+
"""
|
|
18
|
+
import filecmp
|
|
19
|
+
import os
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import tempfile
|
|
23
|
+
|
|
24
|
+
EXTRACTORS = [
|
|
25
|
+
"extract_node_types.py",
|
|
26
|
+
"extract_style_properties.py",
|
|
27
|
+
"extract_placement.py",
|
|
28
|
+
"extract_default_children.py",
|
|
29
|
+
"extract_dynamic_variables.py",
|
|
30
|
+
"extract_interactions.py",
|
|
31
|
+
"extract_pluggables.py",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def main():
|
|
36
|
+
if len(sys.argv) != 2:
|
|
37
|
+
sys.exit(__doc__)
|
|
38
|
+
plugin_root = sys.argv[1]
|
|
39
|
+
here = os.path.dirname(os.path.abspath(__file__))
|
|
40
|
+
data = os.path.join(here, "..", "data")
|
|
41
|
+
if not os.path.isdir(plugin_root):
|
|
42
|
+
sys.exit("no plugin source at %s" % plugin_root)
|
|
43
|
+
|
|
44
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
45
|
+
for name in EXTRACTORS:
|
|
46
|
+
r = subprocess.run([sys.executable, os.path.join(here, name),
|
|
47
|
+
plugin_root, tmp],
|
|
48
|
+
capture_output=True, text=True, encoding="utf-8",
|
|
49
|
+
errors="replace")
|
|
50
|
+
if r.returncode != 0:
|
|
51
|
+
sys.exit("%s failed:\n%s" % (name, (r.stdout + r.stderr)[-600:]))
|
|
52
|
+
|
|
53
|
+
stale, checked = [], 0
|
|
54
|
+
for fname in sorted(os.listdir(tmp)):
|
|
55
|
+
shipped = os.path.join(data, fname)
|
|
56
|
+
if not os.path.exists(shipped):
|
|
57
|
+
stale.append("%s: extracted but not shipped" % fname)
|
|
58
|
+
continue
|
|
59
|
+
checked += 1
|
|
60
|
+
if not filecmp.cmp(shipped, os.path.join(tmp, fname), shallow=False):
|
|
61
|
+
# name the columns that moved, not just the file
|
|
62
|
+
with open(shipped, encoding="utf-8") as a, \
|
|
63
|
+
open(os.path.join(tmp, fname), encoding="utf-8") as b:
|
|
64
|
+
al, bl = a.read().splitlines(), b.read().splitlines()
|
|
65
|
+
first = next((i for i, (x, y) in enumerate(zip(al, bl)) if x != y), None)
|
|
66
|
+
detail = ("%d lines -> %d" % (len(al), len(bl)) if len(al) != len(bl)
|
|
67
|
+
else "first difference at line %d:\n shipped: %s\n fresh : %s"
|
|
68
|
+
% (first + 1, al[first][:110], bl[first][:110]))
|
|
69
|
+
stale.append("%s: %s" % (fname, detail))
|
|
70
|
+
|
|
71
|
+
print("checked %d source-derived tables against a fresh extraction" % checked)
|
|
72
|
+
if stale:
|
|
73
|
+
print("\nSTALE:")
|
|
74
|
+
for s in stale:
|
|
75
|
+
print(" - %s" % s)
|
|
76
|
+
sys.exit(1)
|
|
77
|
+
print("every one matches")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
if __name__ == "__main__":
|
|
81
|
+
main()
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Drive every modal on a dialog-lab page and record what actually happened.
|
|
3
|
+
|
|
4
|
+
python tools/verify_dialog_lab.py --url https://site/dialogs/ --csv data/dialog-form-verification.csv [--viewports 390,768]
|
|
5
|
+
|
|
6
|
+
answers "does the modal mechanism work". This answers the
|
|
7
|
+
question after it: can the three nodes be made into the FORMS a real site needs -
|
|
8
|
+
a bottom sheet, a side drawer, a corner notice, a full takeover, a gate with no
|
|
9
|
+
way out - and does each one still behave at phone width.
|
|
10
|
+
|
|
11
|
+
Each form is checked for the thing that form is FOR, not just "it opened":
|
|
12
|
+
the sheet for sitting at the bottom, the drawer for being at the right edge and
|
|
13
|
+
for Esc being its only way out, the toast for having no overlay and not blocking
|
|
14
|
+
the page, the gate for Esc doing nothing, and the A/B pair for pickOne drawing
|
|
15
|
+
exactly one contender per click.
|
|
16
|
+
"""
|
|
17
|
+
import collections
|
|
18
|
+
import csv
|
|
19
|
+
import sys
|
|
20
|
+
from playwright.sync_api import sync_playwright
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
|
|
24
|
+
AP = argparse.ArgumentParser(description=__doc__)
|
|
25
|
+
AP.add_argument("--url", required=True, help="the dialog lab page")
|
|
26
|
+
AP.add_argument("--csv")
|
|
27
|
+
AP.add_argument("--viewports", default="390,768",
|
|
28
|
+
help="widths at which every modal is re-opened and measured")
|
|
29
|
+
ARGS = AP.parse_args()
|
|
30
|
+
URL = ARGS.url
|
|
31
|
+
OUT = ARGS.csv
|
|
32
|
+
|
|
33
|
+
ROWS = []
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def row(check, result, detail=""):
|
|
37
|
+
ROWS.append((check, result, detail))
|
|
38
|
+
print("%-34s %-5s %s" % (check, result, detail))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
OPEN = "(id) => { const e = document.getElementById(id); return e ? !!e.open : null; }"
|
|
42
|
+
BOX = """(id) => {
|
|
43
|
+
const e = document.getElementById(id);
|
|
44
|
+
if (!e || !e.open) return null;
|
|
45
|
+
const w = e.querySelector('.m-modal-window');
|
|
46
|
+
const r = w.getBoundingClientRect();
|
|
47
|
+
const hr = e.getBoundingClientRect();
|
|
48
|
+
return {x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width),
|
|
49
|
+
h: Math.round(r.height),
|
|
50
|
+
hx: Math.round(hr.x), hy: Math.round(hr.y),
|
|
51
|
+
hw: Math.round(hr.width), hh: Math.round(hr.height),
|
|
52
|
+
vw: document.documentElement.clientWidth, vh: document.documentElement.clientHeight,
|
|
53
|
+
overlay: !!e.querySelector('.m-modal-overlay'),
|
|
54
|
+
ariaModal: e.getAttribute('aria-modal'),
|
|
55
|
+
closedby: e.getAttribute('data-mosaic-modal-closedby'),
|
|
56
|
+
tag: e.tagName.toLowerCase()};
|
|
57
|
+
}"""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def shut_all(p):
|
|
61
|
+
p.evaluate("""() => document.querySelectorAll('dialog.m-modal').forEach(d => {
|
|
62
|
+
if (d.open) { try { d.mosaicDialog ? d.mosaicDialog.close() : d.close(); }
|
|
63
|
+
catch(e) { d.close(); } } })""")
|
|
64
|
+
p.wait_for_timeout(250)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def main():
|
|
68
|
+
with sync_playwright() as pw:
|
|
69
|
+
b = pw.chromium.launch()
|
|
70
|
+
ctx = b.new_context(viewport={"width": 1280, "height": 900})
|
|
71
|
+
p = ctx.new_page()
|
|
72
|
+
p.goto(URL, wait_until="load")
|
|
73
|
+
p.wait_for_timeout(1600)
|
|
74
|
+
|
|
75
|
+
# ---- every modal is a real <dialog> and carries its own closedby
|
|
76
|
+
want = {"centre": "everything", "sheet": "clickOutside", "drawer": "esc",
|
|
77
|
+
"toast": "everything", "takeover": "everything", "gate": "nothing",
|
|
78
|
+
"exit": "everything", "depth": "clickOutside",
|
|
79
|
+
"aba": "everything", "abb": "everything"}
|
|
80
|
+
for key, cb in want.items():
|
|
81
|
+
got = p.evaluate("""(id) => { const e = document.getElementById(id);
|
|
82
|
+
return e ? [e.tagName.toLowerCase(),
|
|
83
|
+
e.getAttribute('data-mosaic-modal-closedby')] : null; }""",
|
|
84
|
+
"dl-" + key)
|
|
85
|
+
ok = got and got[0] == "dialog" and got[1] == cb
|
|
86
|
+
row("HOST/%s" % key, "PASS" if ok else "FAIL", "%s" % (got,))
|
|
87
|
+
|
|
88
|
+
# ---- the toast opened by itself, has no overlay, no aria-modal
|
|
89
|
+
t = p.evaluate(BOX, "dl-toast")
|
|
90
|
+
row("TOAST/pageLoad", "PASS" if t else "FAIL", "opened on load" if t else "shut")
|
|
91
|
+
if t:
|
|
92
|
+
# Authored WITHOUT an overlay. Mosaic's heal pass inserts one anyway -
|
|
93
|
+
# "a required structural part, non-deletable, one per Modal" - so the
|
|
94
|
+
# overlay-less modal is unreachable and aria-modal cannot be shed.
|
|
95
|
+
row("TOAST/overlay-synthesised", "PASS" if t["overlay"] else "FAIL",
|
|
96
|
+
"authored none; heal inserted one")
|
|
97
|
+
row("TOAST/aria-modal-unavoidable",
|
|
98
|
+
"PASS" if t["ariaModal"] == "true" else "FAIL",
|
|
99
|
+
"aria-modal=%r despite no authored overlay" % t["ariaModal"])
|
|
100
|
+
corner = t["x"] + t["w"] > t["vw"] * 0.6 and t["y"] < t["vh"] * 0.4
|
|
101
|
+
row("TOAST/top-right", "PASS" if corner else "FAIL",
|
|
102
|
+
"x=%d y=%d w=%d vw=%d" % (t["x"], t["y"], t["w"], t["vw"]))
|
|
103
|
+
# and because the overlay is there, it blocks like any other modal.
|
|
104
|
+
# Programmatic scrollTo() would sail straight through - this has to be
|
|
105
|
+
# a real wheel event to mean anything.
|
|
106
|
+
p.evaluate("() => scrollTo(0, 0)")
|
|
107
|
+
p.wait_for_timeout(200)
|
|
108
|
+
p.mouse.move(400, 500)
|
|
109
|
+
p.mouse.wheel(0, 700)
|
|
110
|
+
p.wait_for_timeout(600)
|
|
111
|
+
blocked = p.evaluate("() => scrollY") == 0
|
|
112
|
+
row("TOAST/wheel-blocked", "PASS" if blocked else "FAIL",
|
|
113
|
+
"a corner toast is a LAYOUT, not a non-blocking modal")
|
|
114
|
+
shut_all(p)
|
|
115
|
+
|
|
116
|
+
# ---- a closed modal must be INVISIBLE
|
|
117
|
+
#
|
|
118
|
+
# The check that this file exists for. A <dialog>'s open/closed visibility
|
|
119
|
+
# is its `display`, so an author-level `display:flex` on the host beats
|
|
120
|
+
# `dialog:not([open]){display:none}` and every modal on the page sits open
|
|
121
|
+
# forever. Nothing that only inspects the OPEN state can see it - the first
|
|
122
|
+
# version of this verifier passed 36/36 on a page where all ten were
|
|
123
|
+
# permanently on screen.
|
|
124
|
+
p.evaluate("""() => document.querySelectorAll('dialog.m-modal')
|
|
125
|
+
.forEach(d => { if (d.open) { try { d.close(); } catch (e) {} } })""")
|
|
126
|
+
p.wait_for_timeout(400)
|
|
127
|
+
shown = p.evaluate("""() => [...document.querySelectorAll('dialog.m-modal')]
|
|
128
|
+
.filter(d => !d.open)
|
|
129
|
+
.filter(d => { const r = d.getBoundingClientRect();
|
|
130
|
+
return r.width > 0 && r.height > 0; })
|
|
131
|
+
.map(d => d.id + ' (display:' + getComputedStyle(d).display + ')')""")
|
|
132
|
+
row("CLOSED_IS_HIDDEN", "PASS" if not shown else "FAIL",
|
|
133
|
+
"every closed modal is display:none" if not shown
|
|
134
|
+
else "still rendered: %s" % ", ".join(shown))
|
|
135
|
+
# ---- each click-opened form lands where its host put it
|
|
136
|
+
def open_and_box(key):
|
|
137
|
+
p.click("#dl-open-%s" % key)
|
|
138
|
+
p.wait_for_timeout(500)
|
|
139
|
+
return p.evaluate(BOX, "dl-" + key)
|
|
140
|
+
|
|
141
|
+
c = open_and_box("centre")
|
|
142
|
+
if c:
|
|
143
|
+
mid = abs((c["x"] + c["w"] / 2) - c["vw"] / 2) < 30
|
|
144
|
+
row("CENTRE/centred", "PASS" if mid else "FAIL",
|
|
145
|
+
"x=%d w=%d vw=%d" % (c["x"], c["w"], c["vw"]))
|
|
146
|
+
row("CENTRE/aria-modal", "PASS" if c["ariaModal"] == "true" else "FAIL",
|
|
147
|
+
"aria-modal=%r (overlay=%s)" % (c["ariaModal"], c["overlay"]))
|
|
148
|
+
p.keyboard.press("Escape")
|
|
149
|
+
p.wait_for_timeout(400)
|
|
150
|
+
row("CENTRE/esc-closes", "PASS" if not p.evaluate(OPEN, "dl-centre") else "FAIL",
|
|
151
|
+
"closedby=everything")
|
|
152
|
+
else:
|
|
153
|
+
row("CENTRE/centred", "FAIL", "did not open")
|
|
154
|
+
shut_all(p)
|
|
155
|
+
|
|
156
|
+
s = open_and_box("sheet")
|
|
157
|
+
if s:
|
|
158
|
+
bottom = abs((s["y"] + s["h"]) - s["vh"]) < 4
|
|
159
|
+
full = s["w"] > 600
|
|
160
|
+
row("SHEET/at-bottom", "PASS" if bottom else "FAIL",
|
|
161
|
+
"y+h=%d vh=%d" % (s["y"] + s["h"], s["vh"]))
|
|
162
|
+
row("SHEET/wide", "PASS" if full else "FAIL", "w=%d" % s["w"])
|
|
163
|
+
p.keyboard.press("Escape")
|
|
164
|
+
p.wait_for_timeout(400)
|
|
165
|
+
still = p.evaluate(OPEN, "dl-sheet")
|
|
166
|
+
row("SHEET/esc-does-nothing", "PASS" if still else "FAIL",
|
|
167
|
+
"closedby=clickOutside, so Esc must not close it")
|
|
168
|
+
shut_all(p)
|
|
169
|
+
|
|
170
|
+
d = open_and_box("drawer")
|
|
171
|
+
if d:
|
|
172
|
+
right = abs((d["x"] + d["w"]) - (d["hx"] + d["hw"])) < 4
|
|
173
|
+
tall = d["h"] > d["vh"] * 0.9
|
|
174
|
+
row("DRAWER/right-edge", "PASS" if right else "FAIL",
|
|
175
|
+
"x+w=%d layer right=%d" % (d["x"] + d["w"], d["hx"] + d["hw"]))
|
|
176
|
+
row("DRAWER/full-height", "PASS" if tall else "FAIL",
|
|
177
|
+
"h=%d vh=%d" % (d["h"], d["vh"]))
|
|
178
|
+
p.keyboard.press("Escape")
|
|
179
|
+
p.wait_for_timeout(400)
|
|
180
|
+
row("DRAWER/esc-closes", "PASS" if not p.evaluate(OPEN, "dl-drawer") else "FAIL",
|
|
181
|
+
"closedby=esc")
|
|
182
|
+
shut_all(p)
|
|
183
|
+
|
|
184
|
+
k = open_and_box("takeover")
|
|
185
|
+
if k:
|
|
186
|
+
fills = k["w"] >= k["hw"] - 2 and k["h"] >= k["hh"] - 2
|
|
187
|
+
row("TAKEOVER/fills-viewport", "PASS" if fills else "FAIL",
|
|
188
|
+
"%dx%d vs layer %dx%d" % (k["w"], k["h"], k["hw"], k["hh"]))
|
|
189
|
+
shut_all(p)
|
|
190
|
+
|
|
191
|
+
g = open_and_box("gate")
|
|
192
|
+
if g:
|
|
193
|
+
p.keyboard.press("Escape")
|
|
194
|
+
p.wait_for_timeout(400)
|
|
195
|
+
row("GATE/esc-refused", "PASS" if p.evaluate(OPEN, "dl-gate") else "FAIL",
|
|
196
|
+
"closedby=nothing")
|
|
197
|
+
p.mouse.click(8, 8) # the overlay, i.e. outside the window
|
|
198
|
+
p.wait_for_timeout(400)
|
|
199
|
+
row("GATE/outside-refused", "PASS" if p.evaluate(OPEN, "dl-gate") else "FAIL",
|
|
200
|
+
"clicking the overlay must not close it")
|
|
201
|
+
p.click("#dl-close-gate")
|
|
202
|
+
p.wait_for_timeout(400)
|
|
203
|
+
row("GATE/button-closes", "PASS" if not p.evaluate(OPEN, "dl-gate") else "FAIL",
|
|
204
|
+
"the modalClose inside is the only exit")
|
|
205
|
+
shut_all(p)
|
|
206
|
+
|
|
207
|
+
# ---- scrollDepth at 75%
|
|
208
|
+
p.reload(wait_until="load")
|
|
209
|
+
p.wait_for_timeout(1500)
|
|
210
|
+
shut_all(p)
|
|
211
|
+
row("DEPTH/shut-at-rest", "PASS" if not p.evaluate(OPEN, "dl-depth") else "FAIL")
|
|
212
|
+
p.evaluate("() => scrollTo(0, document.body.scrollHeight * 0.85)")
|
|
213
|
+
p.wait_for_timeout(1400)
|
|
214
|
+
row("DEPTH/opens-past-75", "PASS" if p.evaluate(OPEN, "dl-depth") else "FAIL",
|
|
215
|
+
"scrollDepth threshold 75%")
|
|
216
|
+
shut_all(p)
|
|
217
|
+
|
|
218
|
+
# ---- exit intent: pointer leaves through the top
|
|
219
|
+
p.evaluate("() => scrollTo(0, 0)")
|
|
220
|
+
p.mouse.move(640, 400)
|
|
221
|
+
p.wait_for_timeout(300)
|
|
222
|
+
p.mouse.move(640, 0)
|
|
223
|
+
p.evaluate("""() => document.dispatchEvent(
|
|
224
|
+
new MouseEvent('mouseleave', {clientY: -5, bubbles: true}))""")
|
|
225
|
+
p.wait_for_timeout(1600)
|
|
226
|
+
row("EXIT/opens", "PASS" if p.evaluate(OPEN, "dl-exit") else "WARN",
|
|
227
|
+
"exitIntent is desktop-only and synthetic leave is approximate")
|
|
228
|
+
shut_all(p)
|
|
229
|
+
|
|
230
|
+
# ---- the latch: pickOne on a click is NOT a lottery
|
|
231
|
+
#
|
|
232
|
+
# Whichever of the two buttons is clicked FIRST wins the draw and records
|
|
233
|
+
# itself; the other is then suppressed for as long as `remember` holds.
|
|
234
|
+
# Running it both ways is what separates "first evaluated wins" from
|
|
235
|
+
# "first in the payload wins" - only the former flips with the order.
|
|
236
|
+
def latch(order, n=6):
|
|
237
|
+
seen = collections.Counter()
|
|
238
|
+
for i in range(n):
|
|
239
|
+
c2 = b.new_context(viewport={"width": 1280, "height": 900})
|
|
240
|
+
p2 = c2.new_page()
|
|
241
|
+
p2.goto(URL + "?v=%s%d" % (order[0], i), wait_until="load")
|
|
242
|
+
p2.wait_for_timeout(2200)
|
|
243
|
+
p2.evaluate("""() => document.querySelectorAll('dialog.m-modal')
|
|
244
|
+
.forEach(d => { if (d.open) d.close(); })""")
|
|
245
|
+
steps = []
|
|
246
|
+
for which in order:
|
|
247
|
+
p2.click("#dl-open-%s" % which)
|
|
248
|
+
p2.wait_for_timeout(600)
|
|
249
|
+
opened = [k for k in ("aba", "abb") if p2.evaluate(OPEN, "dl-" + k)]
|
|
250
|
+
steps.append("%s>%s" % (which, "+".join(opened) or "none"))
|
|
251
|
+
p2.evaluate("""() => document.querySelectorAll('dialog.m-modal')
|
|
252
|
+
.forEach(d => { if (d.open) d.close(); })""")
|
|
253
|
+
p2.wait_for_timeout(200)
|
|
254
|
+
seen[",".join(steps)] += 1
|
|
255
|
+
if i == 0:
|
|
256
|
+
rec = p2.evaluate("""() => { try { return localStorage['mos:v1:r:dl-ab']
|
|
257
|
+
|| null; } catch(e) { return null; } }""")
|
|
258
|
+
row("LATCH/record-%s" % order[0],
|
|
259
|
+
"PASS" if rec and "lastID" in rec else "WARN", (rec or "none")[:80])
|
|
260
|
+
c2.close()
|
|
261
|
+
return seen
|
|
262
|
+
|
|
263
|
+
def score(seen, order):
|
|
264
|
+
"""Runs where the FIRST click opened its own modal and the second
|
|
265
|
+
opened nothing. A run where the first click opened nothing at all is
|
|
266
|
+
a lost click, not a counter-example - it is reported, not scored."""
|
|
267
|
+
want = "%s>%s,%s>none" % (order[0], order[0], order[1])
|
|
268
|
+
lost = sum(v for k, v in seen.items() if k.startswith("%s>none" % order[0]))
|
|
269
|
+
return sum(v for k, v in seen.items() if k == want), lost, sum(seen.values())
|
|
270
|
+
|
|
271
|
+
first_b = latch(["abb", "aba"])
|
|
272
|
+
gb, lb, nb = score(first_b, ["abb", "aba"])
|
|
273
|
+
row("LATCH/B-first-wins", "PASS" if gb == nb - lb and gb else "FAIL",
|
|
274
|
+
"%d/%d as predicted, %d lost clicks %s" % (gb, nb, lb, dict(first_b)))
|
|
275
|
+
first_a = latch(["aba", "abb"])
|
|
276
|
+
ga, la, na = score(first_a, ["aba", "abb"])
|
|
277
|
+
row("LATCH/A-first-wins", "PASS" if ga == na - la and ga else "FAIL",
|
|
278
|
+
"%d/%d as predicted, %d lost clicks %s" % (ga, na, la, dict(first_a)))
|
|
279
|
+
row("LATCH/order-decides",
|
|
280
|
+
"PASS" if (gb and ga) else "FAIL",
|
|
281
|
+
"the winner flips with click order, so it is the first EVALUATED, "
|
|
282
|
+
"not the first authored")
|
|
283
|
+
|
|
284
|
+
# ---- and every form again on a phone and a tablet
|
|
285
|
+
#
|
|
286
|
+
# The page passing a width check says nothing about the modals: they are
|
|
287
|
+
# a separate layer that only exists once something opens them, so a
|
|
288
|
+
# takeover with desktop padding can overflow on a phone while the
|
|
289
|
+
# document underneath measures clean.
|
|
290
|
+
FIT = """(id) => {
|
|
291
|
+
const e = document.getElementById(id);
|
|
292
|
+
if (!e || !e.open) return null;
|
|
293
|
+
const w = e.querySelector('.m-modal-window');
|
|
294
|
+
const r = w.getBoundingClientRect(), h = e.getBoundingClientRect();
|
|
295
|
+
let over = 0, worst = null;
|
|
296
|
+
w.querySelectorAll('*').forEach(n => {
|
|
297
|
+
const b = n.getBoundingClientRect();
|
|
298
|
+
if (b.right - r.right > over) { over = b.right - r.right;
|
|
299
|
+
worst = n.id || n.className; } });
|
|
300
|
+
return {fits: r.left >= h.left - 1 && r.right <= h.right + 1,
|
|
301
|
+
scrollW: w.scrollWidth, clientW: w.clientWidth,
|
|
302
|
+
over: Math.round(over), worst: worst,
|
|
303
|
+
doc: document.documentElement.scrollWidth
|
|
304
|
+
> document.documentElement.clientWidth};
|
|
305
|
+
}"""
|
|
306
|
+
for vw in [int(x) for x in ARGS.viewports.split(",") if x.strip()]:
|
|
307
|
+
cm = b.new_context(viewport={"width": vw, "height": 780})
|
|
308
|
+
pm = cm.new_page()
|
|
309
|
+
pm.goto(URL, wait_until="load")
|
|
310
|
+
pm.wait_for_timeout(2000)
|
|
311
|
+
pm.evaluate("() => document.querySelectorAll('dialog')"
|
|
312
|
+
".forEach(d => d.open && d.close())")
|
|
313
|
+
for key in ("centre", "sheet", "drawer", "takeover", "gate", "aba"):
|
|
314
|
+
try:
|
|
315
|
+
pm.click("#dl-open-%s" % key, timeout=5000)
|
|
316
|
+
pm.wait_for_timeout(450)
|
|
317
|
+
except Exception:
|
|
318
|
+
row("FIT%d/%s" % (vw, key), "FAIL", "opener not clickable")
|
|
319
|
+
continue
|
|
320
|
+
f = pm.evaluate(FIT, "dl-" + key)
|
|
321
|
+
if not f:
|
|
322
|
+
row("FIT%d/%s" % (vw, key), "FAIL", "did not open")
|
|
323
|
+
else:
|
|
324
|
+
bad = (not f["fits"] or f["scrollW"] > f["clientW"] + 1
|
|
325
|
+
or f["over"] > 1 or f["doc"])
|
|
326
|
+
row("FIT%d/%s" % (vw, key), "FAIL" if bad else "PASS",
|
|
327
|
+
"scrollW=%d/%d childOverflow=%d%s pageScrollsSideways=%s"
|
|
328
|
+
% (f["scrollW"], f["clientW"], f["over"],
|
|
329
|
+
(" <%s>" % f["worst"]) if f["over"] > 1 else "", f["doc"]))
|
|
330
|
+
pm.evaluate("() => document.querySelectorAll('dialog')"
|
|
331
|
+
".forEach(d => d.open && d.close())")
|
|
332
|
+
pm.wait_for_timeout(200)
|
|
333
|
+
cm.close()
|
|
334
|
+
|
|
335
|
+
b.close()
|
|
336
|
+
|
|
337
|
+
if OUT:
|
|
338
|
+
with open(OUT, "w", newline="", encoding="utf-8") as f:
|
|
339
|
+
w = csv.writer(f)
|
|
340
|
+
w.writerow(["check", "result", "detail"])
|
|
341
|
+
w.writerows(ROWS)
|
|
342
|
+
bad = [r for r in ROWS if r[1] == "FAIL"]
|
|
343
|
+
print("\n%d checks, %d FAIL" % (len(ROWS), len(bad)))
|
|
344
|
+
return 1 if bad else 0
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
if __name__ == "__main__":
|
|
348
|
+
sys.exit(main())
|