@junixlabs/uxcli 0.1.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.md +81 -0
- package/VISION.md +71 -0
- package/bin/uxcli.js +31 -0
- package/examples/sylius-guest-checkout.json +11 -0
- package/package.json +16 -0
- package/src/browser.js +103 -0
- package/src/card.js +27 -0
- package/src/gate.js +33 -0
- package/src/journey.js +13 -0
- package/src/probes/consistent-navigation/must-fail/review.html +76 -0
- package/src/probes/consistent-navigation/pair.json +23 -0
- package/src/probes/consistent-navigation/probe.js +40 -0
- package/src/probes/consistent-navigation/spec.md +24 -0
- package/src/probes/error-prevention/must-fail/review.html +67 -0
- package/src/probes/error-prevention/pair.json +23 -0
- package/src/probes/error-prevention/probe.js +90 -0
- package/src/probes/error-prevention/spec.md +35 -0
- package/src/probes/redundant-entry/must-fail/review.html +78 -0
- package/src/probes/redundant-entry/pair.json +23 -0
- package/src/probes/redundant-entry/probe.js +59 -0
- package/src/probes/redundant-entry/spec.md +37 -0
- package/src/refute.js +23 -0
- package/src/run.js +58 -0
- package/src/util.js +6 -0
- package/test/fixtures/checkout/account.html +36 -0
- package/test/fixtures/checkout/cart.html +46 -0
- package/test/fixtures/checkout/contact.html +36 -0
- package/test/fixtures/checkout/details.html +77 -0
- package/test/fixtures/checkout/done.html +46 -0
- package/test/fixtures/checkout/help.html +36 -0
- package/test/fixtures/checkout/home.html +36 -0
- package/test/fixtures/checkout/privacy.html +36 -0
- package/test/fixtures/checkout/review.html +76 -0
- package/test/fixtures/checkout/shop.html +36 -0
- package/test/fixtures/checkout/style.css +28 -0
- package/test/fixtures/checkout/terms.html +36 -0
- package/test/journeys/checkout.json +10 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Review your order – Fixture Shop</title>
|
|
7
|
+
<link rel="stylesheet" href="style.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<header>
|
|
11
|
+
<div class="wrap">
|
|
12
|
+
<a class="brand" href="home.html">Fixture Shop</a>
|
|
13
|
+
<nav aria-label="Main">
|
|
14
|
+
<ul>
|
|
15
|
+
<li><a href="home.html">Home</a></li>
|
|
16
|
+
<li><a href="shop.html">Shop</a></li>
|
|
17
|
+
<li><a href="cart.html">Cart <span class="badge">2<span class="sr"> items</span></span></a></li>
|
|
18
|
+
<li><a href="account.html">Account</a></li>
|
|
19
|
+
<li><a href="help.html">Help</a></li>
|
|
20
|
+
</ul>
|
|
21
|
+
</nav>
|
|
22
|
+
</div>
|
|
23
|
+
</header>
|
|
24
|
+
<main class="wrap">
|
|
25
|
+
<h1>Review your order</h1>
|
|
26
|
+
<p>Check everything is correct before you place your order.</p>
|
|
27
|
+
|
|
28
|
+
<h2>Items</h2>
|
|
29
|
+
<table>
|
|
30
|
+
<thead>
|
|
31
|
+
<tr><th scope="col">Item</th><th scope="col" class="num">Qty</th><th scope="col" class="num">Price</th></tr>
|
|
32
|
+
</thead>
|
|
33
|
+
<tbody>
|
|
34
|
+
<tr><td>Ceramic mug</td><td class="num">1</td><td class="num">£12.00</td></tr>
|
|
35
|
+
<tr><td>Notebook, A5, dotted</td><td class="num">1</td><td class="num">£8.50</td></tr>
|
|
36
|
+
</tbody>
|
|
37
|
+
</table>
|
|
38
|
+
<p class="total">Total: £20.50</p>
|
|
39
|
+
|
|
40
|
+
<form id="order-form" action="done.html" method="get">
|
|
41
|
+
<button type="submit">Place order</button>
|
|
42
|
+
</form>
|
|
43
|
+
<script>
|
|
44
|
+
(function () {
|
|
45
|
+
var fields = ['name', 'email', 'address', 'postcode', 'country'];
|
|
46
|
+
var saved = {};
|
|
47
|
+
try { saved = JSON.parse(sessionStorage.getItem('checkout') || '{}'); } catch (e) {}
|
|
48
|
+
fields.forEach(function (f) {
|
|
49
|
+
document.getElementById('out-' + f).textContent = saved[f] || '';
|
|
50
|
+
});
|
|
51
|
+
document.getElementById('order-form').addEventListener('submit', function (ev) {
|
|
52
|
+
ev.preventDefault();
|
|
53
|
+
sessionStorage.setItem('orderPlaced', '1');
|
|
54
|
+
location.href = 'done.html';
|
|
55
|
+
});
|
|
56
|
+
})();
|
|
57
|
+
</script>
|
|
58
|
+
</main>
|
|
59
|
+
<footer>
|
|
60
|
+
<div class="wrap">
|
|
61
|
+
<a href="privacy.html">Privacy</a>
|
|
62
|
+
<a href="terms.html">Terms</a>
|
|
63
|
+
<a href="contact.html">Contact</a>
|
|
64
|
+
</div>
|
|
65
|
+
</footer>
|
|
66
|
+
</body>
|
|
67
|
+
</html>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"journey": "test/journeys/checkout.json",
|
|
3
|
+
"operator": "review.html: the \"Your details\" summary and its Change links are removed; the entry form has no client-side validation, so no branch of 3.3.4 can hold",
|
|
4
|
+
"hashes": {
|
|
5
|
+
"site": {
|
|
6
|
+
"account.html": "3972383a0e127789055e87f517e5812e7d0aadf98a979748b9468cbf5cdcef29",
|
|
7
|
+
"cart.html": "c40a826acc5df143e6c8bdb8b2ad95b6c236a1bf24315ef4a729902209fa3125",
|
|
8
|
+
"contact.html": "fe0b4f737714599aea8ffc61a0e3e66e66eb2ef900f97f29970d90085bdfb457",
|
|
9
|
+
"details.html": "866dedf0b0ff5baea57490907b07d2f726f00c69add81128e7938438e8f4d987",
|
|
10
|
+
"done.html": "714bfde7c1ba4e24d162f1faf9ab781ac0d06f44b149441c4845e740dcc32102",
|
|
11
|
+
"help.html": "e457b0e9a716019065de458d89114c37fbd14e67ada1ec69c8a2f65efbd1acbf",
|
|
12
|
+
"home.html": "287874d84b918ed8d6c16ce73d9ab631a489a5f9263933811e8ea960702126b2",
|
|
13
|
+
"privacy.html": "24d287f9a4e59218d69eb38360f674a92ada08860f2b194f71b7be2b9dbbdeff",
|
|
14
|
+
"review.html": "6a2191f30dc103062e986c7fada8f036f0931bc2247a70ed599c8b7d50b361b4",
|
|
15
|
+
"shop.html": "56a4d04abc3ca851bc29aff43be027c7f6406aeb0eb90d5dccc39c6088a1e679",
|
|
16
|
+
"style.css": "7f85da7845e02428fb480f86b8bcc3e32ae491882f27bcf35c10caef88999586",
|
|
17
|
+
"terms.html": "bd6a5c83a4e786574107a3aa02dc18e5b327624ebc14739fd0ae1785d180c187"
|
|
18
|
+
},
|
|
19
|
+
"mustFail": {
|
|
20
|
+
"review.html": "b223ad7ac8884c95b04e29fa5e6adef45194919f8360e07153907eb8c6ae74bd"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// flow.error-prevention · WCAG 3.3.4. Spec in spec.md; falsification pair in pair.json.
|
|
2
|
+
import { norm, alnum, normUrl } from '../../util.js';
|
|
3
|
+
import { screen, arrive, fillStep, act, evalIn, shot } from '../../browser.js';
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
id: 'flow.error-prevention', sc: '3.3.4',
|
|
7
|
+
async onStep(page, rec, ctx) {
|
|
8
|
+
const i = rec.i, step = ctx.J.steps[i];
|
|
9
|
+
if (i === ctx.commitIdx - 1 && !step.fill) ctx.reviewScreen = await screen(page, i, rec);
|
|
10
|
+
if (i === ctx.commitIdx) { const prev = ctx.steps[i - 1]; ctx.commitScreen = { ...(await screen(page, i, rec)), flowBreak: !!(prev && prev.flowBreak && rec.arrivedBy === 'goto') }; }
|
|
11
|
+
if (i === ctx.commitIdx) (rec.evidence ||= {})['3.3.4'] = await shot(page, 'main', []);
|
|
12
|
+
},
|
|
13
|
+
async evaluate(ctx) {
|
|
14
|
+
const { J, recorded, segOf, breakAfter, commitIdx, commitScreen, reviewScreen, authSteps, blocked } = ctx; const ep = {};
|
|
15
|
+
const priorAll = recorded.filter(r => commitIdx >= 0 && r.step < commitIdx && !(authSteps.has(r.step) && r.credentialCandidate));
|
|
16
|
+
const priorVals = priorAll.filter(r => segOf[r.step] === segOf[commitIdx]);
|
|
17
|
+
const priorOutside = priorAll.filter(r => segOf[r.step] !== segOf[commitIdx]);
|
|
18
|
+
if (J.sameProcess) ep.override = { sameProcess: J.sameProcess, provenance: 'project' };
|
|
19
|
+
if (priorOutside.length) ep.outsideSegment = priorOutside.map(r => ({ field: r.name || r.label, step: r.step, note: 'recorded in another process segment; not compared' }));
|
|
20
|
+
if (blocked) return { ...ep, verdict: 'blocked' };
|
|
21
|
+
if (commitIdx < 0) return { ...ep, verdict: 'not-committed' };
|
|
22
|
+
if (!commitScreen) return { ...ep, verdict: 'unmeasurable', why: 'commit screen not reached' };
|
|
23
|
+
if (!priorVals.length && priorOutside.length) return { ...ep, verdict: 'unmeasurable', why: 'premise broken at step ' + breakAfter(priorOutside[priorOutside.length - 1].step, commitIdx) + ': recorded values belong to another process segment (direct navigation or a submit that did not navigate); declare sameProcess to override' };
|
|
24
|
+
if (!priorVals.length) return { ...ep, verdict: 'not-applicable', why: 'no non-credential value recorded before the commit step (single-screen commit); probe measures cross-screen review only' };
|
|
25
|
+
if (commitScreen.flowBreak) return { ...ep, verdict: 'unmeasurable', why: 'flow break: previous submit did not change the page and the commit screen was reached by direct navigation' };
|
|
26
|
+
if (commitScreen.noise) return { ...ep, verdict: 'unmeasurable', why: 'commit screen text mutating with no interaction (600 ms)' };
|
|
27
|
+
if (new URL(commitScreen.url).origin !== new URL(J.steps[0].url).origin) return { ...ep, verdict: 'unmeasurable', why: 'commit screen on another origin' };
|
|
28
|
+
|
|
29
|
+
const dataSteps = new Set(priorVals.map(r => r.step));
|
|
30
|
+
const earlierUrls = new Set(J.steps.slice(0, commitIdx).map((s, i) => dataSteps.has(i) && s.url ? normUrl(s.url) : null).filter(Boolean));
|
|
31
|
+
const evalConfirmed = scr => {
|
|
32
|
+
const T = norm(scr.text), TA = alnum(scr.text); const missing = [], reformatted = [], present = [];
|
|
33
|
+
for (const r of priorVals) {
|
|
34
|
+
const cands = [r.value, r.display].map(norm).filter(Boolean);
|
|
35
|
+
const inText = cands.some(v => T.includes(v)), inField = scr.ro.some(f => cands.includes(norm(f.value)));
|
|
36
|
+
if (inText || inField) present.push({ value: r.value, where: inText ? 'text' : 'field' });
|
|
37
|
+
else if (cands.some(v => alnum(v).length >= 3 && TA.includes(alnum(v)))) reformatted.push({ value: r.value, note: 'present after stripping punctuation/whitespace: reformatted by site (invalid-if), reported as finding' });
|
|
38
|
+
else missing.push({ value: r.value, field: r.name || r.label || r.selector, step: r.step });
|
|
39
|
+
}
|
|
40
|
+
const changeCtl = scr.links.find(l => (l.href && earlierUrls.has(normUrl(l.href))) || /\b(change|edit|back|modify)\b/.test(l.text));
|
|
41
|
+
const editableHere = scr.ro.some(f => f.editable && priorVals.some(r => norm(f.value) === norm(r.value)));
|
|
42
|
+
return { holds: missing.length === 0 && (!!changeCtl || editableHere), screen: scr.step, present, reformatted, missing, changeMechanism: changeCtl || (editableHere ? 'values editable on this screen' : null) };
|
|
43
|
+
};
|
|
44
|
+
let conf = evalConfirmed(commitScreen);
|
|
45
|
+
if (!conf.holds && reviewScreen) { const c2 = evalConfirmed(reviewScreen); if (c2.holds) conf = { ...c2, note: 'review on the step before the commit screen' }; else conf.reviewStepAlso = { screen: c2.screen, missing: c2.missing, changeMechanism: c2.changeMechanism }; }
|
|
46
|
+
const branches = { confirmed: conf };
|
|
47
|
+
branches.checked = (!conf.holds && J.checkedPass) ? await checkedPass(ctx) : { holds: null, tested: false, why: conf.holds ? 'not needed' : 'journey does not allow the mutating checked pass' };
|
|
48
|
+
branches.reversible = { holds: !!J.reversible, provenance: 'project', declared: J.reversible || null };
|
|
49
|
+
ep.branches = branches;
|
|
50
|
+
if (conf.holds) return { ...ep, verdict: 'pass', branch: conf.note ? 'confirmed (review on preceding step)' : 'confirmed' };
|
|
51
|
+
if (branches.checked.holds) return { ...ep, verdict: 'pass', branch: 'checked' };
|
|
52
|
+
if (J.reversible) return { ...ep, verdict: 'pass', branch: 'reversible (project)' };
|
|
53
|
+
if (branches.checked.tested) return { ...ep, verdict: 'fail', why: 'no branch holds', missing: conf.missing };
|
|
54
|
+
return { ...ep, verdict: 'unmeasurable', why: 'confirmed false; checked untested; reversible not declared' };
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Separate pass: replay to the entry step, plant one input error, submit, read the outcome (definitions v8 rule 16).
|
|
59
|
+
async function checkedPass(ctx) {
|
|
60
|
+
const { J, recorded, commitIdx, browser } = ctx;
|
|
61
|
+
const pre = recorded.filter(r => r.step < commitIdx).sort((a, b) => b.step - a.step);
|
|
62
|
+
if (!pre.length) return { holds: null, tested: false, why: 'no entry step with recorded values' };
|
|
63
|
+
const onStep = pre.filter(r => r.step === pre[0].step);
|
|
64
|
+
let entry = onStep.find(r => r.required), probeValue = '', probeKind = 'required field emptied';
|
|
65
|
+
if (!entry) { entry = onStep.find(r => r.type === 'email'); probeValue = 'not-an-email'; probeKind = 'type=email given an invalid value'; }
|
|
66
|
+
if (!entry) { entry = onStep.find(r => r.type === 'url'); probeValue = 'not-a-url'; probeKind = 'type=url given an invalid value'; }
|
|
67
|
+
if (!entry) { entry = onStep.find(r => r.minlength > 1); probeValue = 'a'; probeKind = 'minlength field given one character'; }
|
|
68
|
+
const declared = !!entry;
|
|
69
|
+
if (!entry) { entry = onStep[0]; probeValue = ''; probeKind = 'undeclared field emptied (may be optional)'; }
|
|
70
|
+
const bctx = await browser.newContext({ viewport: { width: 1280, height: 800 } }); const page = await bctx.newPage();
|
|
71
|
+
const res = { tested: true, entryStep: entry.step, probedField: entry.name || entry.label || entry.selector, probeKind, probeValue, declaredConstraint: declared };
|
|
72
|
+
try {
|
|
73
|
+
for (let i = 0; i < entry.step; i++) { await arrive(page, J.steps[i]); await fillStep(page, J.steps[i], i, []); await act(page, J.steps[i]); }
|
|
74
|
+
const step = J.steps[entry.step]; await arrive(page, step);
|
|
75
|
+
await fillStep(page, { ...step, fill: Object.fromEntries(Object.entries(step.fill).map(([k, v]) => [k, k === entry.selector ? probeValue : v])) }, entry.step, []);
|
|
76
|
+
const urlBefore = page.url(), textBefore = await evalIn(page, '() => visibleText()');
|
|
77
|
+
await act(page, step);
|
|
78
|
+
const urlAfter = page.url(), textAfter = await evalIn(page, '() => visibleText()');
|
|
79
|
+
const formStill = await page.locator(entry.selector).count() > 0;
|
|
80
|
+
res.nativeValidation = await page.evaluate(() => { const e = [...document.querySelectorAll(':invalid')].find(x => x.validationMessage); return e ? { field: e.name || e.id, message: e.validationMessage } : null; });
|
|
81
|
+
const before = new Set(textBefore.split('\n').map(norm).filter(Boolean));
|
|
82
|
+
const newText = textAfter.split('\n').map(norm).filter(l => l && !before.has(l));
|
|
83
|
+
res.notAdvanced = normUrl(urlAfter) === normUrl(urlBefore) || formStill; res.urlBefore = urlBefore; res.urlAfter = urlAfter; res.newText = newText.slice(0, 5);
|
|
84
|
+
const blockedWithMessage = res.notAdvanced && (newText.length > 0 || !!res.nativeValidation);
|
|
85
|
+
if (blockedWithMessage) { res.holds = true; res.evidence = newText.length ? 'new visible text' : 'native constraint validation'; }
|
|
86
|
+
else if (declared) { res.holds = false; res.evidence = res.notAdvanced ? 'did not advance but no message' : 'advanced with an input error in a declared-constrained field'; }
|
|
87
|
+
else { res.holds = null; res.tested = false; res.why = 'probed field has no declared constraint and the step advanced; the field may be optional, nothing proven'; }
|
|
88
|
+
} catch (e) { res.holds = null; res.tested = false; res.why = 'checked pass error: ' + String(e).slice(0, 160); }
|
|
89
|
+
await bctx.close(); return res;
|
|
90
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# flow.error-prevention · WCAG 3.3.4 · provenance spec
|
|
2
|
+
|
|
3
|
+
- **why:** SC text: at least one of *reversible*, *checked*, *confirmed* must be true for pages that cause legal/financial commitments or modify user data.
|
|
4
|
+
- **applies-when:** the journey has a step marked `commit`, and at least one value was entered in an earlier step of the same process. Values of `type=password` are excluded. Credential fields are identity, not the information being submitted, and are excluded from the *confirmed* comparison: `type=password`, and, on a step whose fill includes a password, fields with `type=email`, `autocomplete` username/email, or name/id/label matching /user|login|email/i. Other fields on that step (a sign-up form's address) count. Credentials stay recorded for 3.3.7. If no non-credential value was recorded before the commit step (single-screen commit), the probe is `not-applicable`: it measures cross-screen review only.
|
|
5
|
+
- **correct-when (any one branch):**
|
|
6
|
+
- *confirmed*: on the screen holding the commit control, or on the screen of the step immediately before it when that step entered no values (a review step "before finalizing the submission", SC text), every recorded non-secret value is present as visible text or as the value of a read-only/disabled/pre-populated field (comparison after trimming, case-folding, collapsing whitespace; for `select`, the option's display text also counts), **and** a mechanism to correct exists: a link or button, outside site chrome (`header`, `nav`, `footer`, landmarks), whose href or text targets an earlier step that recorded non-authentication values (`href` equals that step's URL, or text matches /change|edit|back|modify/i), or the values are editable on the same screen. A logo or Home link to the journey's first step is not a correction mechanism.
|
|
7
|
+
- *checked*: the runner, in a separate pass, submits the entry step with one recorded field carrying a deliberate input error and reads the outcome. Field choice: a `required`/`aria-required` field emptied; else a `type=email` field set to `not-an-email`, `type=url` to `not-a-url`, a `minlength` field to one character; else the first recorded field emptied. Outcome: the step does not advance (same URL, or the same form still present) **and** either new visible text appears or native constraint validation fired (`:invalid` with a non-empty `validationMessage`) → *checked* holds. The step advances → *checked* is false only when the probed field was declared constrained (required, typed, minlength); an undeclared field may simply be optional, so advancing proves nothing and *checked* is `untested`. Programmatic association of the message is SC 3.3.1, not 3.3.4. The pass mutates the site and runs only where the journey marks `checkedPass: true`; otherwise `untested`.
|
|
8
|
+
- *reversible*: declared in the journey (`reversible: <url or text>`), provenance `project`; the runner does not measure it.
|
|
9
|
+
- **verdict:** `pass` with the satisfied branch named; `fail` only if *confirmed* is false, *checked* was **tested** and false, and *reversible* is not declared, listing the missing values; `unmeasurable` with `checked: untested` if *confirmed* is false and the checked pass was not allowed; `not-committed` if no `commit` step; `not-applicable` if no non-credential value was recorded before the commit step; `unmeasurable` if the commit screen could not be reached, was reached by direct navigation after the previous step's submit did not change the page (flow break: recorded values may not have carried), or the recorded values are empty.
|
|
10
|
+
- **invalid-if:** a value was reformatted by the site (dates, phone numbers, card numbers) so that normalized comparison fails; then that value is reported as `finding`, not counted as missing. Screen reached by a redirect to a different origin (third-party payment) is out of scope for this probe and reported as `unmeasurable`.
|
|
11
|
+
- **known-infidelity:** matching is textual, not semantic (a review that shows "J. Smith" for "John Smith" fails); *checked* mutates the flow and is skipped when the entry step has side effects declared in the journey; *reversible* is trusted, not measured.
|
|
12
|
+
|
|
13
|
+
## Input: Journey input (the commitment)
|
|
14
|
+
|
|
15
|
+
A journey is an ordered list of steps in one process. Each step: `url` (or an action that navigates), optional `fill` map, optional `submit` selector. One step may be marked `commit: true`: the action that causes a legal commitment, financial transaction, or modification of user data. The runner records every value the user (or the journey) enters, with the field's identity (`autocomplete`, `name`, `type`, accessible label) and the step index. If no step is marked `commit`, the 3.3.4 probe returns `not-committed`, never `fail`.
|
|
16
|
+
|
|
17
|
+
## Process boundary (new, applies to 3.3.4 and 3.3.7)
|
|
18
|
+
A journey is split into **segments**. Step i continues the segment of step i−1 only if it was reached by the previous step's own action (`arrivedBy: flow`) and the previous step's submit navigated (`flowBreak` absent). A step reached by direct navigation (`goto`), or following a submit that did not change the page, **starts a new segment**. Segments are the tool's measurement of "the same process" (WCAG 3.3.7 note; 3.3.4 "submission").
|
|
19
|
+
|
|
20
|
+
Override, provenance `project`: `sameProcess: [[from, to], …]` in the journey joins the named step ranges into one segment. The human commits that the steps are one process; the runner records the override on the verdict.
|
|
21
|
+
|
|
22
|
+
- **3.3.7:** a field matches only values recorded in the same segment. A match whose only prior lies in another segment is reported as `premise-broken` (step of the break named) and never counts as `fail`. Verdict: `fail` if any same-segment re-ask lacks a mechanism; else `unmeasurable · premise broken at step N` if any cross-segment match exists; else as v8.
|
|
23
|
+
- **3.3.4:** values compared on the commit screen are those recorded in the commit step's segment. If none are in the segment but some exist outside it → `unmeasurable · premise broken at step N`.
|
|
24
|
+
|
|
25
|
+
Acceptance (from P0-B round 2, runner v6 issued `fail`): Sylius register→login-failed→guest checkout and OpenCart register→goto→checkout must both return `unmeasurable · premise broken`. With `sameProcess` declared they must return `fail` with provenance `project`.
|
|
26
|
+
|
|
27
|
+
## Shared invalid-if
|
|
28
|
+
|
|
29
|
+
- Page title matches a bot-challenge pattern → `blocked`.
|
|
30
|
+
- For 3.3.4 and 3.3.7, noise is defined on what they read: if the `textContent` of `main` (or `body` when no `main`) or any input value changes over 600 ms with no interaction → `unmeasurable`. Geometry changes are not noise for these probes. 3.2.3 reads the DOM once and has no noise check.
|
|
31
|
+
- Third-party subtrees (`iframe`, `ins.adsbygoogle`, `.google-auto-placed`, `[id^=aswift]`, `[id^=google_ads]`, `.google-anno*`) are excluded from all three probes. This exclusion is carried from the P0-A finding and is part of the frozen definition.
|
|
32
|
+
|
|
33
|
+
## Falsification pair
|
|
34
|
+
|
|
35
|
+
See `pair.json`: one fixture where this probe must return `fail` for the stated operator, one where it must stay silent. `uxcli gate` runs both and checks the hashes. Written from the WCAG 2.2 Understanding text before any fixture or site was opened; revision history is kept outside the package.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Review your order – Fixture Shop</title>
|
|
7
|
+
<link rel="stylesheet" href="style.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<header>
|
|
11
|
+
<div class="wrap">
|
|
12
|
+
<a class="brand" href="home.html">Fixture Shop</a>
|
|
13
|
+
<nav aria-label="Main">
|
|
14
|
+
<ul>
|
|
15
|
+
<li><a href="home.html">Home</a></li>
|
|
16
|
+
<li><a href="shop.html">Shop</a></li>
|
|
17
|
+
<li><a href="cart.html">Cart <span class="badge">2<span class="sr"> items</span></span></a></li>
|
|
18
|
+
<li><a href="account.html">Account</a></li>
|
|
19
|
+
<li><a href="help.html">Help</a></li>
|
|
20
|
+
</ul>
|
|
21
|
+
</nav>
|
|
22
|
+
</div>
|
|
23
|
+
</header>
|
|
24
|
+
<main class="wrap">
|
|
25
|
+
<h1>Review your order</h1>
|
|
26
|
+
<p>Check everything is correct before you place your order.</p>
|
|
27
|
+
|
|
28
|
+
<h2>Items</h2>
|
|
29
|
+
<table>
|
|
30
|
+
<thead>
|
|
31
|
+
<tr><th scope="col">Item</th><th scope="col" class="num">Qty</th><th scope="col" class="num">Price</th></tr>
|
|
32
|
+
</thead>
|
|
33
|
+
<tbody>
|
|
34
|
+
<tr><td>Ceramic mug</td><td class="num">1</td><td class="num">£12.00</td></tr>
|
|
35
|
+
<tr><td>Notebook, A5, dotted</td><td class="num">1</td><td class="num">£8.50</td></tr>
|
|
36
|
+
</tbody>
|
|
37
|
+
</table>
|
|
38
|
+
<p class="total">Total: £20.50</p>
|
|
39
|
+
|
|
40
|
+
<h2>Your details</h2>
|
|
41
|
+
<dl id="summary">
|
|
42
|
+
<div><dt>Full name</dt><dd><span id="out-name"></span><a href="details.html#name">Change<span class="sr"> full name</span></a></dd></div>
|
|
43
|
+
<div><dt>Email address</dt><dd><span id="out-email"></span><a href="details.html#email">Change<span class="sr"> email address</span></a></dd></div>
|
|
44
|
+
<div><dt>Street address</dt><dd><span id="out-address"></span><a href="details.html#address">Change<span class="sr"> street address</span></a></dd></div>
|
|
45
|
+
<div><dt>Postcode</dt><dd><span id="out-postcode"></span><a href="details.html#postcode">Change<span class="sr"> postcode</span></a></dd></div>
|
|
46
|
+
<div><dt>Country</dt><dd><span id="out-country"></span><a href="details.html#country">Change<span class="sr"> country</span></a></dd></div>
|
|
47
|
+
</dl>
|
|
48
|
+
|
|
49
|
+
<form id="order-form" action="done.html" method="get">
|
|
50
|
+
<label for="confirm-email">Confirm your email for the receipt</label>
|
|
51
|
+
<input id="confirm-email" name="confirm-email" type="email" autocomplete="email">
|
|
52
|
+
<button type="submit">Place order</button>
|
|
53
|
+
</form>
|
|
54
|
+
<script>
|
|
55
|
+
(function () {
|
|
56
|
+
var fields = ['name', 'email', 'address', 'postcode', 'country'];
|
|
57
|
+
var saved = {};
|
|
58
|
+
try { saved = JSON.parse(sessionStorage.getItem('checkout') || '{}'); } catch (e) {}
|
|
59
|
+
fields.forEach(function (f) {
|
|
60
|
+
document.getElementById('out-' + f).textContent = saved[f] || '';
|
|
61
|
+
});
|
|
62
|
+
document.getElementById('order-form').addEventListener('submit', function (ev) {
|
|
63
|
+
ev.preventDefault();
|
|
64
|
+
sessionStorage.setItem('orderPlaced', '1');
|
|
65
|
+
location.href = 'done.html';
|
|
66
|
+
});
|
|
67
|
+
})();
|
|
68
|
+
</script>
|
|
69
|
+
</main>
|
|
70
|
+
<footer>
|
|
71
|
+
<div class="wrap">
|
|
72
|
+
<a href="privacy.html">Privacy</a>
|
|
73
|
+
<a href="terms.html">Terms</a>
|
|
74
|
+
<a href="contact.html">Contact</a>
|
|
75
|
+
</div>
|
|
76
|
+
</footer>
|
|
77
|
+
</body>
|
|
78
|
+
</html>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"journey": "test/journeys/checkout.json",
|
|
3
|
+
"operator": "review.html: a \"Confirm your email for the receipt\" field is added inside the order form, empty, not pre-populated, no way to select the value entered on details.html",
|
|
4
|
+
"hashes": {
|
|
5
|
+
"site": {
|
|
6
|
+
"account.html": "3972383a0e127789055e87f517e5812e7d0aadf98a979748b9468cbf5cdcef29",
|
|
7
|
+
"cart.html": "c40a826acc5df143e6c8bdb8b2ad95b6c236a1bf24315ef4a729902209fa3125",
|
|
8
|
+
"contact.html": "fe0b4f737714599aea8ffc61a0e3e66e66eb2ef900f97f29970d90085bdfb457",
|
|
9
|
+
"details.html": "866dedf0b0ff5baea57490907b07d2f726f00c69add81128e7938438e8f4d987",
|
|
10
|
+
"done.html": "714bfde7c1ba4e24d162f1faf9ab781ac0d06f44b149441c4845e740dcc32102",
|
|
11
|
+
"help.html": "e457b0e9a716019065de458d89114c37fbd14e67ada1ec69c8a2f65efbd1acbf",
|
|
12
|
+
"home.html": "287874d84b918ed8d6c16ce73d9ab631a489a5f9263933811e8ea960702126b2",
|
|
13
|
+
"privacy.html": "24d287f9a4e59218d69eb38360f674a92ada08860f2b194f71b7be2b9dbbdeff",
|
|
14
|
+
"review.html": "6a2191f30dc103062e986c7fada8f036f0931bc2247a70ed599c8b7d50b361b4",
|
|
15
|
+
"shop.html": "56a4d04abc3ca851bc29aff43be027c7f6406aeb0eb90d5dccc39c6088a1e679",
|
|
16
|
+
"style.css": "7f85da7845e02428fb480f86b8bcc3e32ae491882f27bcf35c10caef88999586",
|
|
17
|
+
"terms.html": "bd6a5c83a4e786574107a3aa02dc18e5b327624ebc14739fd0ae1785d180c187"
|
|
18
|
+
},
|
|
19
|
+
"mustFail": {
|
|
20
|
+
"review.html": "ff15027d89634b159a4e8a73475c9282cfd668a10301a68174cc7904c6bb505b"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// flow.redundant-entry · WCAG 3.3.7. Spec in spec.md; falsification pair in pair.json.
|
|
2
|
+
import { norm } from '../../util.js';
|
|
3
|
+
import { evalIn, shot, fieldSelector } from '../../browser.js';
|
|
4
|
+
|
|
5
|
+
function match(inputs, recorded, idx) {
|
|
6
|
+
const matched = [];
|
|
7
|
+
for (const inp of inputs) {
|
|
8
|
+
let by = '';
|
|
9
|
+
const prior = recorded.filter(r => r.step < idx).find(r => {
|
|
10
|
+
if (r.autocomplete && inp.autocomplete && r.autocomplete === inp.autocomplete) by = 'autocomplete';
|
|
11
|
+
else if (r.name && inp.name && r.name === inp.name) by = 'name';
|
|
12
|
+
else if (['email', 'tel', 'url'].includes(inp.type) && inp.type === r.type) by = 'type';
|
|
13
|
+
else if (r.label && inp.label && r.label === inp.label) by = 'label';
|
|
14
|
+
else return false;
|
|
15
|
+
if ((r.section || inp.section) && r.section !== inp.section) return false; // billing vs shipping
|
|
16
|
+
if (r.legend && inp.legend && r.legend !== inp.legend) return false;
|
|
17
|
+
return true;
|
|
18
|
+
});
|
|
19
|
+
if (prior) matched.push({ field: inp, prior, by });
|
|
20
|
+
}
|
|
21
|
+
return matched;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default {
|
|
25
|
+
id: 'flow.redundant-entry', sc: '3.3.7',
|
|
26
|
+
async onStep(page, rec, ctx) {
|
|
27
|
+
if (rec.i === 0 || !ctx.recorded.length) return;
|
|
28
|
+
const details = [];
|
|
29
|
+
for (const m of match(rec.inputsOnArrival, ctx.recorded, rec.i)) {
|
|
30
|
+
const f = m.field, p = m.prior; let mechanism = null;
|
|
31
|
+
if (norm(f.value) === norm(p.value) || norm(f.value) === norm(p.display)) mechanism = 'auto-populated';
|
|
32
|
+
else if (p.essential) mechanism = 'essential (project)'; else if (p.security) mechanism = 'security (project)';
|
|
33
|
+
else mechanism = await evalIn(page, `() => { const v = ${JSON.stringify(norm(p.value))}; const opts = [...document.querySelectorAll('select option, datalist option')].some(o => norm(o.textContent).includes(v) || norm(o.value).includes(v)); const boxes = [...document.querySelectorAll('input[type=checkbox], input[type=radio]')].filter(vis).map(b => norm(label(b))); const offers = boxes.some(t => t.includes(v)); const sameAs = boxes.some(t => /same as|use (my|the|this) .*(address|details|information)/.test(t)); return opts ? 'selectable:option' : offers ? 'selectable:choice' : sameAs ? 'selectable:same-as' : null; }`);
|
|
34
|
+
details.push({ field: { name: f.name, id: f.id, type: f.type, label: f.label, autocomplete: f.autocomplete }, matchedBy: m.by, firstEnteredStep: p.step, firstEnteredAs: { name: p.name, label: p.label, selector: p.selector }, currentValue: f.value, mechanism });
|
|
35
|
+
}
|
|
36
|
+
rec.redundant = { noise: rec.noise, matched: details };
|
|
37
|
+
const bare = details.filter(d => !d.mechanism).map(d => fieldSelector(d.field)).filter(Boolean);
|
|
38
|
+
if (bare.length) (rec.evidence ||= {})['3.3.7'] = await shot(page, '[data-uxcli-scope]', bare);
|
|
39
|
+
},
|
|
40
|
+
async evaluate(ctx) {
|
|
41
|
+
const { J, steps, recorded, segOf, breakAfter, blocked } = ctx; const re = {};
|
|
42
|
+
if (blocked) return { verdict: 'blocked' };
|
|
43
|
+
const evald = steps.filter(s => s.redundant);
|
|
44
|
+
if (!recorded.length || !evald.length) return { verdict: 'unmeasurable', why: 'no earlier values recorded / no later step reached' };
|
|
45
|
+
const reasked = [], ok = [], noisy = [], broken = [];
|
|
46
|
+
for (const s of evald) for (const m of s.redundant.matched) {
|
|
47
|
+
const item = { step: s.i, url: s.url, ...m };
|
|
48
|
+
if (segOf[m.firstEnteredStep] !== segOf[s.i]) { item.breakAtStep = breakAfter(m.firstEnteredStep, s.i); broken.push(item); continue; }
|
|
49
|
+
if (s.redundant.noise) noisy.push(item); else if (m.mechanism) ok.push(item); else reasked.push(item);
|
|
50
|
+
}
|
|
51
|
+
if (J.sameProcess) re.override = { sameProcess: J.sameProcess, provenance: 'project' };
|
|
52
|
+
re.premiseBroken = broken; re.matched = ok.length + reasked.length + noisy.length;
|
|
53
|
+
if (noisy.length && !reasked.length) return { ...re, verdict: 'unmeasurable', why: 'matched field on a step whose text/values mutate with no interaction', noisy };
|
|
54
|
+
if (broken.length && !reasked.length) return { ...re, verdict: 'unmeasurable', why: 'premise broken at step ' + broken[0].breakAtStep + ': the earlier value belongs to another process segment; declare sameProcess to override', satisfied: ok };
|
|
55
|
+
if (reasked.length) return { ...re, verdict: 'fail', reasked, satisfied: ok };
|
|
56
|
+
if (ok.length) return { ...re, verdict: 'pass', satisfied: ok };
|
|
57
|
+
return { ...re, verdict: 'not-applicable' };
|
|
58
|
+
}
|
|
59
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# flow.redundant-entry · WCAG 3.3.7 · provenance spec
|
|
2
|
+
|
|
3
|
+
- **why:** SC text: information previously entered or provided in the same process that is required again must be auto-populated or available to select, except when re-entry is essential, required for security, or the earlier information is no longer valid.
|
|
4
|
+
- **applies-when:** step index > 1 in the journey, evaluated **on arrival at the step, before that step's own fill**; a visible, enabled, editable input (`input` not hidden/password/submit/button/search, `textarea`, `select`) inside the process form — the `form` ancestor of the control the journey submits on that step. A step with no `submit`, or whose control is not inside a form, contributes no inputs: other forms on the page (product review, newsletter, search, site chrome) are not part of the process. The input's identity matches a value recorded in an earlier step. Identity match, in order: same non-empty `autocomplete` token; same `name`; same `type` in {email, tel, url}; accessible label equal after normalization.
|
|
5
|
+
- **correct-when:** for each matched field, one of: its current value equals the earlier value (auto-populated); or a control on the same page offers the earlier value for selection (a `select`, `datalist`, radio group, or checkbox whose option/label text contains the earlier value, or a checkbox/radio whose label matches /same as|use (my|the|this) .*(address|details|information)/i); or the journey marks the field `essential` or `security` (provenance `project`).
|
|
6
|
+
- **verdict:** `pass` if no matched field lacks a mechanism; `fail` listing each re-asked field with the step where it was first entered; `not-applicable` if no field matches; `unmeasurable` if earlier steps recorded no values.
|
|
7
|
+
- **invalid-if:** the earlier step's values were not recorded (journey started mid-process); the field is inside a third-party subtree (`iframe`, ad container) — excluded.
|
|
8
|
+
- **known-infidelity:** identity by `name`/label can conflate distinct fields (billing vs shipping address) — a `fieldset` legend or `autocomplete` section token (`shipping`, `billing`) that differs between the two occurrences suppresses the match; "same as" detection is a text heuristic and is reported in `why`.
|
|
9
|
+
|
|
10
|
+
## Input: Journey input (the commitment)
|
|
11
|
+
|
|
12
|
+
A journey is an ordered list of steps in one process. Each step: `url` (or an action that navigates), optional `fill` map, optional `submit` selector. One step may be marked `commit: true`: the action that causes a legal commitment, financial transaction, or modification of user data. The runner records every value the user (or the journey) enters, with the field's identity (`autocomplete`, `name`, `type`, accessible label) and the step index. If no step is marked `commit`, the 3.3.4 probe returns `not-committed`, never `fail`.
|
|
13
|
+
|
|
14
|
+
## Process boundary (new, applies to 3.3.4 and 3.3.7)
|
|
15
|
+
A journey is split into **segments**. Step i continues the segment of step i−1 only if it was reached by the previous step's own action (`arrivedBy: flow`) and the previous step's submit navigated (`flowBreak` absent). A step reached by direct navigation (`goto`), or following a submit that did not change the page, **starts a new segment**. Segments are the tool's measurement of "the same process" (WCAG 3.3.7 note; 3.3.4 "submission").
|
|
16
|
+
|
|
17
|
+
Override, provenance `project`: `sameProcess: [[from, to], …]` in the journey joins the named step ranges into one segment. The human commits that the steps are one process; the runner records the override on the verdict.
|
|
18
|
+
|
|
19
|
+
- **3.3.7:** a field matches only values recorded in the same segment. A match whose only prior lies in another segment is reported as `premise-broken` (step of the break named) and never counts as `fail`. Verdict: `fail` if any same-segment re-ask lacks a mechanism; else `unmeasurable · premise broken at step N` if any cross-segment match exists; else as v8.
|
|
20
|
+
- **3.3.4:** values compared on the commit screen are those recorded in the commit step's segment. If none are in the segment but some exist outside it → `unmeasurable · premise broken at step N`.
|
|
21
|
+
|
|
22
|
+
Acceptance (from P0-B round 2, runner v6 issued `fail`): Sylius register→login-failed→guest checkout and OpenCart register→goto→checkout must both return `unmeasurable · premise broken`. With `sameProcess` declared they must return `fail` with provenance `project`.
|
|
23
|
+
|
|
24
|
+
## 3.3.7 process scope when the submit control has no form (revises v5 rule 10)
|
|
25
|
+
Scope = the `form` of the step's submit control. When the control is not inside a `form`, scope = the nearest ancestor of the control among `form, fieldset, section, article, [role=region], [role=main], main`, falling back to `main` then `body`; site chrome (`header, nav, footer, [role=search]`) stays excluded.
|
|
26
|
+
|
|
27
|
+
Acceptance: Toolshop register→checkout billing step must report the house-number re-ask as `fail` (verified by hand 2026-09-06); the P0-B automationexercise product-review email must stay excluded (its form is not the process form).
|
|
28
|
+
|
|
29
|
+
## Shared invalid-if
|
|
30
|
+
|
|
31
|
+
- Page title matches a bot-challenge pattern → `blocked`.
|
|
32
|
+
- For 3.3.4 and 3.3.7, noise is defined on what they read: if the `textContent` of `main` (or `body` when no `main`) or any input value changes over 600 ms with no interaction → `unmeasurable`. Geometry changes are not noise for these probes. 3.2.3 reads the DOM once and has no noise check.
|
|
33
|
+
- Third-party subtrees (`iframe`, `ins.adsbygoogle`, `.google-auto-placed`, `[id^=aswift]`, `[id^=google_ads]`, `.google-anno*`) are excluded from all three probes. This exclusion is carried from the P0-A finding and is part of the frozen definition.
|
|
34
|
+
|
|
35
|
+
## Falsification pair
|
|
36
|
+
|
|
37
|
+
See `pair.json`: one fixture where this probe must return `fail` for the stated operator, one where it must stay silent. `uxcli gate` runs both and checks the hashes. Written from the WCAG 2.2 Understanding text before any fixture or site was opened; revision history is kept outside the package.
|
package/src/refute.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Second reader. A fresh model that never saw the probe code gets the check question and the proof images, and answers yes/no.
|
|
2
|
+
// Refuter command is pluggable: UXCLI_REFUTER="claude -p --allowedTools Read --model ..." (default: claude -p --model haiku, tools limited to Read; ~US$0.04 per fail).
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
export function refuteQuestion(p) {
|
|
6
|
+
if (p.sc === '3.3.7') { const fields = [...new Set(p.reasked.map(m => m.field.name || m.field.id))]; return `The tool claims: on the screen shown, the field(s) ${fields.join(', ')} (outlined in red) are empty although the user typed the value(s) earlier in the same process. Looking only at the image(s), is that claim supported?`; }
|
|
7
|
+
if (p.sc === '3.3.4') { const c = p.branches.confirmed; return `The tool claims: on the screen shown (the one that places the order), the previously entered values ${c.missing.slice(0, 3).map(m => JSON.stringify(m.value)).join(', ')} are not shown and there is no control to change them before committing. Looking only at the image, is that claim supported?`; }
|
|
8
|
+
if (p.sc === '3.2.3') { const x = p.inversion; return `The tool claims: the two navigation menus shown (from two pages of the same site) list the same items but in a different relative order; specifically ${x.firstInvertedPair.map(s => s.replace(/^[ht]:/, '').split('/').pop()).join(' and ')} are swapped. Looking only at the images, is that claim supported?`; }
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function refute(p, { cmd = process.env.UXCLI_REFUTER || 'claude -p --model haiku --allowedTools Read --output-format json' } = {}) {
|
|
13
|
+
const q = refuteQuestion(p); if (!q || !p.proof?.length) return { tested: false, why: 'no question or no proof image' };
|
|
14
|
+
const prompt = `You are an independent second reader checking one claim made by an automated UI checker. You have not seen the checker. Do not trust the claim.\nOpen each image with the Read tool: ${p.proof.join(' , ')}\nQuestion: ${q}\nReply with JSON only, on one line: {"supported": true|false, "reason": "<one sentence, what you saw>"}`;
|
|
15
|
+
const [bin, ...args] = cmd.split(/\s+/);
|
|
16
|
+
const r = spawnSync(bin, [...args, prompt], { encoding: 'utf8', timeout: 180000, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
17
|
+
if (r.status !== 0) return { tested: false, why: 'refuter exit ' + r.status + ': ' + (r.stderr || '').slice(0, 200) };
|
|
18
|
+
let text = r.stdout;
|
|
19
|
+
try { const j = JSON.parse(text); text = j.result ?? j.content ?? text; } catch {}
|
|
20
|
+
const m = String(text).match(/\{[^{}]*"supported"[^{}]*\}/);
|
|
21
|
+
if (!m) return { tested: true, parsed: false, raw: String(text).slice(0, 300) };
|
|
22
|
+
try { const a = JSON.parse(m[0]); return { tested: true, parsed: true, supported: !!a.supported, reason: String(a.reason || '').slice(0, 300), agrees: !!a.supported }; } catch { return { tested: true, parsed: false, raw: m[0].slice(0, 300) }; }
|
|
23
|
+
}
|
package/src/run.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Walks a journey once, gives every probe the same observations, returns the verdicts.
|
|
2
|
+
import { launch, arrive, noise, markScope, fillStep, act, evalIn } from './browser.js';
|
|
3
|
+
import { BOT } from './util.js';
|
|
4
|
+
import fs from 'node:fs'; import path from 'node:path';
|
|
5
|
+
import errorPrevention from './probes/error-prevention/probe.js';
|
|
6
|
+
import redundantEntry from './probes/redundant-entry/probe.js';
|
|
7
|
+
import consistentNavigation from './probes/consistent-navigation/probe.js';
|
|
8
|
+
|
|
9
|
+
export const PROBES = [errorPrevention, redundantEntry, consistentNavigation];
|
|
10
|
+
|
|
11
|
+
export async function runJourney(J, { browser, outDir } = {}) {
|
|
12
|
+
const own = !browser; if (own) browser = await launch();
|
|
13
|
+
const ctx = { J, steps: [], recorded: [], authSteps: new Set(), commitIdx: J.steps.findIndex(s => s.commit), blocked: false, browser };
|
|
14
|
+
const bctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
|
15
|
+
const page = await bctx.newPage();
|
|
16
|
+
for (let i = 0; i < J.steps.length; i++) {
|
|
17
|
+
const step = J.steps[i]; const rec = { i, url: null, arrivedBy: null, title: null, noise: null, inputsOnArrival: [] };
|
|
18
|
+
try { rec.arrivedBy = await arrive(page, step); } catch (e) { rec.error = 'load: ' + String(e).slice(0, 160); ctx.steps.push(rec); break; }
|
|
19
|
+
rec.url = page.url(); rec.title = await page.title();
|
|
20
|
+
if (BOT.test(rec.title)) { rec.blocked = true; ctx.blocked = true; ctx.steps.push(rec); break; }
|
|
21
|
+
rec.noise = await noise(page);
|
|
22
|
+
rec.scope = await markScope(page, step);
|
|
23
|
+
rec.inputsOnArrival = await evalIn(page, '() => processInputs()');
|
|
24
|
+
for (const p of PROBES) if (p.onStep) await p.onStep(page, rec, ctx);
|
|
25
|
+
try { if (await fillStep(page, step, i, ctx.recorded)) ctx.authSteps.add(i); rec.flowBreak = await act(page, step); } catch (e) { rec.error = 'act: ' + String(e).slice(0, 160); ctx.steps.push(rec); break; }
|
|
26
|
+
ctx.steps.push(rec);
|
|
27
|
+
}
|
|
28
|
+
await bctx.close();
|
|
29
|
+
segment(ctx);
|
|
30
|
+
const probes = [];
|
|
31
|
+
for (const p of PROBES) probes.push({ probe: p.id, sc: p.sc, provenance: 'spec', ...(await p.evaluate(ctx)) });
|
|
32
|
+
if (own) await browser.close();
|
|
33
|
+
if (outDir) writeEvidence(ctx, probes, outDir);
|
|
34
|
+
for (const s of ctx.steps) delete s.evidence;
|
|
35
|
+
return { journey: J.name, ranAt: new Date().toISOString(), steps: ctx.steps, recorded: ctx.recorded, probes };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Process segments: a goto or a submit that did not navigate starts a new segment; sameProcess joins ranges (provenance project).
|
|
39
|
+
function segment(ctx) {
|
|
40
|
+
const { steps, J } = ctx; const segOf = []; let seg = 0;
|
|
41
|
+
for (let i = 0; i < steps.length; i++) { if (i > 0 && (steps[i].arrivedBy === 'goto' || steps[i - 1].flowBreak)) seg++; segOf[i] = seg; }
|
|
42
|
+
for (const [from, to] of J.sameProcess || []) { const target = segOf[from]; for (let i = from; i <= to && i < segOf.length; i++) { const s0 = segOf[i]; for (let k = 0; k < segOf.length; k++) if (segOf[k] === s0) segOf[k] = target; } }
|
|
43
|
+
steps.forEach((s, i) => { s.segment = segOf[i]; });
|
|
44
|
+
ctx.segOf = segOf;
|
|
45
|
+
ctx.breakAfter = (a, b) => { for (let i = Math.min(a, b) + 1; i <= Math.max(a, b); i++) if (segOf[i] !== segOf[i - 1]) return i; return null; };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Evidence files are written only for fails: the cited screen, cited fields outlined.
|
|
49
|
+
function writeEvidence(ctx, probes, outDir) {
|
|
50
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
51
|
+
const save = (name, buf) => { if (!buf) return null; const f = path.join(outDir, name); fs.writeFileSync(f, buf); return f; };
|
|
52
|
+
for (const p of probes) {
|
|
53
|
+
if (p.verdict !== 'fail') continue;
|
|
54
|
+
if (p.sc === '3.3.7') p.proof = [...new Set(p.reasked.map(m => m.step))].map(i => save(`3.3.7-step${i}.png`, ctx.steps[i]?.evidence?.['3.3.7'])).filter(Boolean);
|
|
55
|
+
if (p.sc === '3.3.4') p.proof = [save(`3.3.4-step${ctx.commitIdx}.png`, ctx.steps[ctx.commitIdx]?.evidence?.['3.3.4'])].filter(Boolean);
|
|
56
|
+
if (p.sc === '3.2.3') { const x = p.inversion; p.proof = [x.stepA, x.stepB].map(i => save(`3.2.3-step${i}.png`, ctx.steps[i]?.evidence?.['3.2.3']?.[x.mechanism])).filter(Boolean); }
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/util.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export const THIRD = 'iframe, ins.adsbygoogle, .google-auto-placed, [id^="aswift"], [id^="google_ads"], [class^="google-anno"], [class*=" google-anno"]';
|
|
2
|
+
export const CHROME = 'header, nav, footer, [role="search"], form[role="search"], [role="navigation"], [role="banner"], [role="contentinfo"]';
|
|
3
|
+
export const BOT = /just a moment|attention required|access denied|verify you are human|application error/i;
|
|
4
|
+
export const norm = s => String(s ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
|
|
5
|
+
export const alnum = s => norm(s).replace(/[^a-z0-9]/g, '');
|
|
6
|
+
export const normUrl = u => { try { const x = new URL(u); return x.origin + x.pathname.replace(/\/$/, '') + x.search + (x.hash.startsWith('#/') ? x.hash : ''); } catch { return u; } };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Your account – Fixture Shop</title>
|
|
7
|
+
<link rel="stylesheet" href="style.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<header>
|
|
11
|
+
<div class="wrap">
|
|
12
|
+
<a class="brand" href="home.html">Fixture Shop</a>
|
|
13
|
+
<nav aria-label="Main">
|
|
14
|
+
<ul>
|
|
15
|
+
<li><a href="home.html">Home</a></li>
|
|
16
|
+
<li><a href="shop.html">Shop</a></li>
|
|
17
|
+
<li><a href="cart.html">Cart <span class="badge">2<span class="sr"> items</span></span></a></li>
|
|
18
|
+
<li><a href="account.html">Account</a></li>
|
|
19
|
+
<li><a href="help.html">Help</a></li>
|
|
20
|
+
</ul>
|
|
21
|
+
</nav>
|
|
22
|
+
</div>
|
|
23
|
+
</header>
|
|
24
|
+
<main class="wrap">
|
|
25
|
+
<h1>Your account</h1>
|
|
26
|
+
<p>Account page placeholder.</p>
|
|
27
|
+
</main>
|
|
28
|
+
<footer>
|
|
29
|
+
<div class="wrap">
|
|
30
|
+
<a href="privacy.html">Privacy</a>
|
|
31
|
+
<a href="terms.html">Terms</a>
|
|
32
|
+
<a href="contact.html">Contact</a>
|
|
33
|
+
</div>
|
|
34
|
+
</footer>
|
|
35
|
+
</body>
|
|
36
|
+
</html>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Your cart – Fixture Shop</title>
|
|
7
|
+
<link rel="stylesheet" href="style.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<header>
|
|
11
|
+
<div class="wrap">
|
|
12
|
+
<a class="brand" href="home.html">Fixture Shop</a>
|
|
13
|
+
<nav aria-label="Main">
|
|
14
|
+
<ul>
|
|
15
|
+
<li><a href="home.html">Home</a></li>
|
|
16
|
+
<li><a href="shop.html">Shop</a></li>
|
|
17
|
+
<li><a href="cart.html">Cart <span class="badge">2<span class="sr"> items</span></span></a></li>
|
|
18
|
+
<li><a href="account.html">Account</a></li>
|
|
19
|
+
<li><a href="help.html">Help</a></li>
|
|
20
|
+
</ul>
|
|
21
|
+
</nav>
|
|
22
|
+
</div>
|
|
23
|
+
</header>
|
|
24
|
+
<main class="wrap">
|
|
25
|
+
<h1>Your cart</h1>
|
|
26
|
+
<table>
|
|
27
|
+
<thead>
|
|
28
|
+
<tr><th scope="col">Item</th><th scope="col" class="num">Qty</th><th scope="col" class="num">Price</th></tr>
|
|
29
|
+
</thead>
|
|
30
|
+
<tbody>
|
|
31
|
+
<tr><td>Ceramic mug</td><td class="num">1</td><td class="num">£12.00</td></tr>
|
|
32
|
+
<tr><td>Notebook, A5, dotted</td><td class="num">1</td><td class="num">£8.50</td></tr>
|
|
33
|
+
</tbody>
|
|
34
|
+
</table>
|
|
35
|
+
<p class="total">Total: £20.50</p>
|
|
36
|
+
<a class="btn" href="details.html">Proceed to details</a>
|
|
37
|
+
</main>
|
|
38
|
+
<footer>
|
|
39
|
+
<div class="wrap">
|
|
40
|
+
<a href="privacy.html">Privacy</a>
|
|
41
|
+
<a href="terms.html">Terms</a>
|
|
42
|
+
<a href="contact.html">Contact</a>
|
|
43
|
+
</div>
|
|
44
|
+
</footer>
|
|
45
|
+
</body>
|
|
46
|
+
</html>
|