@7365admin1/layer-common 3.2.2-staging.109 → 3.2.2-staging.110
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/package.json +1 -1
- package/tools/render-harness/README.md +87 -0
- package/tools/render-harness/baselines.json +22 -0
- package/tools/render-harness/harness-init.ps1 +123 -0
- package/tools/render-harness/probe.mjs +229 -0
- package/tools/render-harness/render-check.mjs +306 -0
- package/tools/render-harness/render-check.selftest.mjs +129 -0
package/package.json
CHANGED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Render harness hardening
|
|
2
|
+
|
|
3
|
+
Tooling, not product code. Nothing here ships in `@7365admin1/layer-common` and
|
|
4
|
+
nothing here is imported by any component — it exists to stop the theme-review
|
|
5
|
+
harness reporting a pass it has not earned.
|
|
6
|
+
|
|
7
|
+
The harness itself (vite + Vuetify + a mirror of this package, driven by
|
|
8
|
+
Playwright) lives outside the repo, under
|
|
9
|
+
`_scratch-theme-review/harness`. These four files attach to it.
|
|
10
|
+
|
|
11
|
+
| File | What it does |
|
|
12
|
+
|---|---|
|
|
13
|
+
| `harness-init.ps1` | Makes a private harness for one worktree: fork directory and dev port both derived from the worktree path, own vite cache, mirror stamped with what was synced. |
|
|
14
|
+
| `render-check.mjs` | The run. Refuses to measure an unprovable mirror, then measures each screen at 360/768/1024/1440 × light/dark. |
|
|
15
|
+
| `probe.mjs` | The in-page half — what is actually read off the glass. |
|
|
16
|
+
| `render-check.selftest.mjs` | Proves every detector fails the scenario it was written for. `node render-check.selftest.mjs`, no server needed. |
|
|
17
|
+
| `baselines.json` | Per-screen recorded element and painted-text counts. |
|
|
18
|
+
|
|
19
|
+
## Use
|
|
20
|
+
|
|
21
|
+
```powershell
|
|
22
|
+
cd <worktree>
|
|
23
|
+
..\iservice365-layer-common\tools\render-harness\harness-init.ps1 # prints fork + port
|
|
24
|
+
Start-Process -WorkingDirectory <fork> cmd.exe -ArgumentList '/c','npx vite --port <port> --strictPort'
|
|
25
|
+
cd <fork>; $env:PORT=<port>
|
|
26
|
+
node render-check.mjs <label> CameraMain NotificationSettings
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Re-run `harness-init.ps1` after any edit to `assets`, `components`,
|
|
30
|
+
`composables`, `constants`, `types` or `utils` — `render-check.mjs` compares the
|
|
31
|
+
mirror to the worktree file by file and refuses to run when they disagree.
|
|
32
|
+
|
|
33
|
+
## What each guard is for
|
|
34
|
+
|
|
35
|
+
Every one of these was a real false pass on 2026-08-14, not a hypothetical.
|
|
36
|
+
|
|
37
|
+
- **Shared mirror.** One `lc/` copy meant two agents silently measured each
|
|
38
|
+
other's branch. Isolation is now derived from the worktree, so there is
|
|
39
|
+
nothing to remember, and the mirror carries a stamp the run checks.
|
|
40
|
+
- **Empty state.** A panel scored 8/8 dark-clean while drawing "No access logs
|
|
41
|
+
found". A table was signed off with zero header columns and "No data
|
|
42
|
+
available", so the header and cell rules under review had never been drawn. A
|
|
43
|
+
screen showing its empty state is a fixture bug and fails.
|
|
44
|
+
- **Empty form.** The same bug in a shape nothing else can see — 1112 elements,
|
|
45
|
+
nothing blank, and not one control holding a value.
|
|
46
|
+
- **Blank screen.** Six elements, zero page errors, and a counter read it as a
|
|
47
|
+
pass. Painted text is the honest signal.
|
|
48
|
+
- **Element counts.** `MIN_ELS = 20` was at once too low (a cold partial paint
|
|
49
|
+
at 32 scored ok where warm is 49) and too high (a drawing screen at 19 was
|
|
50
|
+
reported NOT-MOUNTED). Screens legitimately range from 3 to 1100 elements, so
|
|
51
|
+
each carries its own baseline and an un-baselined screen is refused rather
|
|
52
|
+
than guessed at.
|
|
53
|
+
- **Cold compile.** The run waits for the DOM to stop growing instead of a fixed
|
|
54
|
+
timeout, warms every screen first, prints the discarded warm-up numbers so the
|
|
55
|
+
gap is visible, and re-measures any row well under that screen's own best.
|
|
56
|
+
- **Undefined custom properties.** `var(--success)` where the token is `--ok`:
|
|
57
|
+
the declaration is invalid, the element inherits, and nothing reports it. Now
|
|
58
|
+
caught even when the rule matches nothing on the screen — which is how the
|
|
59
|
+
original survived a full 8-shot render.
|
|
60
|
+
- **Disabled controls.** WCAG-1.4.3-exempt, so logging them as failures put
|
|
61
|
+
fictional entries in an AA ledger. Classified as exempt, and the enabled state
|
|
62
|
+
is probed separately so the exemption cannot hide a real failure.
|
|
63
|
+
- **Vuetify defaults.** `VAutocomplete` was missing from the harness, and 19
|
|
64
|
+
components in this package use one — the run would have invented a defect the
|
|
65
|
+
product does not have. The defaults are diffed against `plugins/vuetify.ts`
|
|
66
|
+
every run.
|
|
67
|
+
|
|
68
|
+
## Baselines
|
|
69
|
+
|
|
70
|
+
`--record` writes the current run's element and painted-text counts:
|
|
71
|
+
|
|
72
|
+
```powershell
|
|
73
|
+
node render-check.mjs --record base CameraMain
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Only record from a run whose **screenshots you have opened**. A baseline
|
|
77
|
+
recorded from a broken render makes the break permanent. A conversion that
|
|
78
|
+
genuinely shrinks a screen below 70% of its baseline will flag — look at the
|
|
79
|
+
shot, then re-record.
|
|
80
|
+
|
|
81
|
+
`baselines.json` accepts `"allowEmpty": true` per screen for the rare component
|
|
82
|
+
whose empty state IS the thing under review.
|
|
83
|
+
|
|
84
|
+
## Rule that does not bend
|
|
85
|
+
|
|
86
|
+
Fixtures are harness-only. Package code is never bent to suit the harness. If a
|
|
87
|
+
screen will not draw, the fixture is wrong.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"CameraMain": {
|
|
3
|
+
"els": 55,
|
|
4
|
+
"textChars": 85,
|
|
5
|
+
"recordedFrom": "0642279"
|
|
6
|
+
},
|
|
7
|
+
"NotificationSettings": {
|
|
8
|
+
"els": 150,
|
|
9
|
+
"textChars": 485,
|
|
10
|
+
"recordedFrom": "0642279"
|
|
11
|
+
},
|
|
12
|
+
"OvernightParkingAvailability": {
|
|
13
|
+
"els": 1132,
|
|
14
|
+
"textChars": 385,
|
|
15
|
+
"recordedFrom": "0642279"
|
|
16
|
+
},
|
|
17
|
+
"BulletinExpirationChip": {
|
|
18
|
+
"els": 3,
|
|
19
|
+
"textChars": 2,
|
|
20
|
+
"recordedFrom": "0642279"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# Per-worktree render-harness isolation.
|
|
2
|
+
#
|
|
3
|
+
# WHY THIS EXISTS. The theme-review harness renders ONE `harness/lc/` mirror,
|
|
4
|
+
# synced from a layer-common checkout by `sync-lc.ps1`. When two agents work at
|
|
5
|
+
# once, the second `sync-lc.ps1` overwrites the first agent's mirror, and both
|
|
6
|
+
# then measure whichever checkout synced last. That is silent - the run still
|
|
7
|
+
# prints element counts, contrast readings and screenshots, all of the WRONG
|
|
8
|
+
# branch. On 2026-08-14 it happened repeatedly, and every batch worked around it
|
|
9
|
+
# by hand-forking the harness and hand-picking a port. Hand-picking is what
|
|
10
|
+
# fails: forget it once and the whole run is fiction.
|
|
11
|
+
#
|
|
12
|
+
# So: isolation is DERIVED, never chosen. The fork directory and the dev port
|
|
13
|
+
# both come from the worktree path, so the same worktree always gets the same
|
|
14
|
+
# private harness and the same port, and two different worktrees cannot collide.
|
|
15
|
+
#
|
|
16
|
+
# cd <worktree>; ..\iservice365-layer-common\tools\render-harness\harness-init.ps1
|
|
17
|
+
#
|
|
18
|
+
# Prints the fork path and port. Re-running is safe: it re-syncs `lc/` and
|
|
19
|
+
# leaves the port alone.
|
|
20
|
+
[CmdletBinding()]
|
|
21
|
+
param(
|
|
22
|
+
# The layer-common worktree to render. Defaults to the current git worktree.
|
|
23
|
+
[string]$Worktree = (git rev-parse --show-toplevel),
|
|
24
|
+
# The canonical harness this fork is copied from.
|
|
25
|
+
[string]$Canonical = "C:\Seven365Projects\iService365\_scratch-theme-review\harness"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
$ErrorActionPreference = "Stop"
|
|
29
|
+
|
|
30
|
+
if (-not (Test-Path "$Worktree\.git")) { throw "not a git worktree: $Worktree" }
|
|
31
|
+
if (-not (Test-Path "$Canonical\src\main.js")) { throw "canonical harness not found: $Canonical" }
|
|
32
|
+
|
|
33
|
+
$slug = Split-Path $Worktree -Leaf
|
|
34
|
+
$fork = Join-Path (Split-Path $Canonical -Parent) "harness-$slug"
|
|
35
|
+
|
|
36
|
+
# --- port, derived ----------------------------------------------------------
|
|
37
|
+
# FNV-1a over the worktree path: same worktree -> same port, every session, on
|
|
38
|
+
# every machine. Then step upward past anything already listening, so a hash
|
|
39
|
+
# collision or a stale server degrades to "next free port" instead of two
|
|
40
|
+
# agents sharing one vite and measuring each other.
|
|
41
|
+
function Get-DerivedPort([string]$s) {
|
|
42
|
+
$h = [uint32]2166136261
|
|
43
|
+
foreach ($c in $s.ToLower().ToCharArray()) {
|
|
44
|
+
# uint64 multiply, then modulo back into 32 bits. PowerShell widens past
|
|
45
|
+
# uint32 and refuses the cast rather than wrapping, and `-band 0xFFFFFFFF`
|
|
46
|
+
# is not the mask it looks like - 5.1 parses that literal as int -1.
|
|
47
|
+
$h = [uint32]((([uint64]($h -bxor [uint32][int]$c)) * 16777619) % 4294967296)
|
|
48
|
+
}
|
|
49
|
+
$p = 5400 + [int]($h % 200)
|
|
50
|
+
$busy = (Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue).LocalPort
|
|
51
|
+
while ($busy -contains $p) { $p++ }
|
|
52
|
+
return $p
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
$portFile = Join-Path $fork ".harness-port"
|
|
56
|
+
if (Test-Path $portFile) {
|
|
57
|
+
$port = [int](Get-Content $portFile -Raw).Trim()
|
|
58
|
+
} else {
|
|
59
|
+
$port = Get-DerivedPort $Worktree
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# --- fork -------------------------------------------------------------------
|
|
63
|
+
# Keyed on a file the copy produces, not on the directory: a run that died
|
|
64
|
+
# half-way would otherwise leave a directory that looks finished forever.
|
|
65
|
+
if (-not (Test-Path "$fork\vite.config.mjs")) {
|
|
66
|
+
New-Item -ItemType Directory -Path $fork -Force | Out-Null
|
|
67
|
+
# An ALLOWLIST, not "everything minus the big things": the canonical harness
|
|
68
|
+
# has accumulated hundreds of one-off runners, .json measure dumps and logs,
|
|
69
|
+
# and it also contains a stray `nul` that Copy-Item cannot read at all.
|
|
70
|
+
# `node_modules` becomes a junction (a full copy is ~400MB per agent) and
|
|
71
|
+
# `lc` is re-synced below.
|
|
72
|
+
foreach ($n in "src", "apps", "public", "index.html", "package.json", "yarn.lock", "vite.config.mjs", "sync-lc.ps1") {
|
|
73
|
+
Copy-Item (Join-Path $Canonical $n) -Destination $fork -Recurse -Force
|
|
74
|
+
}
|
|
75
|
+
cmd /c mklink /J "$fork\node_modules" "$Canonical\node_modules" | Out-Null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
# The hardened runner lives in the repo, next to this script, so a fork always
|
|
79
|
+
# gets the CURRENT one rather than whatever the canonical harness was carrying.
|
|
80
|
+
Copy-Item (Join-Path $PSScriptRoot "render-check.mjs") -Destination $fork -Force
|
|
81
|
+
Copy-Item (Join-Path $PSScriptRoot "baselines.json") -Destination $fork -Force -ErrorAction SilentlyContinue
|
|
82
|
+
|
|
83
|
+
# A junctioned `node_modules` SHARES `node_modules/.vite` with the harness it
|
|
84
|
+
# points at. Two servers writing one dep-cache produced 504s that photograph as
|
|
85
|
+
# a blank screen - i.e. a fake "conversion broke the page". Own cache, always.
|
|
86
|
+
$vite = Join-Path $fork "vite.config.mjs"
|
|
87
|
+
$cfg = [System.IO.File]::ReadAllText($vite)
|
|
88
|
+
if ($cfg -notmatch "cacheDir") {
|
|
89
|
+
$cfg = $cfg -replace "(?m)^\s*server:\s*\{", " cacheDir: `"./.vite-cache`",`r`n server: {"
|
|
90
|
+
# UTF8Encoding($false): Set-Content -Encoding utf8 writes a BOM on Windows
|
|
91
|
+
# PowerShell 5.1, and a BOM in a config file makes PostCSS's loader throw,
|
|
92
|
+
# which 500s EVERY .vue file - indistinguishable from a broken checkout.
|
|
93
|
+
[System.IO.File]::WriteAllText($vite, $cfg, (New-Object System.Text.UTF8Encoding($false)))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
$pkgPath = Join-Path $fork "package.json"
|
|
97
|
+
$pkg = [System.IO.File]::ReadAllText($pkgPath)
|
|
98
|
+
$pkg = $pkg -replace '--port \d+', "--port $port"
|
|
99
|
+
[System.IO.File]::WriteAllText($pkgPath, $pkg, (New-Object System.Text.UTF8Encoding($false)))
|
|
100
|
+
[System.IO.File]::WriteAllText($portFile, "$port", (New-Object System.Text.UTF8Encoding($false)))
|
|
101
|
+
|
|
102
|
+
# --- mirror -----------------------------------------------------------------
|
|
103
|
+
& "$fork\sync-lc.ps1" -Src $Worktree
|
|
104
|
+
|
|
105
|
+
# Stamp WHAT was mirrored, so a measurement can prove which checkout it rendered
|
|
106
|
+
# rather than assume it. `render-check.mjs` refuses to run when this disagrees
|
|
107
|
+
# with the worktree it was pointed at - which is what a foreign `sync-lc.ps1`
|
|
108
|
+
# stamping over the mirror looks like. Written after the sync, because
|
|
109
|
+
# `sync-lc.ps1` deletes everything in `lc/` that is not one of its six dirs.
|
|
110
|
+
$stamp = @{
|
|
111
|
+
worktree = $Worktree
|
|
112
|
+
head = (git -C $Worktree rev-parse HEAD)
|
|
113
|
+
branch = (git -C $Worktree rev-parse --abbrev-ref HEAD)
|
|
114
|
+
port = $port
|
|
115
|
+
} | ConvertTo-Json -Compress
|
|
116
|
+
[System.IO.File]::WriteAllText((Join-Path $fork "lc\.synced-from.json"), $stamp, (New-Object System.Text.UTF8Encoding($false)))
|
|
117
|
+
|
|
118
|
+
Write-Output "harness : $fork"
|
|
119
|
+
Write-Output "port : $port"
|
|
120
|
+
Write-Output "renders : $Worktree @ $(git -C $Worktree rev-parse --short HEAD)"
|
|
121
|
+
Write-Output ""
|
|
122
|
+
Write-Output " Start-Process -WorkingDirectory '$fork' npx -ArgumentList 'vite','--port','$port','--strictPort'"
|
|
123
|
+
Write-Output " cd '$fork'; `$env:PORT=$port; node render-check.mjs <label> <Screen> [Screen...]"
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// The in-page probe, in its own file so render-check.selftest.mjs can assert on
|
|
2
|
+
// it against a hand-built page instead of hoping some product screen happens to
|
|
3
|
+
// contain a disabled control, an undefined token and an empty state today.
|
|
4
|
+
//
|
|
5
|
+
export default function probe(opts) {
|
|
6
|
+
const root = document.querySelector(".app-content") || document.body;
|
|
7
|
+
|
|
8
|
+
const parseRGB = (s) => {
|
|
9
|
+
const m = String(s).match(/[\d.]+/g);
|
|
10
|
+
return m ? m.slice(0, 4).map(Number) : null;
|
|
11
|
+
};
|
|
12
|
+
const over = (fg, bg) => {
|
|
13
|
+
const a = fg.length > 3 ? fg[3] : 1;
|
|
14
|
+
return [0, 1, 2].map((i) => fg[i] * a + bg[i] * (1 - a));
|
|
15
|
+
};
|
|
16
|
+
const lum = (c) => {
|
|
17
|
+
const f = c.map((v) => {
|
|
18
|
+
v /= 255;
|
|
19
|
+
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
|
20
|
+
});
|
|
21
|
+
return 0.2126 * f[0] + 0.7152 * f[1] + 0.0722 * f[2];
|
|
22
|
+
};
|
|
23
|
+
const ratio = (a, b) => {
|
|
24
|
+
const [l1, l2] = [lum(a), lum(b)].sort((x, y) => y - x);
|
|
25
|
+
return (l1 + 0.05) / (l2 + 0.05);
|
|
26
|
+
};
|
|
27
|
+
const backdrop = (el) => {
|
|
28
|
+
const stack = [];
|
|
29
|
+
for (let n = el; n; n = n.parentElement) {
|
|
30
|
+
const bg = parseRGB(getComputedStyle(n).backgroundColor);
|
|
31
|
+
if (!bg) continue;
|
|
32
|
+
const a = bg.length > 3 ? bg[3] : 1;
|
|
33
|
+
if (a === 0) continue;
|
|
34
|
+
stack.push(bg);
|
|
35
|
+
if (a === 1) break;
|
|
36
|
+
}
|
|
37
|
+
let base = [255, 255, 255];
|
|
38
|
+
for (let i = stack.length - 1; i >= 0; i--) base = over(stack[i], base);
|
|
39
|
+
return base;
|
|
40
|
+
};
|
|
41
|
+
const visible = (el) => {
|
|
42
|
+
const r = el.getBoundingClientRect();
|
|
43
|
+
if (r.width < 1 || r.height < 1) return false;
|
|
44
|
+
const s = getComputedStyle(el);
|
|
45
|
+
return s.visibility !== "hidden" && s.display !== "none" && Number(s.opacity) > 0.05;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// A disabled control is exempt from WCAG 1.4.3, so its reading is not a
|
|
49
|
+
// defect. It is still recorded - separately - because "exempt" must not be a
|
|
50
|
+
// way for a real failure to disappear.
|
|
51
|
+
const DISABLED_SEL =
|
|
52
|
+
'[disabled],[aria-disabled="true"],.v-btn--disabled,.v-input--disabled,.v-field--disabled,fieldset[disabled]';
|
|
53
|
+
const isDisabled = (el) => !!el.closest(DISABLED_SEL);
|
|
54
|
+
|
|
55
|
+
const ownText = (el) =>
|
|
56
|
+
[...el.childNodes]
|
|
57
|
+
.filter((n) => n.nodeType === 3 && n.textContent.trim())
|
|
58
|
+
.map((n) => n.textContent.trim())
|
|
59
|
+
.join(" ");
|
|
60
|
+
|
|
61
|
+
const contrast = (only) => {
|
|
62
|
+
const out = [];
|
|
63
|
+
const seen = new Set();
|
|
64
|
+
for (const el of root.querySelectorAll("*")) {
|
|
65
|
+
if (only && !only.has(el)) continue;
|
|
66
|
+
if (!visible(el)) continue;
|
|
67
|
+
const own = ownText(el);
|
|
68
|
+
if (!own) continue;
|
|
69
|
+
const s = getComputedStyle(el);
|
|
70
|
+
const fg = parseRGB(s.color);
|
|
71
|
+
if (!fg) continue;
|
|
72
|
+
const bg = backdrop(el);
|
|
73
|
+
const px = parseFloat(s.fontSize);
|
|
74
|
+
const w = parseInt(s.fontWeight, 10) || 400;
|
|
75
|
+
const large = px >= 18.66 || (px >= 14 && w >= 700);
|
|
76
|
+
const need = large ? 3 : 4.5;
|
|
77
|
+
const r = ratio(over(fg, bg), bg);
|
|
78
|
+
if (r + 0.005 < need) {
|
|
79
|
+
const key = `${own.slice(0, 30)}|${s.color}|${px}`;
|
|
80
|
+
if (seen.has(key)) continue;
|
|
81
|
+
seen.add(key);
|
|
82
|
+
out.push({
|
|
83
|
+
text: own.slice(0, 40),
|
|
84
|
+
color: s.color,
|
|
85
|
+
bg: `rgb(${bg.map(Math.round).join(", ")})`,
|
|
86
|
+
px,
|
|
87
|
+
weight: w,
|
|
88
|
+
ratio: Math.round(r * 100) / 100,
|
|
89
|
+
need,
|
|
90
|
+
disabled: isDisabled(el),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// --- empty state -------------------------------------------------------
|
|
98
|
+
// Vuetify's own no-data slot, plus the hand-written variants the screens use
|
|
99
|
+
// ("No access logs found", "No records", "Nothing to show").
|
|
100
|
+
const EMPTY_RE = /^no\b[\w\s'-]{0,40}\b(data|records?|results?|items?|found|available|yet)\b|^nothing to (show|display)\b/i;
|
|
101
|
+
const emptyText = [];
|
|
102
|
+
for (const el of root.querySelectorAll("*")) {
|
|
103
|
+
if (!visible(el)) continue;
|
|
104
|
+
const t = ownText(el);
|
|
105
|
+
if (t && t.length < 60 && EMPTY_RE.test(t)) emptyText.push(t.slice(0, 50));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// A table with zero header cells is the `computedHeaders === undefined`
|
|
109
|
+
// trap: the screen mounts, the toolbar draws, and the thing under review -
|
|
110
|
+
// the header and cell type rules - was never on the glass.
|
|
111
|
+
const tables = [...root.querySelectorAll("table")].filter(visible).map((t) => ({
|
|
112
|
+
cols: t.querySelectorAll("thead th").length,
|
|
113
|
+
rows: [...t.querySelectorAll("tbody tr")].filter((tr) => !EMPTY_RE.test(tr.innerText.trim())).length,
|
|
114
|
+
}));
|
|
115
|
+
|
|
116
|
+
// A form whose every control is empty is the other shape of the same fixture
|
|
117
|
+
// bug, and it does NOT show an empty state or a short DOM - which is how
|
|
118
|
+
// `OvernightParkingAvailability` was signed off with every day toggled off
|
|
119
|
+
// and every time select blank at 1112 elements. That left its Save button
|
|
120
|
+
// permanently disabled, and the disabled ink is the entire source of the
|
|
121
|
+
// 1.75:1 / 2.21:1 readings that went into an AA ledger as real defects.
|
|
122
|
+
// A screen with several controls and not one value in any of them was fed
|
|
123
|
+
// nothing.
|
|
124
|
+
const controls = [...root.querySelectorAll("input, textarea, select, .v-field__input")].filter(visible);
|
|
125
|
+
const filled = controls.filter((c) => {
|
|
126
|
+
if (c.type === "checkbox" || c.type === "radio") return c.checked;
|
|
127
|
+
if ("value" in c && c.value !== undefined) return String(c.value).trim() !== "";
|
|
128
|
+
return c.textContent.trim() !== "";
|
|
129
|
+
}).length;
|
|
130
|
+
const emptyForm = controls.length >= 4 && filled === 0;
|
|
131
|
+
|
|
132
|
+
// --- blank screen ------------------------------------------------------
|
|
133
|
+
// Element count alone said "6 elements, no errors" and a counter read it as a
|
|
134
|
+
// pass. Painted text is the honest signal: a screen nobody can read is blank
|
|
135
|
+
// whatever its element count.
|
|
136
|
+
let textChars = 0;
|
|
137
|
+
for (const el of root.querySelectorAll("*")) if (visible(el)) textChars += ownText(el).length;
|
|
138
|
+
|
|
139
|
+
// --- undefined custom properties ---------------------------------------
|
|
140
|
+
// `color: var(--success)` where the token is `--ok`. An undefined custom
|
|
141
|
+
// property with no fallback invalidates the entire declaration, so the
|
|
142
|
+
// element inherits and nothing anywhere reports a problem.
|
|
143
|
+
//
|
|
144
|
+
// Two passes: collect every custom property DECLARED by any stylesheet, then
|
|
145
|
+
// look for `var(--x)` references (no fallback) to a name nothing declares.
|
|
146
|
+
// Referencing a name that IS declared somewhere is left alone - it may be
|
|
147
|
+
// scoped to a component that this screen does not draw, and guessing there
|
|
148
|
+
// would produce exactly the kind of invented defect this file exists to stop.
|
|
149
|
+
const declared = new Set();
|
|
150
|
+
const refs = new Map(); // --name -> {prop, selector}
|
|
151
|
+
for (const sheet of document.styleSheets) {
|
|
152
|
+
let rules;
|
|
153
|
+
try {
|
|
154
|
+
rules = sheet.cssRules;
|
|
155
|
+
} catch {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (!rules) continue;
|
|
159
|
+
const id = sheet.ownerNode?.getAttribute?.("data-vite-dev-id") || sheet.href || "";
|
|
160
|
+
const ours = !/vuetify|materialdesignicons/i.test(id);
|
|
161
|
+
const walk = (list) => {
|
|
162
|
+
for (const rule of list) {
|
|
163
|
+
if (rule.cssRules) walk(rule.cssRules);
|
|
164
|
+
const st = rule.style;
|
|
165
|
+
if (!st) continue;
|
|
166
|
+
for (const prop of st) {
|
|
167
|
+
if (prop.startsWith("--")) declared.add(prop);
|
|
168
|
+
if (!ours) continue;
|
|
169
|
+
const v = st.getPropertyValue(prop);
|
|
170
|
+
if (!v.includes("var(")) continue;
|
|
171
|
+
for (const m of v.matchAll(/var\(\s*(--[\w-]+)\s*\)/g))
|
|
172
|
+
if (!refs.has(m[1])) refs.set(m[1], { prop, selector: (rule.selectorText || "").slice(0, 60) });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
walk(rules);
|
|
177
|
+
}
|
|
178
|
+
const undefinedVars = [];
|
|
179
|
+
for (const [name, where] of refs) {
|
|
180
|
+
if (declared.has(name)) continue;
|
|
181
|
+
// last chance: a property set inline or by script rather than by a rule
|
|
182
|
+
if (getComputedStyle(document.documentElement).getPropertyValue(name).trim()) continue;
|
|
183
|
+
undefinedVars.push({ name, ...where });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// --- the rest ----------------------------------------------------------
|
|
187
|
+
const bars = [...root.querySelectorAll(".v-toolbar")].filter(visible);
|
|
188
|
+
const legacy = bars.filter(
|
|
189
|
+
(b) =>
|
|
190
|
+
!b.classList.contains("screen-dialog-bar") &&
|
|
191
|
+
[...b.classList].some((c) => /^bg-(grey|blue-grey|white|black)/.test(c)),
|
|
192
|
+
);
|
|
193
|
+
const t = root.querySelector(".screen-title, .page-header__title, h1, .text-h5, .text-h6");
|
|
194
|
+
|
|
195
|
+
const result = {
|
|
196
|
+
els: root.querySelectorAll("*").length,
|
|
197
|
+
textChars,
|
|
198
|
+
title: t ? t.innerText.trim().slice(0, 40) : null,
|
|
199
|
+
bars: bars.length,
|
|
200
|
+
legacy: legacy.length,
|
|
201
|
+
overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
|
202
|
+
emptyText: [...new Set(emptyText)],
|
|
203
|
+
tables,
|
|
204
|
+
controls: controls.length,
|
|
205
|
+
filled,
|
|
206
|
+
emptyForm,
|
|
207
|
+
undefinedVars,
|
|
208
|
+
fails: contrast(null),
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// --- enabled re-probe --------------------------------------------------
|
|
212
|
+
// Runs LAST and mutates the DOM, so nothing above sees it. Strips the
|
|
213
|
+
// disabled state off every control that had one and re-reads contrast on
|
|
214
|
+
// just those elements, so "exempt because disabled" can never be the reason a
|
|
215
|
+
// real failure went unreported.
|
|
216
|
+
if (opts.probeEnabled) {
|
|
217
|
+
const touched = new Set();
|
|
218
|
+
for (const el of root.querySelectorAll(DISABLED_SEL)) {
|
|
219
|
+
el.removeAttribute("disabled");
|
|
220
|
+
el.removeAttribute("aria-disabled");
|
|
221
|
+
for (const c of [...el.classList]) if (/--disabled$/.test(c)) el.classList.remove(c);
|
|
222
|
+
for (const d of el.querySelectorAll("*")) touched.add(d);
|
|
223
|
+
touched.add(el);
|
|
224
|
+
}
|
|
225
|
+
void root.offsetHeight;
|
|
226
|
+
result.enabledFails = touched.size ? contrast(touched) : [];
|
|
227
|
+
}
|
|
228
|
+
return result;
|
|
229
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// Hardened render check for the theme-review harness.
|
|
2
|
+
//
|
|
3
|
+
// This supersedes the ad-hoc `*-measure.mjs` runners. Every guard below exists
|
|
4
|
+
// because the older runners produced a FALSE PASS on 2026-08-14 - a green row
|
|
5
|
+
// for a screen that had not drawn the thing being reviewed. In order of how
|
|
6
|
+
// much damage each one did:
|
|
7
|
+
//
|
|
8
|
+
// 1. ISOLATION one shared `lc/` mirror, so two agents silently measured
|
|
9
|
+
// each other's branch. Now the mirror is stamped and the
|
|
10
|
+
// run aborts unless the stamp matches what it was pointed
|
|
11
|
+
// at (see harness-init.ps1).
|
|
12
|
+
// 2. EMPTY STATE a panel scored 8/8 dark-clean while drawing "No access
|
|
13
|
+
// logs found"; a table was signed off with zero columns and
|
|
14
|
+
// "No data available". A screen showing its empty state has
|
|
15
|
+
// not been measured - it is a fixture bug, and it FAILS.
|
|
16
|
+
// 3. BASELINE `MIN_ELS = 20` was both too low (a partial paint at 32
|
|
17
|
+
// els scored ok where warm was 49) and too high (a
|
|
18
|
+
// correctly-drawing screen at 19 els was reported
|
|
19
|
+
// NOT-MOUNTED). One global floor cannot be right for
|
|
20
|
+
// screens that legitimately range from 19 to 1100 elements,
|
|
21
|
+
// so each screen carries its own recorded baseline and an
|
|
22
|
+
// un-baselined screen is refused rather than guessed at.
|
|
23
|
+
// 4. COLD COMPILE a cold vite compile paints a PARTIAL screen that still
|
|
24
|
+
// cleared the floor. Warm-up is mandatory and unskippable,
|
|
25
|
+
// and any row well under its own screen's best is remeasured.
|
|
26
|
+
// 5. UNDEFINED VAR `var(--success)` where the token is `--ok`: an undefined
|
|
27
|
+
// custom property makes the whole declaration invalid and
|
|
28
|
+
// the element silently inherits. No error, no warning, and
|
|
29
|
+
// the render looks plausible. Now detected.
|
|
30
|
+
// 6. DISABLED NOISE disabled controls are WCAG-1.4.3-exempt, but the contrast
|
|
31
|
+
// probe logged them as failures, which put fictional
|
|
32
|
+
// entries in an AA ledger. Now classified, and the ENABLED
|
|
33
|
+
// state is probed separately so the exemption never hides a
|
|
34
|
+
// real failure.
|
|
35
|
+
// 7. DEFAULTS DRIFT `VAutocomplete` was missing from the harness's Vuetify
|
|
36
|
+
// defaults, so 19 components would have been measured
|
|
37
|
+
// unstyled - inventing a defect the product does not have.
|
|
38
|
+
// The harness defaults are now diffed against the product's
|
|
39
|
+
// `plugins/vuetify.ts` on every run.
|
|
40
|
+
//
|
|
41
|
+
// Usage:
|
|
42
|
+
// PORT=5412 node render-check.mjs <label> Screen [Screen...]
|
|
43
|
+
// PORT=5412 node render-check.mjs --record <label> Screen [Screen...]
|
|
44
|
+
//
|
|
45
|
+
// `--record` writes/updates baselines.json from THIS run. Only ever record from
|
|
46
|
+
// a run you have opened the screenshots for.
|
|
47
|
+
import { chromium } from "playwright";
|
|
48
|
+
import { writeFileSync, readFileSync, readdirSync, existsSync, mkdirSync } from "node:fs";
|
|
49
|
+
import { createHash } from "node:crypto";
|
|
50
|
+
// The in-page probe lives in its own file so `render-check.selftest.mjs` can
|
|
51
|
+
// assert on it directly. Passed to page.evaluate as a FUNCTION, never as a
|
|
52
|
+
// source string: a string expression cannot receive the argument, so `opts`
|
|
53
|
+
// arrived undefined and the enabled re-probe silently never ran.
|
|
54
|
+
import IN_PAGE from "./probe.mjs";
|
|
55
|
+
|
|
56
|
+
const args = process.argv.slice(2);
|
|
57
|
+
const record = args[0] === "--record" && args.shift();
|
|
58
|
+
const [label, ...screens] = args;
|
|
59
|
+
if (!label || !screens.length) {
|
|
60
|
+
console.error("usage: [--record] node render-check.mjs <label> Screen [Screen...]");
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const PORT = Number(process.env.PORT || 0);
|
|
65
|
+
const WIDTHS = [360, 768, 1024, 1440];
|
|
66
|
+
const THEMES = ["light", "dark"];
|
|
67
|
+
const PARTIAL = 0.7; // a row this far under its own screen's best is a partial paint
|
|
68
|
+
|
|
69
|
+
let fatal = 0;
|
|
70
|
+
const die = (msg) => {
|
|
71
|
+
console.error(`FATAL: ${msg}`);
|
|
72
|
+
fatal++;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// GUARD 1 - isolation. Refuse to measure a mirror we cannot prove the origin of.
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
if (!existsSync("lc/.synced-from.json")) {
|
|
79
|
+
die(
|
|
80
|
+
"lc/ carries no .synced-from.json stamp, so there is no way to tell which\n" +
|
|
81
|
+
" checkout is mounted. Run tools/render-harness/harness-init.ps1 first.",
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
// The mirror is compared to the worktree by CONTENT, not by commit: an
|
|
85
|
+
// uncommitted edit is exactly as invisible as a foreign re-sync, and a commit
|
|
86
|
+
// that touches nothing the harness renders should not force a re-sync.
|
|
87
|
+
const MIRRORED = ["assets", "components", "composables", "constants", "types", "utils"];
|
|
88
|
+
const fingerprint = (base) => {
|
|
89
|
+
const files = [];
|
|
90
|
+
const walk = (dir, rel) => {
|
|
91
|
+
if (!existsSync(dir)) return;
|
|
92
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
93
|
+
if (e.isDirectory()) walk(`${dir}/${e.name}`, `${rel}/${e.name}`);
|
|
94
|
+
else files.push([`${rel}/${e.name}`, createHash("sha1").update(readFileSync(`${dir}/${e.name}`)).digest("hex")]);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
for (const d of MIRRORED) walk(`${base}/${d}`, d);
|
|
98
|
+
files.sort((a, b) => (a[0] < b[0] ? -1 : 1));
|
|
99
|
+
return new Map(files);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
let stamp = null;
|
|
103
|
+
if (!fatal) {
|
|
104
|
+
stamp = JSON.parse(readFileSync("lc/.synced-from.json", "utf8"));
|
|
105
|
+
if (PORT && stamp.port !== PORT)
|
|
106
|
+
die(`mirror was stamped for port ${stamp.port} but PORT=${PORT} - that is another agent's server.`);
|
|
107
|
+
|
|
108
|
+
const mine = fingerprint(stamp.worktree);
|
|
109
|
+
const mirror = fingerprint("lc");
|
|
110
|
+
let diff = null;
|
|
111
|
+
for (const [p, h] of mine) if (mirror.get(p) !== h) { diff = mirror.has(p) ? `differs: ${p}` : `missing: ${p}`; break; }
|
|
112
|
+
if (!diff) for (const p of mirror.keys()) if (!mine.has(p)) { diff = `stale extra: ${p}`; break; }
|
|
113
|
+
if (diff)
|
|
114
|
+
die(
|
|
115
|
+
`lc/ does not match ${stamp.worktree} (${diff}).\n` +
|
|
116
|
+
" Either the worktree moved on, or another agent's sync-lc.ps1 wrote over this mirror.\n" +
|
|
117
|
+
" Re-run harness-init.ps1; measuring now would report someone else's code.",
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// GUARD 2 - Vuetify defaults drift. The harness has to configure Vuetify the
|
|
123
|
+
// same way the product does, or it measures a component the product never ships.
|
|
124
|
+
// Compared by parsing both files rather than by keeping a second hand-written
|
|
125
|
+
// list, which is the thing that went stale in the first place.
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
const defaultKeys = (src) => {
|
|
128
|
+
const at = src.indexOf("defaults:");
|
|
129
|
+
if (at < 0) return null;
|
|
130
|
+
let depth = 0,
|
|
131
|
+
end = at;
|
|
132
|
+
for (let i = src.indexOf("{", at); i < src.length; i++) {
|
|
133
|
+
if (src[i] === "{") depth++;
|
|
134
|
+
else if (src[i] === "}" && --depth === 0) {
|
|
135
|
+
end = i;
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const block = src.slice(at, end);
|
|
140
|
+
// only top-level component keys, and not the ones commented out
|
|
141
|
+
return new Set(
|
|
142
|
+
block
|
|
143
|
+
.split("\n")
|
|
144
|
+
.filter((l) => !l.trim().startsWith("//"))
|
|
145
|
+
.join("\n")
|
|
146
|
+
.match(/\bV[A-Z][A-Za-z]*(?=:\s*\{)/g) ?? [],
|
|
147
|
+
);
|
|
148
|
+
};
|
|
149
|
+
if (!fatal && stamp) {
|
|
150
|
+
const productFile = `${stamp.worktree}/plugins/vuetify.ts`;
|
|
151
|
+
if (existsSync(productFile) && existsSync("src/main.js")) {
|
|
152
|
+
const want = defaultKeys(readFileSync(productFile, "utf8"));
|
|
153
|
+
const have = defaultKeys(readFileSync("src/main.js", "utf8"));
|
|
154
|
+
const missing = [...(want ?? [])].filter((k) => !have?.has(k));
|
|
155
|
+
if (missing.length)
|
|
156
|
+
die(
|
|
157
|
+
`harness Vuetify defaults are missing ${missing.join(", ")}, which plugins/vuetify.ts sets.\n` +
|
|
158
|
+
" Those components would render unstyled here and the run would invent a defect.",
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (fatal) process.exit(1);
|
|
164
|
+
|
|
165
|
+
const shotDir = `shots-${label}`;
|
|
166
|
+
mkdirSync(shotDir, { recursive: true });
|
|
167
|
+
const baselines = existsSync("baselines.json") ? JSON.parse(readFileSync("baselines.json", "utf8")) : {};
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
const browser = await chromium.launch();
|
|
171
|
+
const URL_FOR = (name, theme) => `http://localhost:${PORT}/?screen=module&main=${name}&theme=${theme}`;
|
|
172
|
+
|
|
173
|
+
const probe = async (name, theme, width, shot) => {
|
|
174
|
+
const page = await browser.newPage({ viewport: { width, height: 900 } });
|
|
175
|
+
const errs = [];
|
|
176
|
+
page.on("pageerror", (e) => errs.push(String(e).slice(0, 160)));
|
|
177
|
+
await page.goto(URL_FOR(name, theme), { waitUntil: "networkidle" });
|
|
178
|
+
// Wait for the DOM to STOP GROWING rather than for a fixed 900ms. A cold vite
|
|
179
|
+
// compile paints a screen in pieces, and the old fixed wait sometimes ended
|
|
180
|
+
// mid-paint: NFCTagMain came back els:32 bars:0 and scored ok where the warm
|
|
181
|
+
// render is els:49 with two legacy toolbars. That is worse than a blank,
|
|
182
|
+
// because every number in the row looks like a successful conversion.
|
|
183
|
+
let last = -1;
|
|
184
|
+
for (let i = 0; i < 40; i++) {
|
|
185
|
+
await page.waitForTimeout(150);
|
|
186
|
+
const n = await page.evaluate(() => (document.querySelector(".app-content") || document.body).querySelectorAll("*").length);
|
|
187
|
+
if (n === last && i >= 3) break; // 3 polls minimum, then two agreeing reads
|
|
188
|
+
last = n;
|
|
189
|
+
}
|
|
190
|
+
const r = await page.evaluate(IN_PAGE, { probeEnabled: false });
|
|
191
|
+
if (shot) await page.screenshot({ path: shot, fullPage: true });
|
|
192
|
+
// after the screenshot, because it edits the DOM
|
|
193
|
+
const withEnabled = await page.evaluate(IN_PAGE, { probeEnabled: true });
|
|
194
|
+
await page.close();
|
|
195
|
+
return { name, theme, width, ...r, enabledFails: withEnabled.enabledFails ?? [], errs };
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// Mandatory warm-up. Not a flag: a cold compile paints a partial screen that
|
|
199
|
+
// looks like a pass, and an option to skip this is an option to publish fiction.
|
|
200
|
+
const warm = new Map();
|
|
201
|
+
for (const name of screens) warm.set(name, (await probe(name, "light", 1440, null)).els);
|
|
202
|
+
// Printed, not just used: on a cold vite cache the warm-up number comes back
|
|
203
|
+
// materially UNDER the steady one, and that gap is the whole of trap 4. Seeing
|
|
204
|
+
// it is how you know the warm-up earned its place in this run rather than in
|
|
205
|
+
// some other one.
|
|
206
|
+
console.log(`warm-up (discarded): ${[...warm].map(([n, e]) => `${n} els:${e}`).join(", ")}\n`);
|
|
207
|
+
|
|
208
|
+
const rows = [];
|
|
209
|
+
for (const name of screens) {
|
|
210
|
+
const slug = name.replace(/\//g, "-");
|
|
211
|
+
for (const theme of THEMES)
|
|
212
|
+
for (const width of WIDTHS)
|
|
213
|
+
rows.push(await probe(name, theme, width, `${shotDir}/${slug}--${theme}--${width}.png`));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Partial-paint guard: seeded with the warm-up so a screen whose every measured
|
|
217
|
+
// row is partial still has something honest to be compared against.
|
|
218
|
+
const best = new Map(warm);
|
|
219
|
+
for (const r of rows) best.set(r.name, Math.max(best.get(r.name) ?? 0, r.els));
|
|
220
|
+
for (let i = 0; i < rows.length; i++) {
|
|
221
|
+
const r = rows[i];
|
|
222
|
+
if (r.els >= best.get(r.name) * PARTIAL) continue;
|
|
223
|
+
const slug = r.name.replace(/\//g, "-");
|
|
224
|
+
console.log(` re-measuring ${r.name} ${r.theme} ${r.width} (els:${r.els} vs best ${best.get(r.name)})`);
|
|
225
|
+
rows[i] = await probe(r.name, r.theme, r.width, `${shotDir}/${slug}--${r.theme}--${r.width}.png`);
|
|
226
|
+
rows[i].remeasured = true;
|
|
227
|
+
}
|
|
228
|
+
await browser.close();
|
|
229
|
+
|
|
230
|
+
writeFileSync(`${label}-measure.json`, JSON.stringify(rows, null, 2));
|
|
231
|
+
|
|
232
|
+
if (record) {
|
|
233
|
+
for (const name of screens) {
|
|
234
|
+
const mine = rows.filter((r) => r.name === name);
|
|
235
|
+
baselines[name] = {
|
|
236
|
+
...baselines[name],
|
|
237
|
+
els: Math.max(...mine.map((r) => r.els)),
|
|
238
|
+
textChars: Math.max(...mine.map((r) => r.textChars)),
|
|
239
|
+
recordedFrom: stamp.head.slice(0, 7),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
writeFileSync("baselines.json", JSON.stringify(baselines, null, 2) + "\n");
|
|
243
|
+
console.log(`recorded ${screens.length} baseline(s) -> baselines.json`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// Verdict. Every one of these is a hard fail; none of them used to be.
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
let bad = 0;
|
|
250
|
+
for (const r of rows) {
|
|
251
|
+
const base = baselines[r.name];
|
|
252
|
+
const problems = [];
|
|
253
|
+
|
|
254
|
+
if (!base) problems.push("NO-BASELINE");
|
|
255
|
+
else if (r.els < Math.floor(base.els * PARTIAL)) problems.push(`UNDER-BASELINE (${r.els}/${base.els})`);
|
|
256
|
+
|
|
257
|
+
if (r.textChars === 0) problems.push("BLANK");
|
|
258
|
+
else if (base && r.textChars < Math.floor(base.textChars * PARTIAL))
|
|
259
|
+
problems.push(`UNDER-TEXT (${r.textChars}/${base.textChars})`);
|
|
260
|
+
|
|
261
|
+
if (r.els < best.get(r.name) * PARTIAL) problems.push(`PARTIAL (${r.els}/${best.get(r.name)})`);
|
|
262
|
+
|
|
263
|
+
if (!base?.allowEmpty) {
|
|
264
|
+
if (r.emptyText.length) problems.push(`EMPTY-STATE "${r.emptyText[0]}"`);
|
|
265
|
+
if (r.emptyForm) problems.push(`EMPTY-FORM (0/${r.controls} controls hold a value)`);
|
|
266
|
+
for (const t of r.tables) {
|
|
267
|
+
if (!t.cols) problems.push("TABLE-0-COLS");
|
|
268
|
+
else if (!t.rows) problems.push("TABLE-0-ROWS");
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (r.undefinedVars.length)
|
|
272
|
+
problems.push(`UNDEFINED-VAR ${r.undefinedVars.map((v) => v.name).join(",")}`);
|
|
273
|
+
|
|
274
|
+
const aa = r.fails.filter((f) => !f.disabled);
|
|
275
|
+
const exempt = r.fails.length - aa.length;
|
|
276
|
+
if (aa.length) problems.push(`AA:${aa.length}`);
|
|
277
|
+
if (r.enabledFails.length) problems.push(`AA-ENABLED:${r.enabledFails.length}`);
|
|
278
|
+
if (r.overflow > 0) problems.push(`OVERFLOW+${r.overflow}`);
|
|
279
|
+
if (r.legacy) problems.push(`LEGACY:${r.legacy}`);
|
|
280
|
+
|
|
281
|
+
if (problems.length) bad++;
|
|
282
|
+
console.log(
|
|
283
|
+
`${r.name.padEnd(28)} ${r.theme.padEnd(5)} ${String(r.width).padStart(4)} els:${String(r.els).padStart(4)}` +
|
|
284
|
+
` txt:${String(r.textChars).padStart(5)} bars:${r.bars}(legacy ${r.legacy}) title:${r.title ?? "NONE"}` +
|
|
285
|
+
`${exempt ? ` exempt:${exempt}` : ""} ${problems.join(" ") || "ok"}`,
|
|
286
|
+
);
|
|
287
|
+
if (r.errs.length) console.log(" pageerror: " + r.errs[0]);
|
|
288
|
+
}
|
|
289
|
+
console.log(`\n${rows.length} rows, ${bad} with problems. json -> ${label}-measure.json, shots -> ${shotDir}/`);
|
|
290
|
+
|
|
291
|
+
const agg = new Map();
|
|
292
|
+
for (const r of rows)
|
|
293
|
+
for (const f of [...r.fails.filter((x) => !x.disabled), ...r.enabledFails.map((x) => ({ ...x, forced: true }))]) {
|
|
294
|
+
const k = `${f.color}|${f.px}|${f.weight}|${f.bg}|${f.forced ? "en" : ""}`;
|
|
295
|
+
if (!agg.has(k)) agg.set(k, { ...f, where: new Set() });
|
|
296
|
+
agg.get(k).where.add(`${r.name}/${r.theme}`);
|
|
297
|
+
}
|
|
298
|
+
if (agg.size) {
|
|
299
|
+
console.log("\nDISTINCT CONTRAST FAILURES (worst first; [forced-enabled] = read after removing the disabled state)");
|
|
300
|
+
for (const f of [...agg.values()].sort((a, b) => a.ratio - b.ratio))
|
|
301
|
+
console.log(
|
|
302
|
+
` ${String(f.ratio).padStart(5)}:1 (need ${f.need}) ${f.px}px/${f.weight} ${f.color} on ${f.bg}` +
|
|
303
|
+
` "${f.text}"${f.forced ? " [forced-enabled]" : ""} [${[...f.where].join(", ")}]`,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
process.exit(bad ? 1 : 0);
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Proves each detector in probe.mjs FAILS the scenario it was written for, and
|
|
2
|
+
// passes the corrected version of that same scenario.
|
|
3
|
+
//
|
|
4
|
+
// Against a hand-built page rather than a product screen, on purpose: which
|
|
5
|
+
// screen happens to contain a disabled control or an undefined token changes
|
|
6
|
+
// week to week, and a guard nobody can demonstrate failing is not a guard.
|
|
7
|
+
//
|
|
8
|
+
// node render-check.selftest.mjs
|
|
9
|
+
import { chromium } from "playwright";
|
|
10
|
+
import probe from "./probe.mjs";
|
|
11
|
+
|
|
12
|
+
let failed = 0;
|
|
13
|
+
const check = (name, cond, got) => {
|
|
14
|
+
console.log(`${cond ? " ok " : "FAIL "}${name}${cond ? "" : ` got: ${JSON.stringify(got)}`}`);
|
|
15
|
+
if (!cond) failed++;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const page = await (await chromium.launch()).newPage({ viewport: { width: 1200, height: 900 } });
|
|
19
|
+
const run = async (html, opts = { probeEnabled: true }) => {
|
|
20
|
+
await page.setContent(`<div class="app-content">${html}</div>`, { waitUntil: "load" });
|
|
21
|
+
return page.evaluate(probe, opts);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// --- empty state -----------------------------------------------------------
|
|
25
|
+
// The HID access-logs panel scored 8/8 dark-clean while drawing this string.
|
|
26
|
+
check(
|
|
27
|
+
"empty state: 'No access logs found' is detected",
|
|
28
|
+
(await run(`<p>No access logs found</p>`)).emptyText.length === 1,
|
|
29
|
+
);
|
|
30
|
+
check(
|
|
31
|
+
"empty state: real content is not",
|
|
32
|
+
(await run(`<p>Lobby ANPR</p><p>Carpark Exit</p>`)).emptyText.length === 0,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
// --- table shape -----------------------------------------------------------
|
|
36
|
+
// CameraMain: computedHeaders returned undefined, so the table drew no columns
|
|
37
|
+
// at all and the header/cell rules under review were never on the glass.
|
|
38
|
+
const noCols = await run(`<table><thead><tr></tr></thead><tbody><tr><td>x</td></tr></tbody></table>`);
|
|
39
|
+
check("table: zero header columns is detected", noCols.tables[0].cols === 0, noCols.tables);
|
|
40
|
+
const ok = await run(
|
|
41
|
+
`<table><thead><tr><th>URL</th><th>Type</th></tr></thead><tbody><tr><td>a</td><td>b</td></tr></tbody></table>`,
|
|
42
|
+
);
|
|
43
|
+
check("table: a drawn table reports its columns and rows", ok.tables[0].cols === 2 && ok.tables[0].rows === 1, ok.tables);
|
|
44
|
+
const noRows = await run(
|
|
45
|
+
`<table><thead><tr><th>URL</th></tr></thead><tbody><tr><td>No data available</td></tr></tbody></table>`,
|
|
46
|
+
);
|
|
47
|
+
check("table: the no-data row is not counted as a row", noRows.tables[0].rows === 0, noRows.tables);
|
|
48
|
+
|
|
49
|
+
// --- blank screen ----------------------------------------------------------
|
|
50
|
+
// OvernightParkingAvailability once rendered blank with zero page errors and
|
|
51
|
+
// six elements, and a counter read that as a pass.
|
|
52
|
+
check("blank: no painted text reports textChars 0", (await run(`<div><span></span><span></span></div>`)).textChars === 0);
|
|
53
|
+
const painted = await run(`<p>Seventh Condominium</p>`);
|
|
54
|
+
check("blank: painted text is counted", painted.textChars === 19, painted.textChars);
|
|
55
|
+
|
|
56
|
+
// --- empty form ------------------------------------------------------------
|
|
57
|
+
// The other shape of the same fixture bug: 1112 elements, nothing blank, and
|
|
58
|
+
// not one control holding a value - which is what left Save disabled and put
|
|
59
|
+
// its disabled ink into an AA ledger as a real defect.
|
|
60
|
+
const blankForm = await run(
|
|
61
|
+
`<form>${'<input type="text" value="">'.repeat(3)}<input type="checkbox"><input type="checkbox"></form>`,
|
|
62
|
+
);
|
|
63
|
+
check("empty form: no control holds a value is detected", blankForm.emptyForm === true, blankForm);
|
|
64
|
+
const oneValue = await run(
|
|
65
|
+
`<form><input type="text" value="22:00">${'<input type="text" value="">'.repeat(2)}<input type="checkbox" checked><input type="checkbox"></form>`,
|
|
66
|
+
);
|
|
67
|
+
check("empty form: one saved value clears it", oneValue.emptyForm === false, oneValue);
|
|
68
|
+
|
|
69
|
+
// --- undefined custom property ---------------------------------------------
|
|
70
|
+
// `var(--success)` where the token is `--ok`. The declaration is invalid, the
|
|
71
|
+
// element inherits, and nothing anywhere reports a problem.
|
|
72
|
+
const badVar = await run(
|
|
73
|
+
`<style>:root{--ok:#0e7c58}.day-tick--on{color:var(--success)}</style><p class="day-tick--on">on</p>`,
|
|
74
|
+
);
|
|
75
|
+
check(
|
|
76
|
+
"undefined var: var(--success) with no such token is detected",
|
|
77
|
+
badVar.undefinedVars.length === 1 && badVar.undefinedVars[0].name === "--success",
|
|
78
|
+
badVar.undefinedVars,
|
|
79
|
+
);
|
|
80
|
+
const goodVar = await run(
|
|
81
|
+
`<style>:root{--ok:#0e7c58}.day-tick--on{color:var(--ok)}</style><p class="day-tick--on">on</p>`,
|
|
82
|
+
);
|
|
83
|
+
check("undefined var: the correct token is not flagged", goodVar.undefinedVars.length === 0, goodVar.undefinedVars);
|
|
84
|
+
// The 2026-08-14 bug survived a full 8-shot render because the element never
|
|
85
|
+
// drew. Catching it only when it draws would not have caught it.
|
|
86
|
+
const unrendered = await run(
|
|
87
|
+
`<style>:root{--ok:#0e7c58}.day-tick--on{color:var(--success)}</style><p>no tick on this screen</p>`,
|
|
88
|
+
);
|
|
89
|
+
check(
|
|
90
|
+
"undefined var: caught even when nothing on the screen uses the rule",
|
|
91
|
+
unrendered.undefinedVars.length === 1,
|
|
92
|
+
unrendered.undefinedVars,
|
|
93
|
+
);
|
|
94
|
+
// A name that IS declared elsewhere must not be flagged, or every scoped
|
|
95
|
+
// component variable becomes an invented defect.
|
|
96
|
+
const scoped = await run(
|
|
97
|
+
`<style>.card{--pad:8px}.other{padding:var(--pad)}</style><p class="other">x</p>`,
|
|
98
|
+
);
|
|
99
|
+
check("undefined var: a property declared elsewhere is left alone", scoped.undefinedVars.length === 0, scoped.undefinedVars);
|
|
100
|
+
|
|
101
|
+
// --- disabled classification + enabled re-probe ------------------------------
|
|
102
|
+
// Disabled controls are WCAG-1.4.3-exempt. Logging them as failures put
|
|
103
|
+
// fictional entries in an AA ledger; hiding them entirely would let a real
|
|
104
|
+
// failure escape behind the exemption. Both, separately.
|
|
105
|
+
const dis = await run(
|
|
106
|
+
`<style>button{background:#ffffff;color:#c9c9c9;font-size:14px}</style><button disabled>Save</button>`,
|
|
107
|
+
);
|
|
108
|
+
check("disabled: the reading is captured", dis.fails.length === 1, dis.fails);
|
|
109
|
+
check("disabled: and classified exempt, not counted as AA", dis.fails[0]?.disabled === true, dis.fails[0]);
|
|
110
|
+
check(
|
|
111
|
+
"disabled: the ENABLED state is probed separately and still fails",
|
|
112
|
+
dis.enabledFails.length === 1,
|
|
113
|
+
dis.enabledFails,
|
|
114
|
+
);
|
|
115
|
+
const enabled = await run(
|
|
116
|
+
`<style>button{background:#ffffff;color:#c9c9c9;font-size:14px}</style><button>Save</button>`,
|
|
117
|
+
);
|
|
118
|
+
check("disabled: an enabled control is not marked exempt", enabled.fails[0]?.disabled === false, enabled.fails[0]);
|
|
119
|
+
const contrasty = await run(
|
|
120
|
+
`<style>button{background:#ffffff;color:#1b1d21;font-size:14px}</style><button disabled>Save</button>`,
|
|
121
|
+
);
|
|
122
|
+
check(
|
|
123
|
+
"disabled: a disabled control that also passes enabled reports nothing",
|
|
124
|
+
contrasty.fails.length === 0 && contrasty.enabledFails.length === 0,
|
|
125
|
+
contrasty.fails,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
console.log(failed ? `\n${failed} FAILED` : "\nall detectors fire");
|
|
129
|
+
process.exit(failed ? 1 : 0);
|