@hyperfixi/testing-framework 2.9.0 → 2.9.2
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 +7 -6
- package/src/multilingual/canonical-validity.ts +3 -2
- package/src/multilingual/effect-signature.ts +92 -0
- package/src/multilingual/shipped-examples-execution.test.ts +157 -0
- package/src/multilingual/shipped-examples-execution.ts +499 -0
- package/src/multilingual/shipped-sources-validity.test.ts +95 -0
- package/src/multilingual/shipped-sources-validity.ts +194 -0
- package/src/multilingual/validators/execution-validator.test.ts +5 -3
- package/src/multilingual/validators/execution-validator.ts +7 -47
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperfixi/testing-framework",
|
|
3
|
-
"version": "2.9.
|
|
3
|
+
"version": "2.9.2",
|
|
4
4
|
"description": "Cross-platform behavior testing suite for LokaScript applications",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
@@ -40,7 +40,8 @@
|
|
|
40
40
|
"analyze-failures": "tsx src/multilingual/tools/analyze-failures.ts",
|
|
41
41
|
"typecheck": "tsc --noEmit",
|
|
42
42
|
"test:check": "VITEST_QUIET=1 bash ../../scripts/vitest-run.sh --reporter=dot",
|
|
43
|
-
"test:canonical": "FOREIGN_CANONICAL_VALIDITY=1 VITEST_TIMEOUT=300 bash ../../scripts/vitest-run.sh --reporter=dot src/multilingual/canonical-validity.test.ts src/multilingual/foreign-canonical-validity.test.ts"
|
|
43
|
+
"test:canonical": "FOREIGN_CANONICAL_VALIDITY=1 VITEST_TIMEOUT=300 bash ../../scripts/vitest-run.sh --reporter=dot src/multilingual/canonical-validity.test.ts src/multilingual/foreign-canonical-validity.test.ts",
|
|
44
|
+
"test:shipped-sources": "VITEST_TIMEOUT=300 bash ../../scripts/vitest-run.sh --reporter=dot src/multilingual/shipped-sources-validity.test.ts"
|
|
44
45
|
},
|
|
45
46
|
"keywords": [
|
|
46
47
|
"hyperscript",
|
|
@@ -56,10 +57,10 @@
|
|
|
56
57
|
"author": "LokaScript Contributors",
|
|
57
58
|
"license": "MIT",
|
|
58
59
|
"dependencies": {
|
|
59
|
-
"@hyperfixi/core": "^2.9.
|
|
60
|
-
"@hyperfixi/patterns-reference": "^2.9.
|
|
61
|
-
"@lokascript/i18n": "^2.9.
|
|
62
|
-
"@lokascript/semantic": "^2.9.
|
|
60
|
+
"@hyperfixi/core": "^2.9.2",
|
|
61
|
+
"@hyperfixi/patterns-reference": "^2.9.2",
|
|
62
|
+
"@lokascript/i18n": "^2.9.2",
|
|
63
|
+
"@lokascript/semantic": "^2.9.2",
|
|
63
64
|
"diff": "^8.0.3",
|
|
64
65
|
"esbuild": "^0.28.0",
|
|
65
66
|
"happy-dom": "^20.10.6",
|
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
*
|
|
16
16
|
* The FOREIGN-side sibling (foreign-canonical-validity.ts) is folded into
|
|
17
17
|
* `multilingual/cli.ts`'s `--regression` gate as the R4 canonical-validity
|
|
18
|
-
* ratchet. This en-side check remains vitest-only
|
|
19
|
-
*
|
|
18
|
+
* ratchet. This en-side check remains vitest-only; its allowlist is currently
|
|
19
|
+
* EMPTY (134/134 corpus references render valid — it formerly held one entry,
|
|
20
|
+
* pick-text-range). The same loader recipe now also ships in the
|
|
20
21
|
* build-time `@hyperscript-tools/i18n` transpiler (`src/validate.ts`, CLI
|
|
21
22
|
* `--check`, Eleventy `parseCheck`) to gate its ENGLISH side (input + foreign→
|
|
22
23
|
* English output). Faithful foreign-OUTPUT gating still awaits the v2
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM effect signatures — shared by the R2 execution validator (corpus
|
|
3
|
+
* patterns vs the en reference) and the shipped-examples execution gate
|
|
4
|
+
* (shipped handlers vs the upstream engine).
|
|
5
|
+
*
|
|
6
|
+
* A signature is a before/after diff of every element's identity-relevant
|
|
7
|
+
* state: classes, attributes, inline style, and leaf text. Selectors, classes,
|
|
8
|
+
* and attribute names are code (not translated), so signatures are directly
|
|
9
|
+
* comparable across languages — and across engines, which is what makes the
|
|
10
|
+
* upstream-oracle comparison possible at all.
|
|
11
|
+
*
|
|
12
|
+
* Extracted verbatim from validators/execution-validator.ts (R2), which now
|
|
13
|
+
* imports from here. Any change to the serialization changes BOTH gates'
|
|
14
|
+
* signatures at once, deliberately: the two must never disagree about what a
|
|
15
|
+
* DOM effect is.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Attributes that are ENGINE bookkeeping, not page behavior: hyperscript
|
|
20
|
+
* source (`_`), hyperfixi's processed-marker, show/hide display memo, and the
|
|
21
|
+
* shipped-examples harness's identity keys. Excluded from signatures so a
|
|
22
|
+
* cross-engine comparison measures what the handler DID to the page, not how
|
|
23
|
+
* each engine annotates its own work. For R2 this changed exactly one locked
|
|
24
|
+
* signature (`hide-with-transition`, which loses its data-original-display
|
|
25
|
+
* memo) and no fidelity numbers — translations are compared against a same-run
|
|
26
|
+
* en reference, so both sides drop the memo together.
|
|
27
|
+
*/
|
|
28
|
+
const ENGINE_ATTRS = new Set([
|
|
29
|
+
'_',
|
|
30
|
+
'data-hyperscript-powered',
|
|
31
|
+
'data-original-display',
|
|
32
|
+
'data-exec-key',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Serialize the identity-relevant state of one element.
|
|
37
|
+
* Leaf text only — container text would duplicate every child mutation.
|
|
38
|
+
*/
|
|
39
|
+
export function serializeElement(el: Element): string {
|
|
40
|
+
const attrs = Array.from(el.attributes)
|
|
41
|
+
.filter(a => a.name !== 'class' && a.name !== 'style' && !ENGINE_ATTRS.has(a.name))
|
|
42
|
+
.map(a => `${a.name}=${a.value}`)
|
|
43
|
+
.sort()
|
|
44
|
+
.join(',');
|
|
45
|
+
const classes = Array.from(el.classList).sort().join(' ');
|
|
46
|
+
const style = (el as HTMLElement).getAttribute('style') ?? '';
|
|
47
|
+
const text =
|
|
48
|
+
el.childNodes.length === 0 || (el.childNodes.length === 1 && el.firstChild?.nodeType === 3)
|
|
49
|
+
? (el.textContent ?? '')
|
|
50
|
+
: '';
|
|
51
|
+
return `cls[${classes}] attr[${attrs}] style[${style}] text[${text}]`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Snapshot every element under <body>. <body> participates under its own
|
|
56
|
+
* stable key (body-targeted effects must be visible).
|
|
57
|
+
*
|
|
58
|
+
* Keying: `data-exec-key` when present (stamped by the shipped-examples
|
|
59
|
+
* harness BEFORE the engine runs, so the key is the element's IDENTITY — an
|
|
60
|
+
* engine inserting or removing one element cannot shift every later element's
|
|
61
|
+
* key, which flat indices did: one injected node turned a one-line diff into a
|
|
62
|
+
* whole-page churn storm). Elements without a key (added during the run, and
|
|
63
|
+
* the whole R2 corpus fixture, which is never stamped) fall back to `#id` /
|
|
64
|
+
* `tag[document-order-index]` — R2's original keying, byte-identical for its
|
|
65
|
+
* signatures.
|
|
66
|
+
*/
|
|
67
|
+
export function snapshot(document: Document): Map<string, string> {
|
|
68
|
+
const out = new Map<string, string>();
|
|
69
|
+
out.set('body', serializeElement(document.body));
|
|
70
|
+
document.body.querySelectorAll('*').forEach((el, i) => {
|
|
71
|
+
const execKey = el.getAttribute?.('data-exec-key');
|
|
72
|
+
const key = execKey
|
|
73
|
+
? `${el.tagName.toLowerCase()}:${execKey}${el.id ? `#${el.id}` : ''}`
|
|
74
|
+
: el.id
|
|
75
|
+
? `#${el.id}`
|
|
76
|
+
: `${el.tagName.toLowerCase()}[${i}]`;
|
|
77
|
+
out.set(key, serializeElement(el));
|
|
78
|
+
});
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Sorted, stable list of per-element changes between two snapshots. */
|
|
83
|
+
export function diffSnapshots(before: Map<string, string>, after: Map<string, string>): string[] {
|
|
84
|
+
const effects: string[] = [];
|
|
85
|
+
for (const [k, v] of after) {
|
|
86
|
+
const b = before.get(k);
|
|
87
|
+
if (b === undefined) effects.push(`+${k} ${v}`);
|
|
88
|
+
else if (b !== v) effects.push(`Δ${k} ${v}`);
|
|
89
|
+
}
|
|
90
|
+
for (const k of before.keys()) if (!after.has(k)) effects.push(`-${k}`);
|
|
91
|
+
return effects.sort();
|
|
92
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shipped-examples execution gate (see shipped-examples-execution.ts for the
|
|
3
|
+
* why and the execution model).
|
|
4
|
+
*
|
|
5
|
+
* Executes every eligible `_="…"` handler shipped in `examples/**` on BOTH
|
|
6
|
+
* hyperfixi and the real `hyperscript.org` engine, in jsdom, and ratchets on
|
|
7
|
+
* divergence of their DOM effect signatures. Upstream is the behavioral
|
|
8
|
+
* oracle — the same role R4 gives it for validity. This is the gate that would
|
|
9
|
+
* have caught the #785 defect (a conditional body running unconditionally on a
|
|
10
|
+
* shipped page) on BEHAVIOR: every parse-level gate stayed green while it
|
|
11
|
+
* shipped.
|
|
12
|
+
*
|
|
13
|
+
* Assertions, matching the shipped-sources gate:
|
|
14
|
+
* 1. sanity — pages walked, handlers extracted, comparisons actually ran
|
|
15
|
+
* (guards the silent-zero failure mode);
|
|
16
|
+
* 2. no NEW divergence appears outside the committed allowlist;
|
|
17
|
+
* 3. no allowlisted key has silently converged (stale entries must be
|
|
18
|
+
* removed so the list only ever ratchets down).
|
|
19
|
+
*
|
|
20
|
+
* To update after an intentional change: re-run and regenerate
|
|
21
|
+
* `baselines/shipped-examples-execution.json` (the allowlist key embeds a
|
|
22
|
+
* source hash, so FIXING a handler changes its key and assertion 3 forces the
|
|
23
|
+
* entry's removal).
|
|
24
|
+
*
|
|
25
|
+
* Node-only (walks the repo, imports hyperscript.org off disk). The sweep
|
|
26
|
+
* swaps jsdom globals per handler execution — safe because vitest isolates
|
|
27
|
+
* test files per worker.
|
|
28
|
+
*
|
|
29
|
+
* @vitest-environment node
|
|
30
|
+
* The node environment is REQUIRED, not a preference: under the suite default
|
|
31
|
+
* (happy-dom) the DOM constructors already exist on globalThis, the harness's
|
|
32
|
+
* globals bootstrap refuses to overwrite what it does not own, and both
|
|
33
|
+
* engines then bind happy-dom's constructors — every instanceof against a
|
|
34
|
+
* jsdom element fails and every hyperfixi signature comes back empty
|
|
35
|
+
* (measured: 0 real matches under happy-dom vs 74 under node).
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { readFileSync } from 'node:fs';
|
|
39
|
+
import { fileURLToPath } from 'node:url';
|
|
40
|
+
import path from 'node:path';
|
|
41
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
42
|
+
import {
|
|
43
|
+
runShippedExamplesExecution,
|
|
44
|
+
triggerEventOf,
|
|
45
|
+
keyFor,
|
|
46
|
+
type ExecutionParityResult,
|
|
47
|
+
} from './shipped-examples-execution';
|
|
48
|
+
|
|
49
|
+
interface AllowlistDoc {
|
|
50
|
+
allowedDivergences: Array<{
|
|
51
|
+
key: string;
|
|
52
|
+
file: string;
|
|
53
|
+
event: string;
|
|
54
|
+
excerpt: string;
|
|
55
|
+
reason: string;
|
|
56
|
+
}>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const baselinePath = path.resolve(
|
|
60
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
61
|
+
'../../baselines/shipped-examples-execution.json'
|
|
62
|
+
);
|
|
63
|
+
const allowlist = JSON.parse(readFileSync(baselinePath, 'utf8')) as AllowlistDoc;
|
|
64
|
+
const allowed = new Set(allowlist.allowedDivergences.map(e => e.key));
|
|
65
|
+
|
|
66
|
+
describe('shipped-examples execution gate', () => {
|
|
67
|
+
let result: ExecutionParityResult;
|
|
68
|
+
|
|
69
|
+
beforeAll(async () => {
|
|
70
|
+
result = await runShippedExamplesExecution();
|
|
71
|
+
|
|
72
|
+
// Visibility, not assertions: what the sweep could not compare, and why.
|
|
73
|
+
// A silently shrinking denominator is this gate's own blind spot.
|
|
74
|
+
const reasons = new Map<string, number>();
|
|
75
|
+
for (const s of result.skipped) {
|
|
76
|
+
const r = s.reason.split(':')[0] ?? s.reason;
|
|
77
|
+
reasons.set(r, (reasons.get(r) ?? 0) + 1);
|
|
78
|
+
}
|
|
79
|
+
const vacuous = result.compared.filter(c => c.vacuous).length;
|
|
80
|
+
console.log(
|
|
81
|
+
`[shipped-examples-execution] pages=${result.pages} handlers=${result.handlers} ` +
|
|
82
|
+
`compared=${result.compared.length} (vacuous=${vacuous}) skipped=${result.skipped.length}`
|
|
83
|
+
);
|
|
84
|
+
for (const [r, n] of [...reasons].sort((a, b) => b[1] - a[1])) {
|
|
85
|
+
console.log(`[shipped-examples-execution] skip ×${n}: ${r}`);
|
|
86
|
+
}
|
|
87
|
+
}, 240_000);
|
|
88
|
+
|
|
89
|
+
it('walks pages and compares handlers (sanity: extraction and both engines working)', () => {
|
|
90
|
+
// Floors well below current values (55 / 333 / 162 / 74) but far above
|
|
91
|
+
// zero: a broken walk, extractor, or engine bootstrap fails loudly here
|
|
92
|
+
// instead of making assertions 2-3 vacuously pass.
|
|
93
|
+
expect(result.pages).toBeGreaterThan(40);
|
|
94
|
+
expect(result.handlers).toBeGreaterThan(250);
|
|
95
|
+
expect(result.compared.length).toBeGreaterThan(120);
|
|
96
|
+
// Vacuous (empty-vs-empty) pairs are NOT parity evidence — the floor is on
|
|
97
|
+
// real, non-empty signature matches.
|
|
98
|
+
const realMatches = result.compared.filter(c => c.match && !c.vacuous).length;
|
|
99
|
+
expect(realMatches).toBeGreaterThan(60);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('has no NEW divergence from upstream outside the allowlist', () => {
|
|
103
|
+
const unexpected = result.compared.filter(c => !c.match && !allowed.has(c.key));
|
|
104
|
+
expect(
|
|
105
|
+
unexpected,
|
|
106
|
+
unexpected.length
|
|
107
|
+
? `\nShipped handlers whose DOM effect DIVERGES from the hyperscript.org engine ` +
|
|
108
|
+
`(fix the behavior, or allowlist with a family reason):\n` +
|
|
109
|
+
unexpected
|
|
110
|
+
.map(
|
|
111
|
+
f =>
|
|
112
|
+
` [${f.key}]\n` +
|
|
113
|
+
` "${f.excerpt}"\n` +
|
|
114
|
+
` hyperfixi: ${JSON.stringify(f.hyperfixiEffects).slice(0, 300)}\n` +
|
|
115
|
+
` upstream : ${JSON.stringify(f.upstreamEffects).slice(0, 300)}`
|
|
116
|
+
)
|
|
117
|
+
.join('\n') +
|
|
118
|
+
`\n\nTriage guidance: an EMPTY hyperfixi signature with a non-empty upstream one usually\n` +
|
|
119
|
+
`means hyperfixi silently dropped behavior (the #785 class). The reverse often means a\n` +
|
|
120
|
+
`deliberate hyperfixi extension or a jsdom limitation on the upstream side — check the\n` +
|
|
121
|
+
`existing family reasons in baselines/shipped-examples-execution.json before adding a new one.`
|
|
122
|
+
: ''
|
|
123
|
+
).toEqual([]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('has no stale allowlist entries (a now-converged handler must be removed so the list ratchets down)', () => {
|
|
127
|
+
const stillDiverging = new Set(result.compared.filter(c => !c.match).map(c => c.key));
|
|
128
|
+
const stale = allowlist.allowedDivergences.map(e => e.key).filter(k => !stillDiverging.has(k));
|
|
129
|
+
expect(
|
|
130
|
+
stale,
|
|
131
|
+
stale.length
|
|
132
|
+
? `\nThese allowlisted handlers no longer diverge (fixed, or edited — the key embeds a\n` +
|
|
133
|
+
`source hash; or no longer eligible, in which case the coverage loss should be deliberate).\n` +
|
|
134
|
+
`Remove them from baselines/shipped-examples-execution.json:\n ${stale.join('\n ')}`
|
|
135
|
+
: ''
|
|
136
|
+
).toEqual([]);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('harness pieces', () => {
|
|
141
|
+
it('extracts the trigger event from the leading on-clause', () => {
|
|
142
|
+
expect(triggerEventOf('on click add .a to me')).toBe('click');
|
|
143
|
+
expect(triggerEventOf(' on every click log me')).toBe('click');
|
|
144
|
+
expect(triggerEventOf("on keydown[key=='Escape'] from window hide .x")).toBe('keydown');
|
|
145
|
+
expect(triggerEventOf('on draggable:start add .drag')).toBe('draggable:start');
|
|
146
|
+
expect(triggerEventOf('on click or keyup toggle .a')).toBe('click');
|
|
147
|
+
expect(triggerEventOf('install Draggable')).toBeNull();
|
|
148
|
+
expect(triggerEventOf('init set x to 1')).toBeNull();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('keys embed the source hash, so an edited handler changes key', () => {
|
|
152
|
+
const a = keyFor({ file: 'f.html', source: 'on click add .a', event: 'click' });
|
|
153
|
+
const b = keyFor({ file: 'f.html', source: 'on click add .b', event: 'click' });
|
|
154
|
+
expect(a).not.toBe(b);
|
|
155
|
+
expect(a).toMatch(/^f\.html::[0-9a-f]{10}::click$/);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shipped-examples execution gate — upstream as the behavioral oracle
|
|
3
|
+
* -------------------------------------------------------------------
|
|
4
|
+
* Every parse-level gate in this repo went green while `native-dialog.html`
|
|
5
|
+
* shipped with a conditional body running unconditionally (#785), and again
|
|
6
|
+
* while five upstream-valid `if` shapes regressed on a PR branch (#786). The
|
|
7
|
+
* class they cannot see is BEHAVIOR: a handler that parses cleanly and then
|
|
8
|
+
* does the wrong thing on the page.
|
|
9
|
+
*
|
|
10
|
+
* This gate executes the handlers we actually ship. For each `_="…"` attribute
|
|
11
|
+
* in `examples/**`, the full page is loaded into two jsdom instances — one
|
|
12
|
+
* processed by hyperfixi (parse + a fresh Runtime with the element as context,
|
|
13
|
+
* the same shape the browser attribute processor uses), one by the real
|
|
14
|
+
* `hyperscript.org` engine (`processNode`) — the handler's trigger event is
|
|
15
|
+
* dispatched, and the resulting DOM effect signatures (../effect-signature.ts,
|
|
16
|
+
* shared with the R2 execution ratchet) are diffed against each other.
|
|
17
|
+
* Upstream plays the role R4 gives it for validity: the oracle. A divergence
|
|
18
|
+
* means hyperfixi's runtime behavior differs from upstream's ON A SHIPPED
|
|
19
|
+
* PAGE — exactly the #785/#786 failure mode, caught on behavior instead of by
|
|
20
|
+
* luck.
|
|
21
|
+
*
|
|
22
|
+
* ## Fair denominator (mirrors R4)
|
|
23
|
+
* A handler is only compared when BOTH engines accept its source: hyperfixi
|
|
24
|
+
* must compile it clean (`ok: true`, no recovered errors — recovering sources
|
|
25
|
+
* are the shipped-sources gate's business) and upstream must parse it (a
|
|
26
|
+
* hyperfixi-only extension has no oracle). It must also be deterministically
|
|
27
|
+
* executable in jsdom: triggered by a dispatchable event, free of network /
|
|
28
|
+
* timer / navigation constructs. Every exclusion is recorded with a reason —
|
|
29
|
+
* the skip list is part of the result, never silent (`no silent caps`).
|
|
30
|
+
*
|
|
31
|
+
* ## Execution model
|
|
32
|
+
* Per handler and engine: a FRESH jsdom of the whole page, every eligible
|
|
33
|
+
* handler installed (page-like — bubbling into sibling handlers stays real and
|
|
34
|
+
* symmetric), then ONLY the probed handler's event dispatched, with a
|
|
35
|
+
* before/after snapshot around it. See runHandlerOnEngine for why isolation is
|
|
36
|
+
* worth its cost.
|
|
37
|
+
*
|
|
38
|
+
* ## Node-only
|
|
39
|
+
* Imports the real `hyperscript.org` build off disk and swaps jsdom globals
|
|
40
|
+
* per handler execution (both engines resolve `document` lazily through
|
|
41
|
+
* globalThis — the same mechanism the R2 validator relies on). Cannot run in a
|
|
42
|
+
* browser suite, and under vitest REQUIRES the node environment (see the test
|
|
43
|
+
* file's docblock).
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import fs from 'node:fs';
|
|
47
|
+
import path from 'node:path';
|
|
48
|
+
import { createHash } from 'node:crypto';
|
|
49
|
+
import { createRequire } from 'node:module';
|
|
50
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
51
|
+
import { JSDOM } from 'jsdom';
|
|
52
|
+
import { snapshot, diffSnapshots } from './effect-signature';
|
|
53
|
+
|
|
54
|
+
/** Repo root, from `packages/testing-framework/src/multilingual/`. */
|
|
55
|
+
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..');
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Only `examples/**` — unlike the shipped-sources validity gate, execution
|
|
59
|
+
* needs the surrounding PAGE (the handler's selectors resolve against it), and
|
|
60
|
+
* doc snippets have none.
|
|
61
|
+
*/
|
|
62
|
+
const DEFAULT_ROOTS = ['examples'];
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Events the harness can dispatch deterministically. `on load` is excluded
|
|
66
|
+
* (fires through engine-specific initialization, not a dispatchable event);
|
|
67
|
+
* anything not listed is skipped with a reason.
|
|
68
|
+
*/
|
|
69
|
+
const SAFE_EVENTS = new Set([
|
|
70
|
+
'click',
|
|
71
|
+
'dblclick',
|
|
72
|
+
'mousedown',
|
|
73
|
+
'mouseup',
|
|
74
|
+
'mouseenter',
|
|
75
|
+
'mouseleave',
|
|
76
|
+
'mouseover',
|
|
77
|
+
'mouseout',
|
|
78
|
+
'pointerdown',
|
|
79
|
+
'pointerup',
|
|
80
|
+
'input',
|
|
81
|
+
'change',
|
|
82
|
+
'keydown',
|
|
83
|
+
'keyup',
|
|
84
|
+
'focus',
|
|
85
|
+
'blur',
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Constructs that make an execution nondeterministic in jsdom (network,
|
|
90
|
+
* timers, animation, navigation, module-level installs) — with, for each, the
|
|
91
|
+
* reason it is excluded. Matched against the whole source.
|
|
92
|
+
*/
|
|
93
|
+
const DISQUALIFIERS: Array<{ pattern: RegExp; reason: string }> = [
|
|
94
|
+
{ pattern: /\bfetch\b/, reason: 'network (fetch)' },
|
|
95
|
+
{ pattern: /\bwait\b/, reason: 'timers (wait)' },
|
|
96
|
+
{ pattern: /\bsettle\b/, reason: 'animation settling' },
|
|
97
|
+
{ pattern: /\btransition\b/, reason: 'animation (transition)' },
|
|
98
|
+
{ pattern: /\brepeat\s+forever\b/, reason: 'unbounded loop' },
|
|
99
|
+
{ pattern: /\bgo\s+to\s+url\b/i, reason: 'navigation' },
|
|
100
|
+
{ pattern: /\binstall\s/, reason: 'behavior install (registries differ by design)' },
|
|
101
|
+
{ pattern: /(^|\s)js(\s|\()/, reason: 'js interop block' },
|
|
102
|
+
{ pattern: /\beventsource\b/i, reason: 'SSE' },
|
|
103
|
+
{ pattern: /\bsocket\b/i, reason: 'WebSocket' },
|
|
104
|
+
{ pattern: /\bnavigator\b/, reason: 'browser API jsdom does not implement' },
|
|
105
|
+
{ pattern: /\bbeep!/, reason: 'debug construct' },
|
|
106
|
+
{
|
|
107
|
+
pattern: /\bnew Date\b|\bMath\.random\b|toLocaleTimeString|Date\.now/,
|
|
108
|
+
reason: 'nondeterministic value (time/random) — signatures would flake across the two runs',
|
|
109
|
+
},
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
/** A handler extracted from a shipped page. */
|
|
113
|
+
export interface ShippedHandler {
|
|
114
|
+
/** Repo-relative path of the page. */
|
|
115
|
+
file: string;
|
|
116
|
+
/** Document-order index among the page's `[_]` elements — the element's identity across jsdom instances. */
|
|
117
|
+
index: number;
|
|
118
|
+
/** The hyperscript source. */
|
|
119
|
+
source: string;
|
|
120
|
+
/** The trigger event parsed from the leading `on` clause, when any. */
|
|
121
|
+
event: string | null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface SkippedHandler extends ShippedHandler {
|
|
125
|
+
reason: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** One compared handler: both engines ran it; signatures either match or not. */
|
|
129
|
+
export interface ComparedHandler extends ShippedHandler {
|
|
130
|
+
/** Stable key: file + source hash + event. Fixing a source changes its key. */
|
|
131
|
+
key: string;
|
|
132
|
+
event: string;
|
|
133
|
+
hyperfixiEffects: string[];
|
|
134
|
+
upstreamEffects: string[];
|
|
135
|
+
match: boolean;
|
|
136
|
+
/**
|
|
137
|
+
* Both signatures empty. NOT evidence of parity — a genuinely effect-free
|
|
138
|
+
* handler (bare `halt`, a `log`) and a both-engines-broken handler look the
|
|
139
|
+
* same. Counted separately; never let vacuous pairs inflate the match count.
|
|
140
|
+
*/
|
|
141
|
+
vacuous: boolean;
|
|
142
|
+
/** Single-line excerpt, for reading the baseline without opening the file. */
|
|
143
|
+
excerpt: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface ExecutionParityResult {
|
|
147
|
+
/** Pages walked. */
|
|
148
|
+
pages: number;
|
|
149
|
+
/** Handlers found (before eligibility). */
|
|
150
|
+
handlers: number;
|
|
151
|
+
/** Handlers executed on both engines. */
|
|
152
|
+
compared: ComparedHandler[];
|
|
153
|
+
/** Handlers excluded, each with its reason. */
|
|
154
|
+
skipped: SkippedHandler[];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Stable key for one handler execution. */
|
|
158
|
+
export function keyFor(h: { file: string; source: string; event: string }): string {
|
|
159
|
+
const hash = createHash('sha1').update(h.source).digest('hex').slice(0, 10);
|
|
160
|
+
return `${h.file}::${hash}::${h.event}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** First `on <event>` name, if the source is an event handler. */
|
|
164
|
+
export function triggerEventOf(source: string): string | null {
|
|
165
|
+
// `on every click`, `on click[filter]`, `on click from #x`, `on click or keyup`
|
|
166
|
+
// all yield their first event name; modifiers/filters are dropped.
|
|
167
|
+
const m = source.match(/^\s*on\s+(?:every\s+)?([\w-]+(?::[\w-]+)?)/);
|
|
168
|
+
return m ? (m[1] ?? null) : null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function walkHtml(dir: string, acc: string[] = []): string[] {
|
|
172
|
+
let entries: fs.Dirent[];
|
|
173
|
+
try {
|
|
174
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
175
|
+
} catch {
|
|
176
|
+
return acc;
|
|
177
|
+
}
|
|
178
|
+
for (const entry of entries) {
|
|
179
|
+
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name.startsWith('.')) {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const full = path.join(dir, entry.name);
|
|
183
|
+
if (entry.isDirectory()) walkHtml(full, acc);
|
|
184
|
+
else if (entry.name.endsWith('.html')) acc.push(full);
|
|
185
|
+
}
|
|
186
|
+
return acc;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Extract the page's `[_]` handlers in document order. */
|
|
190
|
+
export function extractHandlers(file: string, html: string): ShippedHandler[] {
|
|
191
|
+
const dom = new JSDOM(html);
|
|
192
|
+
const out: ShippedHandler[] = [];
|
|
193
|
+
dom.window.document.querySelectorAll('[_]').forEach((el: Element, index: number) => {
|
|
194
|
+
const source = el.getAttribute('_') ?? '';
|
|
195
|
+
out.push({ file, index, source, event: triggerEventOf(source) });
|
|
196
|
+
});
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Minimal engine surfaces, injected by `initEngines`. */
|
|
201
|
+
export interface Engines {
|
|
202
|
+
/** hyperfixi compile check (fair-denominator side 1). */
|
|
203
|
+
compileClean(source: string): boolean;
|
|
204
|
+
/** upstream parse check (fair-denominator side 2). Returns error strings; [] = valid. */
|
|
205
|
+
upstreamErrors(source: string): string[];
|
|
206
|
+
/** Install a handler on an element via hyperfixi (the public eval surface). */
|
|
207
|
+
hyperfixiInstall(source: string, el: Element): Promise<void>;
|
|
208
|
+
/** Install a handler on an element via the upstream engine. */
|
|
209
|
+
upstreamInstall(el: Element): void;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Keys this module has installed onto globalThis from a jsdom window. Tracked
|
|
214
|
+
* so every later `installGlobals` RE-points them at the new page's window —
|
|
215
|
+
* merely skipping keys that already exist left every DOM constructor
|
|
216
|
+
* (`HTMLElement`, `Element`, …) bound to the FIRST page forever, and both
|
|
217
|
+
* engines' `instanceof` checks then silently failed for later pages' elements
|
|
218
|
+
* (measured: upstream produced empty signatures for `on click put 'Hello' into
|
|
219
|
+
* #output` — the simplest possible handler — on every page after the first).
|
|
220
|
+
*/
|
|
221
|
+
const installedGlobalKeys = new Set<string>();
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Point the process globals at a page's jsdom (jsdom-global style: everything
|
|
225
|
+
* the window owns that node does not, plus the DOM-critical names node has
|
|
226
|
+
* opinions about). Both engines resolve `document` lazily through globalThis,
|
|
227
|
+
* so this is what "switching pages" means.
|
|
228
|
+
*/
|
|
229
|
+
export function installGlobals(dom: JSDOM): void {
|
|
230
|
+
const g = globalThis as Record<string, unknown>;
|
|
231
|
+
const win = dom.window as unknown as Record<string, unknown>;
|
|
232
|
+
for (const key of Object.getOwnPropertyNames(win)) {
|
|
233
|
+
if (key in globalThis && !installedGlobalKeys.has(key)) continue; // node's own — leave it
|
|
234
|
+
try {
|
|
235
|
+
g[key] = win[key];
|
|
236
|
+
installedGlobalKeys.add(key);
|
|
237
|
+
} catch {
|
|
238
|
+
/* read-only */
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
for (const key of ['window', 'document', 'Event', 'CustomEvent', 'navigator']) {
|
|
242
|
+
try {
|
|
243
|
+
Object.defineProperty(g, key, {
|
|
244
|
+
value: key === 'window' ? dom.window : win[key],
|
|
245
|
+
configurable: true,
|
|
246
|
+
writable: true,
|
|
247
|
+
});
|
|
248
|
+
installedGlobalKeys.add(key);
|
|
249
|
+
} catch {
|
|
250
|
+
/* read-only */
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Errors thrown inside dispatched listeners surface as unhandled rejections
|
|
257
|
+
* (handlers are async) and would crash the sweep. Trapped, not asserted: a
|
|
258
|
+
* runtime error that damages behavior diverges in its effect signature, which
|
|
259
|
+
* is the comparison — same stance as the R2 validator.
|
|
260
|
+
*/
|
|
261
|
+
let rejectionTrapInstalled = false;
|
|
262
|
+
function installRejectionTrap(): void {
|
|
263
|
+
if (rejectionTrapInstalled) return;
|
|
264
|
+
process.on('unhandledRejection', () => {
|
|
265
|
+
/* swallowed — the effect signature is the assertion */
|
|
266
|
+
});
|
|
267
|
+
rejectionTrapInstalled = true;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Load both engines ONCE, after bootstrapping jsdom globals (both packages
|
|
272
|
+
* touch DOM constructors at module evaluation). Callers then swap pages via
|
|
273
|
+
* `installGlobals`.
|
|
274
|
+
*/
|
|
275
|
+
export async function initEngines(): Promise<Engines> {
|
|
276
|
+
installRejectionTrap();
|
|
277
|
+
installGlobals(new JSDOM('<!doctype html><html><body></body></html>'));
|
|
278
|
+
|
|
279
|
+
// Install goes through parse + a FRESH Runtime per handler with the element
|
|
280
|
+
// as context — the same shape the browser attribute processor uses, and the
|
|
281
|
+
// same shape the R2 validator proved stable across per-page document swaps.
|
|
282
|
+
// (The api singleton `hyperscript.eval` binds document-dependent state at
|
|
283
|
+
// first use, which is correct in a browser — document identity never changes
|
|
284
|
+
// within a realm — but silently resolves later PAGES' selectors against the
|
|
285
|
+
// first page here. Measured: a two-page probe no-opped page 2's toggle.)
|
|
286
|
+
const core = (await import('@hyperfixi/core')) as unknown as {
|
|
287
|
+
hyperscript: {
|
|
288
|
+
compileSync(code: string): { ok: boolean; errors?: Array<{ message: string }> };
|
|
289
|
+
};
|
|
290
|
+
parse(code: string): { success: boolean; node?: unknown };
|
|
291
|
+
Runtime: new () => { execute(ast: unknown, ctx: unknown): Promise<unknown> };
|
|
292
|
+
createContext(el: HTMLElement): unknown;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const require = createRequire(import.meta.url);
|
|
296
|
+
const esm = path.join(path.dirname(require.resolve('hyperscript.org')), '_hyperscript.esm.js');
|
|
297
|
+
const hs = (await import(pathToFileURL(esm).href)).default as {
|
|
298
|
+
parse(src: string): { errors?: Array<{ message: string }> };
|
|
299
|
+
processNode(el: Node): void;
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
compileClean(source) {
|
|
304
|
+
try {
|
|
305
|
+
const r = core.hyperscript.compileSync(source);
|
|
306
|
+
return r.ok && (r.errors ?? []).length === 0;
|
|
307
|
+
} catch {
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
upstreamErrors(source) {
|
|
312
|
+
try {
|
|
313
|
+
return (hs.parse(source)?.errors ?? []).map(e => e.message);
|
|
314
|
+
} catch (e) {
|
|
315
|
+
return ['threw: ' + (e as Error).message.split('\n')[0]];
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
async hyperfixiInstall(source, el) {
|
|
319
|
+
const parsed = core.parse(source);
|
|
320
|
+
if (!parsed.success || !parsed.node) {
|
|
321
|
+
throw new Error('parse failed at install (eligibility should have caught this)');
|
|
322
|
+
}
|
|
323
|
+
const runtime = new core.Runtime();
|
|
324
|
+
await runtime.execute(parsed.node, core.createContext(el as HTMLElement));
|
|
325
|
+
},
|
|
326
|
+
upstreamInstall(el) {
|
|
327
|
+
hs.processNode(el);
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Drain micro/macrotasks after a dispatch (same window the R2 validator uses). */
|
|
333
|
+
const SETTLE_MS = 20;
|
|
334
|
+
const settle = () => new Promise(r => setTimeout(r, SETTLE_MS));
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Execute ONE handler on one engine, in ISOLATION: a fresh jsdom of the page,
|
|
338
|
+
* every eligible handler installed (as on a real page — bubbling into sibling
|
|
339
|
+
* handlers stays page-like and symmetric across engines), then ONLY the probed
|
|
340
|
+
* handler's event dispatched, with a before/after snapshot around it.
|
|
341
|
+
*
|
|
342
|
+
* Isolation is deliberate, and worth its jsdom-per-handler cost: a sequential
|
|
343
|
+
* dispatch-them-all model was measured amplifying ONE real divergence into a
|
|
344
|
+
* page's worth of cascades (upstream's inert element-`decrement` made the
|
|
345
|
+
* following `put 0 into #count` write 0-over-0 — invisible — so every later
|
|
346
|
+
* handler on the page diverged too), and letting double-failures hide as
|
|
347
|
+
* empty-vs-empty "matches". Fresh state per handler makes each comparison
|
|
348
|
+
* independently triageable.
|
|
349
|
+
*
|
|
350
|
+
* Runtime errors do not abort — the effect signature is the comparison, and an
|
|
351
|
+
* error that damages behavior diverges in its effects.
|
|
352
|
+
*/
|
|
353
|
+
async function runHandlerOnEngine(
|
|
354
|
+
html: string,
|
|
355
|
+
eligible: Array<ShippedHandler & { event: string }>,
|
|
356
|
+
target: ShippedHandler & { event: string },
|
|
357
|
+
install: (h: ShippedHandler & { event: string }, el: Element) => Promise<void> | void
|
|
358
|
+
): Promise<string[]> {
|
|
359
|
+
// No pretendToBeVisual: nothing eligible needs rAF (animation constructs are
|
|
360
|
+
// disqualified), and its frame timer would keep node alive after the sweep.
|
|
361
|
+
const dom = new JSDOM(html);
|
|
362
|
+
try {
|
|
363
|
+
installGlobals(dom);
|
|
364
|
+
const doc = dom.window.document;
|
|
365
|
+
const els = doc.querySelectorAll('[_]');
|
|
366
|
+
|
|
367
|
+
// Stamp identity keys BEFORE anything runs: same page → same stamping on
|
|
368
|
+
// both engines, so snapshot keys are the element's identity and an
|
|
369
|
+
// engine-inserted node cannot shift them (see snapshot()).
|
|
370
|
+
doc.body
|
|
371
|
+
.querySelectorAll('*')
|
|
372
|
+
.forEach((el: Element, i: number) => el.setAttribute('data-exec-key', String(i)));
|
|
373
|
+
|
|
374
|
+
for (const h of eligible) {
|
|
375
|
+
const el = els[h.index];
|
|
376
|
+
if (!el) continue;
|
|
377
|
+
try {
|
|
378
|
+
await install(h, el);
|
|
379
|
+
} catch {
|
|
380
|
+
/* recorded via the empty signature */
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const el = els[target.index];
|
|
385
|
+
if (!el) return ['<element not found>'];
|
|
386
|
+
if ((target.event === 'input' || target.event === 'change') && 'value' in el) {
|
|
387
|
+
(el as HTMLInputElement).value = 'test';
|
|
388
|
+
}
|
|
389
|
+
const before = snapshot(doc);
|
|
390
|
+
el.dispatchEvent(new dom.window.Event(target.event, { bubbles: true, cancelable: true }));
|
|
391
|
+
await settle();
|
|
392
|
+
return diffSnapshots(before, snapshot(doc));
|
|
393
|
+
} finally {
|
|
394
|
+
// Release the page's timers/listeners so the process can exit; the next
|
|
395
|
+
// page (or the caller's bootstrap dom) re-points the globals.
|
|
396
|
+
dom.window.close();
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* The sweep: walk `examples/**`, extract handlers, apply the fair-denominator
|
|
402
|
+
* filters (each skip reasoned), and execute every eligible handler on both
|
|
403
|
+
* engines.
|
|
404
|
+
*/
|
|
405
|
+
export async function runShippedExamplesExecution(opts?: {
|
|
406
|
+
roots?: string[];
|
|
407
|
+
repoRoot?: string;
|
|
408
|
+
engines?: Engines;
|
|
409
|
+
}): Promise<ExecutionParityResult> {
|
|
410
|
+
const repoRoot = opts?.repoRoot ?? REPO_ROOT;
|
|
411
|
+
const roots = opts?.roots ?? DEFAULT_ROOTS;
|
|
412
|
+
const engines = opts?.engines ?? (await initEngines());
|
|
413
|
+
|
|
414
|
+
const compared: ComparedHandler[] = [];
|
|
415
|
+
const skipped: SkippedHandler[] = [];
|
|
416
|
+
let pages = 0;
|
|
417
|
+
let handlers = 0;
|
|
418
|
+
|
|
419
|
+
for (const root of roots) {
|
|
420
|
+
for (const full of walkHtml(path.join(repoRoot, root))) {
|
|
421
|
+
const rel = path.relative(repoRoot, full);
|
|
422
|
+
const html = fs.readFileSync(full, 'utf8');
|
|
423
|
+
const pageHandlers = extractHandlers(rel, html);
|
|
424
|
+
if (pageHandlers.length === 0) continue;
|
|
425
|
+
pages++;
|
|
426
|
+
handlers += pageHandlers.length;
|
|
427
|
+
|
|
428
|
+
const eligible: Array<ShippedHandler & { event: string }> = [];
|
|
429
|
+
for (const h of pageHandlers) {
|
|
430
|
+
if (!h.event) {
|
|
431
|
+
skipped.push({ ...h, reason: 'not an `on <event>` handler' });
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (!SAFE_EVENTS.has(h.event)) {
|
|
435
|
+
skipped.push({ ...h, reason: `event not dispatchable deterministically (${h.event})` });
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
const disq = DISQUALIFIERS.find(d => d.pattern.test(h.source));
|
|
439
|
+
if (disq) {
|
|
440
|
+
skipped.push({ ...h, reason: disq.reason });
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
if (!engines.compileClean(h.source)) {
|
|
444
|
+
skipped.push({
|
|
445
|
+
...h,
|
|
446
|
+
reason: 'hyperfixi does not compile it clean (shipped-sources gate territory)',
|
|
447
|
+
});
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
const upstreamErrs = engines.upstreamErrors(h.source);
|
|
451
|
+
if (upstreamErrs.length > 0) {
|
|
452
|
+
skipped.push({ ...h, reason: `upstream rejects it (no oracle): ${upstreamErrs[0]}` });
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
eligible.push(h as ShippedHandler & { event: string });
|
|
456
|
+
}
|
|
457
|
+
if (eligible.length === 0) continue;
|
|
458
|
+
|
|
459
|
+
// Both engines log runtime errors to the console during dispatch
|
|
460
|
+
// (COMMAND FAILED etc.). That is expected data here — the effect
|
|
461
|
+
// signature carries the consequence — so silence the console for the
|
|
462
|
+
// engine runs only, restoring even on a throw.
|
|
463
|
+
const saved = {
|
|
464
|
+
log: console.log,
|
|
465
|
+
warn: console.warn,
|
|
466
|
+
error: console.error,
|
|
467
|
+
debug: console.debug,
|
|
468
|
+
};
|
|
469
|
+
const noop = () => {};
|
|
470
|
+
console.log = console.warn = console.error = console.debug = noop;
|
|
471
|
+
try {
|
|
472
|
+
for (const h of eligible) {
|
|
473
|
+
const ours = await runHandlerOnEngine(html, eligible, h, (hh, el) =>
|
|
474
|
+
engines.hyperfixiInstall(hh.source, el)
|
|
475
|
+
);
|
|
476
|
+
const theirs = await runHandlerOnEngine(html, eligible, h, (_hh, el) =>
|
|
477
|
+
engines.upstreamInstall(el)
|
|
478
|
+
);
|
|
479
|
+
compared.push({
|
|
480
|
+
...h,
|
|
481
|
+
key: keyFor(h),
|
|
482
|
+
hyperfixiEffects: ours,
|
|
483
|
+
upstreamEffects: theirs,
|
|
484
|
+
match: JSON.stringify(ours) === JSON.stringify(theirs),
|
|
485
|
+
vacuous: ours.length === 0 && theirs.length === 0,
|
|
486
|
+
excerpt: h.source.replace(/\s+/g, ' ').trim().slice(0, 100),
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
} finally {
|
|
490
|
+
console.log = saved.log;
|
|
491
|
+
console.warn = saved.warn;
|
|
492
|
+
console.error = saved.error;
|
|
493
|
+
console.debug = saved.debug;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return { pages, handlers, compared, skipped };
|
|
499
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shipped-sources validity gate (see shipped-sources-validity.ts for the why).
|
|
3
|
+
*
|
|
4
|
+
* Compiles every hyperscript source we ship in `examples/` and the doc trees,
|
|
5
|
+
* and ratchets on the recovers-with-errors state (`ok: true` with a non-empty
|
|
6
|
+
* `errors`). Three assertions, matching the canonical-validity gates:
|
|
7
|
+
* 1. sanity — sources were actually found and mostly compile clean;
|
|
8
|
+
* 2. no NEW recovering source appears outside the committed allowlist;
|
|
9
|
+
* 3. no allowlisted key has silently become clean (stale entries must be
|
|
10
|
+
* removed so the list only ever shrinks).
|
|
11
|
+
*
|
|
12
|
+
* To update after an intentional fix: re-run and rewrite
|
|
13
|
+
* `baselines/shipped-sources-validity.json`. Note the allowlist key embeds a
|
|
14
|
+
* hash of the source, so FIXING a source changes its key — the entry goes
|
|
15
|
+
* stale and assertion 3 makes removing it mandatory.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { JSDOM } from 'jsdom';
|
|
22
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
23
|
+
import {
|
|
24
|
+
checkShippedSourcesValidity,
|
|
25
|
+
type ShippedSourcesResult,
|
|
26
|
+
type CompileForValidity,
|
|
27
|
+
} from './shipped-sources-validity';
|
|
28
|
+
|
|
29
|
+
interface AllowlistDoc {
|
|
30
|
+
allowedRecovered: Array<{
|
|
31
|
+
key: string;
|
|
32
|
+
file: string;
|
|
33
|
+
error: string;
|
|
34
|
+
upstream: string;
|
|
35
|
+
reason: string;
|
|
36
|
+
}>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const baselinePath = path.resolve(
|
|
40
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
41
|
+
'../../baselines/shipped-sources-validity.json'
|
|
42
|
+
);
|
|
43
|
+
const allowlist = JSON.parse(readFileSync(baselinePath, 'utf8')) as AllowlistDoc;
|
|
44
|
+
const allowed = new Set(allowlist.allowedRecovered.map(e => e.key));
|
|
45
|
+
|
|
46
|
+
describe('shipped-sources validity gate', () => {
|
|
47
|
+
let result: ShippedSourcesResult;
|
|
48
|
+
|
|
49
|
+
beforeAll(async () => {
|
|
50
|
+
// Import core through its built entry, the same surface a consumer gets.
|
|
51
|
+
const core = (await import('@hyperfixi/core')) as unknown as {
|
|
52
|
+
hyperscript: { compileSync: CompileForValidity };
|
|
53
|
+
};
|
|
54
|
+
const dom = new JSDOM('<!doctype html><html><body></body></html>');
|
|
55
|
+
result = checkShippedSourcesValidity(
|
|
56
|
+
code => core.hyperscript.compileSync(code),
|
|
57
|
+
dom.window.document as unknown as Parameters<typeof checkShippedSourcesValidity>[1]
|
|
58
|
+
);
|
|
59
|
+
}, 120_000);
|
|
60
|
+
|
|
61
|
+
it('finds and compiles the shipped sources (sanity: trees walked, extraction working)', () => {
|
|
62
|
+
// Guards the silent-zero failure mode: a broken walk or extractor would
|
|
63
|
+
// make every other assertion vacuously pass.
|
|
64
|
+
expect(result.checked).toBeGreaterThan(100);
|
|
65
|
+
expect(result.clean).toBeGreaterThan(100);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('has no NEW source that parses with recovered errors outside the allowlist', () => {
|
|
69
|
+
const unexpected = result.findings.filter(f => !allowed.has(f.key));
|
|
70
|
+
expect(
|
|
71
|
+
unexpected,
|
|
72
|
+
unexpected.length
|
|
73
|
+
? `\nNew shipped sources that parse ok:true WITH errors (fix the source, or allowlist with a reason):\n` +
|
|
74
|
+
unexpected
|
|
75
|
+
.map(f => ` [${f.key}]\n "${f.excerpt}"\n -> ${f.error}`)
|
|
76
|
+
.join('\n') +
|
|
77
|
+
`\n\nAsk the real hyperscript.org engine for a second opinion before deciding:\n` +
|
|
78
|
+
`upstream REJECTING means the source is malformed; upstream ACCEPTING means this is a hyperfixi parser defect.\n` +
|
|
79
|
+
`See loadCanonicalParser() in canonical-validity.ts.`
|
|
80
|
+
: ''
|
|
81
|
+
).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('has no stale allowlist entries (a now-clean source must be removed so the list ratchets down)', () => {
|
|
85
|
+
const stillFailing = new Set(result.findings.map(f => f.key));
|
|
86
|
+
const stale = allowlist.allowedRecovered.map(e => e.key).filter(key => !stillFailing.has(key));
|
|
87
|
+
expect(
|
|
88
|
+
stale,
|
|
89
|
+
stale.length
|
|
90
|
+
? `\nThese allowlisted sources no longer recover-with-errors (fixed, or edited — the key embeds a source hash).\n` +
|
|
91
|
+
`Remove them from baselines/shipped-sources-validity.json:\n ${stale.join('\n ')}`
|
|
92
|
+
: ''
|
|
93
|
+
).toEqual([]);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shipped-sources validity gate
|
|
3
|
+
* -----------------------------
|
|
4
|
+
* Every hyperscript source we ship in `examples/` and the doc trees, parsed
|
|
5
|
+
* with hyperfixi, must not land in the *recovers-with-errors* state:
|
|
6
|
+
* `ok === true` with a non-empty `errors`.
|
|
7
|
+
*
|
|
8
|
+
* That state is the blind spot this gate exists for. The parser is
|
|
9
|
+
* deliberately resilient — it recovers from some malformed input and returns a
|
|
10
|
+
* usable-but-degraded AST plus diagnostics — so genuinely broken sources
|
|
11
|
+
* neither throw nor report `ok: false`. Nothing in the existing suites looks at
|
|
12
|
+
* the combination, which is how five malformed examples shipped undetected
|
|
13
|
+
* (an `if` with no `end`, a `put` with no target, `{id}` where `${id}` was
|
|
14
|
+
* meant, and a `*--css-var` neither engine supports). The same sweep is what
|
|
15
|
+
* caught the `send`-vs-`trigger` divergence and the `parseTriggerCommand` hang
|
|
16
|
+
* in #780, neither of which was reachable from the existing tests.
|
|
17
|
+
*
|
|
18
|
+
* Deliberately NOT gated: `ok === false`. That is a much larger and mostly
|
|
19
|
+
* legitimate class — non-English sources (which need the multilingual path,
|
|
20
|
+
* not `compileSync`), plugin syntax whose feature is not installed, and
|
|
21
|
+
* intentionally-broken "this does not work" doc snippets. Gating it would
|
|
22
|
+
* drown the signal. A source that fails outright is also loud; one that
|
|
23
|
+
* silently recovers is not, and that asymmetry is the whole point.
|
|
24
|
+
*
|
|
25
|
+
* Node-only: the second-opinion path needs `loadCanonicalParser()`, which
|
|
26
|
+
* imports the real `hyperscript.org` build off disk. Cannot run in a browser
|
|
27
|
+
* suite.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import fs from 'node:fs';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
import { createHash } from 'node:crypto';
|
|
33
|
+
import { fileURLToPath } from 'node:url';
|
|
34
|
+
import { extractHyperscriptFromMarkup } from '@hyperfixi/patterns-reference';
|
|
35
|
+
|
|
36
|
+
/** Repo root, from `packages/testing-framework/src/multilingual/`. */
|
|
37
|
+
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..');
|
|
38
|
+
|
|
39
|
+
/** Trees whose hyperscript we ship to users. */
|
|
40
|
+
const DEFAULT_ROOTS = ['examples', 'docs', 'packages/core/docs'];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Paths excluded from the sweep, each with the reason it is not a shipped
|
|
44
|
+
* source. Keep this list short and justified — every entry is coverage lost.
|
|
45
|
+
*/
|
|
46
|
+
const EXCLUDED = [
|
|
47
|
+
{
|
|
48
|
+
match: (rel: string) => rel.includes(`${path.sep}archive${path.sep}`),
|
|
49
|
+
reason: 'historical phase/summary docs, not shipped guidance',
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
export interface ShippedSource {
|
|
54
|
+
/** Repo-relative path of the file the source came from. */
|
|
55
|
+
file: string;
|
|
56
|
+
/** How it was extracted, for triage. */
|
|
57
|
+
kind: 'html-attribute' | 'markdown-html-block';
|
|
58
|
+
/** The hyperscript source itself. */
|
|
59
|
+
source: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ShippedSourceFinding extends ShippedSource {
|
|
63
|
+
/** Stable allowlist key: file plus a hash of the source. */
|
|
64
|
+
key: string;
|
|
65
|
+
/** First hyperfixi diagnostic. */
|
|
66
|
+
error: string;
|
|
67
|
+
/** Single-line excerpt, for reading the baseline without opening the file. */
|
|
68
|
+
excerpt: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ShippedSourcesResult {
|
|
72
|
+
/** Sources extracted and compiled. */
|
|
73
|
+
checked: number;
|
|
74
|
+
/** Sources that compiled with no diagnostics. */
|
|
75
|
+
clean: number;
|
|
76
|
+
/** Sources in the recovers-with-errors state. */
|
|
77
|
+
findings: ShippedSourceFinding[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Minimal shape of `hyperscript.compileSync`, injected so this stays testable. */
|
|
81
|
+
export type CompileForValidity = (code: string) => {
|
|
82
|
+
ok: boolean;
|
|
83
|
+
errors?: Array<{ message: string }>;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A stable key for an individual snippet.
|
|
88
|
+
*
|
|
89
|
+
* Hashing the source (rather than using its index in the file) means the key
|
|
90
|
+
* survives reordering, and — the point — CHANGES when the snippet is fixed, so
|
|
91
|
+
* a stale allowlist entry cannot silently keep covering a source that has been
|
|
92
|
+
* edited. The stale-entry test turns that into a hard failure.
|
|
93
|
+
*/
|
|
94
|
+
function keyFor(file: string, source: string): string {
|
|
95
|
+
return `${file}::${createHash('sha1').update(source).digest('hex').slice(0, 10)}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function walk(dir: string, acc: string[] = []): string[] {
|
|
99
|
+
let entries: fs.Dirent[];
|
|
100
|
+
try {
|
|
101
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
102
|
+
} catch {
|
|
103
|
+
return acc;
|
|
104
|
+
}
|
|
105
|
+
for (const entry of entries) {
|
|
106
|
+
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name.startsWith('.')) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const full = path.join(dir, entry.name);
|
|
110
|
+
if (entry.isDirectory()) walk(full, acc);
|
|
111
|
+
else if (/\.(html|md)$/.test(entry.name)) acc.push(full);
|
|
112
|
+
}
|
|
113
|
+
return acc;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Collect every hyperscript source we ship.
|
|
118
|
+
*
|
|
119
|
+
* HTML files contribute their `_=` / `hx-live` attributes and
|
|
120
|
+
* `<script type="text/hyperscript">` bodies, read through the DOM (see
|
|
121
|
+
* `extractHyperscriptFromMarkup` for why not a regex). Markdown contributes
|
|
122
|
+
* the same, extracted from its fenced ```html blocks.
|
|
123
|
+
*
|
|
124
|
+
* Bare fenced ```hyperscript blocks are deliberately NOT collected: in this
|
|
125
|
+
* repo they are overwhelmingly syntax *notation* rather than code
|
|
126
|
+
* (`send <event> to <target>`, `copy <source> as <format>`), which no parser
|
|
127
|
+
* can accept and which it would be wrong to demand parses. The sources that
|
|
128
|
+
* actually run on a page are the attributes.
|
|
129
|
+
*/
|
|
130
|
+
export function collectShippedSources(
|
|
131
|
+
doc: Parameters<typeof extractHyperscriptFromMarkup>[0],
|
|
132
|
+
opts?: { roots?: string[]; repoRoot?: string }
|
|
133
|
+
): ShippedSource[] {
|
|
134
|
+
const repoRoot = opts?.repoRoot ?? REPO_ROOT;
|
|
135
|
+
const roots = opts?.roots ?? DEFAULT_ROOTS;
|
|
136
|
+
const out: ShippedSource[] = [];
|
|
137
|
+
|
|
138
|
+
for (const root of roots) {
|
|
139
|
+
for (const full of walk(path.join(repoRoot, root))) {
|
|
140
|
+
const rel = path.relative(repoRoot, full);
|
|
141
|
+
if (EXCLUDED.some(e => e.match(rel))) continue;
|
|
142
|
+
const text = fs.readFileSync(full, 'utf8');
|
|
143
|
+
|
|
144
|
+
if (full.endsWith('.html')) {
|
|
145
|
+
for (const source of extractHyperscriptFromMarkup(doc, text).snippets) {
|
|
146
|
+
out.push({ file: rel, kind: 'html-attribute', source });
|
|
147
|
+
}
|
|
148
|
+
} else {
|
|
149
|
+
for (const block of text.matchAll(/```html\n([\s\S]*?)```/g)) {
|
|
150
|
+
for (const source of extractHyperscriptFromMarkup(doc, block[1] ?? '').snippets) {
|
|
151
|
+
out.push({ file: rel, kind: 'markdown-html-block', source });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Compile every shipped source and report the ones that recovered with errors.
|
|
162
|
+
*/
|
|
163
|
+
export function checkShippedSourcesValidity(
|
|
164
|
+
compile: CompileForValidity,
|
|
165
|
+
doc: Parameters<typeof extractHyperscriptFromMarkup>[0],
|
|
166
|
+
opts?: { roots?: string[]; repoRoot?: string }
|
|
167
|
+
): ShippedSourcesResult {
|
|
168
|
+
const sources = collectShippedSources(doc, opts);
|
|
169
|
+
const findings: ShippedSourceFinding[] = [];
|
|
170
|
+
let clean = 0;
|
|
171
|
+
|
|
172
|
+
for (const s of sources) {
|
|
173
|
+
let result: ReturnType<CompileForValidity>;
|
|
174
|
+
try {
|
|
175
|
+
result = compile(s.source);
|
|
176
|
+
} catch {
|
|
177
|
+
// A throw is the loud failure mode, not this gate's business.
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const errors = result.errors ?? [];
|
|
181
|
+
if (result.ok && errors.length > 0) {
|
|
182
|
+
findings.push({
|
|
183
|
+
...s,
|
|
184
|
+
key: keyFor(s.file, s.source),
|
|
185
|
+
error: errors[0]?.message ?? 'recovered with errors',
|
|
186
|
+
excerpt: s.source.replace(/\s+/g, ' ').trim().slice(0, 100),
|
|
187
|
+
});
|
|
188
|
+
} else if (result.ok) {
|
|
189
|
+
clean++;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return { checked: sources.length, clean, findings };
|
|
194
|
+
}
|
|
@@ -339,8 +339,10 @@ describe('R2 execution validator (lock)', () => {
|
|
|
339
339
|
// The three wave-9 additions. Each en reference must produce a non-empty,
|
|
340
340
|
// deterministic signature against the existing fixture. The `*opacity`
|
|
341
341
|
// hide/show STRATEGIES are synchronous (no timer): hide writes display:none
|
|
342
|
-
//
|
|
343
|
-
//
|
|
342
|
+
// on #btn (its data-original-display memo is engine bookkeeping, excluded
|
|
343
|
+
// from signatures since the shared effect-signature module — see
|
|
344
|
+
// ENGINE_ATTRS there); show adds the visibility class on #modal.
|
|
345
|
+
// chained-access-possessive-dot writes the parent (.card) display.
|
|
344
346
|
const cases: ReadonlyArray<[string, string, string[]]> = [
|
|
345
347
|
[
|
|
346
348
|
'chained-access-possessive-dot',
|
|
@@ -350,7 +352,7 @@ describe('R2 execution validator (lock)', () => {
|
|
|
350
352
|
[
|
|
351
353
|
'hide-with-transition',
|
|
352
354
|
'on click hide me with *opacity',
|
|
353
|
-
['Δ#btn cls[] attr[
|
|
355
|
+
['Δ#btn cls[] attr[id=btn] style[display: none;] text[Click]'],
|
|
354
356
|
],
|
|
355
357
|
[
|
|
356
358
|
'show-with-transition',
|
|
@@ -31,6 +31,7 @@ import { JSDOM } from 'jsdom';
|
|
|
31
31
|
import { parseSemantic, buildAST } from '@lokascript/semantic';
|
|
32
32
|
import { getAllPatterns, getTranslationsByLanguage } from '@hyperfixi/patterns-reference';
|
|
33
33
|
import type { LanguageCode } from '../types';
|
|
34
|
+
import { snapshot, diffSnapshots } from '../effect-signature';
|
|
34
35
|
|
|
35
36
|
/**
|
|
36
37
|
* The curated execution subset: simple, deterministic, fixture-friendly
|
|
@@ -287,53 +288,12 @@ export interface ExecutionResult {
|
|
|
287
288
|
executionFidelity?: number;
|
|
288
289
|
}
|
|
289
290
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
const attrs = Array.from(el.attributes)
|
|
297
|
-
.filter(a => a.name !== 'class' && a.name !== 'style')
|
|
298
|
-
.map(a => `${a.name}=${a.value}`)
|
|
299
|
-
.sort()
|
|
300
|
-
.join(',');
|
|
301
|
-
const classes = Array.from(el.classList).sort().join(' ');
|
|
302
|
-
const style = (el as HTMLElement).getAttribute('style') ?? '';
|
|
303
|
-
// Leaf text only — container text would duplicate every child mutation.
|
|
304
|
-
const text =
|
|
305
|
-
el.childNodes.length === 0 || (el.childNodes.length === 1 && el.firstChild?.nodeType === 3)
|
|
306
|
-
? (el.textContent ?? '')
|
|
307
|
-
: '';
|
|
308
|
-
return `cls[${classes}] attr[${attrs}] style[${style}] text[${text}]`;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
function snapshot(document: Document): Map<string, string> {
|
|
312
|
-
const out = new Map<string, string>();
|
|
313
|
-
// body participates under its own stable key: body-targeted effects
|
|
314
|
-
// (`add .modal-open to body`, modal-open/modal-close-button) must be
|
|
315
|
-
// visible in the signature now that the runtime resolves `body`. Before
|
|
316
|
-
// wave 3b these writes fell back to `me` (visible by accident); a correct
|
|
317
|
-
// body write was invisible and a dropped one unscoreable.
|
|
318
|
-
out.set('body', serializeElement(document.body));
|
|
319
|
-
document.body.querySelectorAll('*').forEach((el, i) => {
|
|
320
|
-
const key = el.id ? `#${el.id}` : `${el.tagName.toLowerCase()}[${i}]`;
|
|
321
|
-
out.set(key, serializeElement(el));
|
|
322
|
-
});
|
|
323
|
-
return out;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
/** Sorted, stable list of per-element changes between two snapshots. */
|
|
327
|
-
function diffSnapshots(before: Map<string, string>, after: Map<string, string>): string[] {
|
|
328
|
-
const effects: string[] = [];
|
|
329
|
-
for (const [k, v] of after) {
|
|
330
|
-
const b = before.get(k);
|
|
331
|
-
if (b === undefined) effects.push(`+${k} ${v}`);
|
|
332
|
-
else if (b !== v) effects.push(`Δ${k} ${v}`);
|
|
333
|
-
}
|
|
334
|
-
for (const k of before.keys()) if (!after.has(k)) effects.push(`-${k}`);
|
|
335
|
-
return effects.sort();
|
|
336
|
-
}
|
|
291
|
+
// Effect-signature machinery (serializeElement/snapshot/diffSnapshots) moved to
|
|
292
|
+
// ../effect-signature.ts (imported at the top), shared with the
|
|
293
|
+
// shipped-examples execution gate so the two can never disagree about what a
|
|
294
|
+
// DOM effect is. Notes that used to live on the local copies (leaf-text-only
|
|
295
|
+
// rationale; body's own stable key, added in wave 3b when the runtime learned
|
|
296
|
+
// to resolve `body`) moved with them.
|
|
337
297
|
|
|
338
298
|
/** How long to let the dispatched handler settle (ms). The subset contains no
|
|
339
299
|
* waits/transitions/fetches, so this only needs to drain micro/macrotasks. */
|