@7365admin1/layer-common 3.2.2-staging.122 → 3.2.2-staging.123

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.
@@ -1,127 +0,0 @@
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
- # `probe.mjs` is imported by the runner, so a fork without it dies on
82
- # ERR_MODULE_NOT_FOUND before it measures anything. It was missing from this
83
- # list, which every fork made before now had to work around by hand.
84
- Copy-Item (Join-Path $PSScriptRoot "probe.mjs") -Destination $fork -Force
85
- Copy-Item (Join-Path $PSScriptRoot "baselines.json") -Destination $fork -Force -ErrorAction SilentlyContinue
86
-
87
- # A junctioned `node_modules` SHARES `node_modules/.vite` with the harness it
88
- # points at. Two servers writing one dep-cache produced 504s that photograph as
89
- # a blank screen - i.e. a fake "conversion broke the page". Own cache, always.
90
- $vite = Join-Path $fork "vite.config.mjs"
91
- $cfg = [System.IO.File]::ReadAllText($vite)
92
- if ($cfg -notmatch "cacheDir") {
93
- $cfg = $cfg -replace "(?m)^\s*server:\s*\{", " cacheDir: `"./.vite-cache`",`r`n server: {"
94
- # UTF8Encoding($false): Set-Content -Encoding utf8 writes a BOM on Windows
95
- # PowerShell 5.1, and a BOM in a config file makes PostCSS's loader throw,
96
- # which 500s EVERY .vue file - indistinguishable from a broken checkout.
97
- [System.IO.File]::WriteAllText($vite, $cfg, (New-Object System.Text.UTF8Encoding($false)))
98
- }
99
-
100
- $pkgPath = Join-Path $fork "package.json"
101
- $pkg = [System.IO.File]::ReadAllText($pkgPath)
102
- $pkg = $pkg -replace '--port \d+', "--port $port"
103
- [System.IO.File]::WriteAllText($pkgPath, $pkg, (New-Object System.Text.UTF8Encoding($false)))
104
- [System.IO.File]::WriteAllText($portFile, "$port", (New-Object System.Text.UTF8Encoding($false)))
105
-
106
- # --- mirror -----------------------------------------------------------------
107
- & "$fork\sync-lc.ps1" -Src $Worktree
108
-
109
- # Stamp WHAT was mirrored, so a measurement can prove which checkout it rendered
110
- # rather than assume it. `render-check.mjs` refuses to run when this disagrees
111
- # with the worktree it was pointed at - which is what a foreign `sync-lc.ps1`
112
- # stamping over the mirror looks like. Written after the sync, because
113
- # `sync-lc.ps1` deletes everything in `lc/` that is not one of its six dirs.
114
- $stamp = @{
115
- worktree = $Worktree
116
- head = (git -C $Worktree rev-parse HEAD)
117
- branch = (git -C $Worktree rev-parse --abbrev-ref HEAD)
118
- port = $port
119
- } | ConvertTo-Json -Compress
120
- [System.IO.File]::WriteAllText((Join-Path $fork "lc\.synced-from.json"), $stamp, (New-Object System.Text.UTF8Encoding($false)))
121
-
122
- Write-Output "harness : $fork"
123
- Write-Output "port : $port"
124
- Write-Output "renders : $Worktree @ $(git -C $Worktree rev-parse --short HEAD)"
125
- Write-Output ""
126
- Write-Output " Start-Process -WorkingDirectory '$fork' npx -ArgumentList 'vite','--port','$port','--strictPort'"
127
- Write-Output " cd '$fork'; `$env:PORT=$port; node render-check.mjs <label> <Screen> [Screen...]"
@@ -1,229 +0,0 @@
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
- }
@@ -1,306 +0,0 @@
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);