@hyperfixi/testing-framework 4.1.0 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +184 -1
- package/package.json +7 -7
- package/src/multilingual/command-shapes-gate.ts +76 -0
- package/src/multilingual/command-shapes.1.test.ts +9 -0
- package/src/multilingual/command-shapes.2.test.ts +9 -0
- package/src/multilingual/command-shapes.3.test.ts +9 -0
- package/src/multilingual/command-shapes.cases.json +6816 -0
- package/src/multilingual/command-shapes.cases.test.ts +122 -0
- package/src/multilingual/command-shapes.oracle.test.ts +204 -0
- package/src/multilingual/command-shapes.ts +839 -0
- package/src/multilingual/direct-path-shapes.ts +9 -2
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The command-shape gate's inputs: the harvested cases and the baseline's own
|
|
3
|
+
* consistency. The gate itself is command-shapes.<n>.test.ts.
|
|
4
|
+
*
|
|
5
|
+
* @vitest-environment node
|
|
6
|
+
*/
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
import { beforeAll, describe, expect, it } from 'vitest';
|
|
9
|
+
import {
|
|
10
|
+
LOUD,
|
|
11
|
+
SILENT_FAMILIES,
|
|
12
|
+
silentFamilyOf,
|
|
13
|
+
collapse,
|
|
14
|
+
expandLanes,
|
|
15
|
+
loadCommandShapeCases,
|
|
16
|
+
loadEngineReaders,
|
|
17
|
+
orphanedEntries,
|
|
18
|
+
readerOf,
|
|
19
|
+
vendoredUpstreamVersion,
|
|
20
|
+
type EngineParser,
|
|
21
|
+
type Reader,
|
|
22
|
+
} from './command-shapes';
|
|
23
|
+
import { loadCommandShapesBaseline } from './command-shapes-gate';
|
|
24
|
+
|
|
25
|
+
const { cases, upstream } = loadCommandShapeCases();
|
|
26
|
+
const baseline = loadCommandShapesBaseline();
|
|
27
|
+
|
|
28
|
+
describe('command-shape cases', () => {
|
|
29
|
+
let readers: Record<Reader, EngineParser>;
|
|
30
|
+
|
|
31
|
+
beforeAll(async () => {
|
|
32
|
+
readers = await loadEngineReaders();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('were harvested from the upstream version the suite vendors (re-harvest when it moves)', () => {
|
|
36
|
+
expect(upstream).toBe(vendoredUpstreamVersion());
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// A re-harvest that changes the count is a change to the gate's input:
|
|
40
|
+
// update this pin in the same PR, and say what moved.
|
|
41
|
+
it('are the 1135 the harvest found', () => {
|
|
42
|
+
expect(cases.length).toBe(1135);
|
|
43
|
+
const byOrigin = cases.reduce<Record<string, number>>((m, c) => {
|
|
44
|
+
const kind = c.origin.split(':')[0] ?? '';
|
|
45
|
+
m[kind] = (m[kind] ?? 0) + 1;
|
|
46
|
+
return m;
|
|
47
|
+
}, {});
|
|
48
|
+
expect(byOrigin).toEqual({ upstream: 947, reference: 122, 'reference-pattern': 8, hover: 58 });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('are not edited by hand: each id is the hash of its source', () => {
|
|
52
|
+
const wrong = cases.filter(
|
|
53
|
+
c => createHash('sha1').update(collapse(c.source)).digest('hex').slice(0, 10) !== c.id
|
|
54
|
+
);
|
|
55
|
+
expect(wrong.map(c => c.id)).toEqual([]);
|
|
56
|
+
expect(new Set(cases.map(c => c.id)).size).toBe(cases.length);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('are all scripts the engine reads, as the case is read', () => {
|
|
60
|
+
const bad: string[] = [];
|
|
61
|
+
for (const c of cases) {
|
|
62
|
+
try {
|
|
63
|
+
readers[readerOf(c)].parse(c.source);
|
|
64
|
+
} catch (e) {
|
|
65
|
+
bad.push(`${c.id}: ${(e as Error).message.split('\n')[0]}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
expect(bad).toEqual([]);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('command-shape baseline', () => {
|
|
73
|
+
it('was made from these cases', () => {
|
|
74
|
+
expect(baseline.upstream).toBe(upstream);
|
|
75
|
+
expect(baseline.cases).toBe(cases.length);
|
|
76
|
+
expect(orphanedEntries(baseline, cases)).toEqual([]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('lists each pair once: never both refused and silent', () => {
|
|
80
|
+
const both = Object.entries(baseline.entries).filter(([, e]) => {
|
|
81
|
+
const silent = new Set(expandLanes(e.silent));
|
|
82
|
+
return expandLanes(e.refused).some(lane => silent.has(lane));
|
|
83
|
+
});
|
|
84
|
+
expect(both.map(([id]) => id)).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// A shape semantic does not carry yet is refused, never silently lossy (M1
|
|
88
|
+
// phase 2). Each pair is still judged by the gate; this holds the list to it.
|
|
89
|
+
it('no LOUD case is listed silent', () => {
|
|
90
|
+
const silent = Object.entries(baseline.entries).filter(([, e]) => e.loud && e.silent);
|
|
91
|
+
expect(silent.map(([id, e]) => `${id} [${e.silent}] ${e.src}`)).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// The M1 plan's Phase 2 exit: the English lane has no silent pair, and every
|
|
95
|
+
// foreign silent entry carries a reason.
|
|
96
|
+
it('no case is silent in the English lane', () => {
|
|
97
|
+
const silent = Object.entries(baseline.entries).filter(([, e]) =>
|
|
98
|
+
(e.silent ?? '').split(' ').includes('en')
|
|
99
|
+
);
|
|
100
|
+
expect(silent.map(([id, e]) => `${id}: ${e.src}`)).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('every silent entry carries a reason (SILENT_FAMILIES)', () => {
|
|
104
|
+
const bare = Object.entries(baseline.entries).filter(
|
|
105
|
+
([id, e]) => e.silent && (!silentFamilyOf(id) || !e.why)
|
|
106
|
+
);
|
|
107
|
+
expect(bare.map(([id, e]) => `${id} [${e.silent}]: ${e.src}`)).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('every SILENT_FAMILIES id is a case still silent somewhere (drop it once fixed)', () => {
|
|
111
|
+
const stale = SILENT_FAMILIES.flatMap(f =>
|
|
112
|
+
f.ids.filter(id => !baseline.entries[id]?.silent).map(id => `${f.name}: ${id}`)
|
|
113
|
+
);
|
|
114
|
+
expect(stale).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// A LOUD family with no failing case is carried now: drop it from LOUD.
|
|
118
|
+
it('every LOUD family still has a case that does not pass', () => {
|
|
119
|
+
const listed = new Set(Object.values(baseline.entries).map(e => e.loud?.split(':')[0] ?? ''));
|
|
120
|
+
expect(LOUD.map(f => f.name).filter(name => !listed.has(name))).toEqual([]);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The command-shape gate's ORACLE (see command-shapes.ts), tested.
|
|
3
|
+
*
|
|
4
|
+
* - Each named equivalence is a claim that two spellings are the same
|
|
5
|
+
* program. Its pins run on `@hyperfixi/engine` and on upstream
|
|
6
|
+
* `hyperscript.org`: all four runs (two spellings, two engines) must leave
|
|
7
|
+
* the same, non-empty observation, and the rewrite must be what makes the
|
|
8
|
+
* two parses equal.
|
|
9
|
+
* - The oracle sees what a lossy renderer drops: every value token of every
|
|
10
|
+
* case, changed, changes the parse (an engine node that keeps a value only
|
|
11
|
+
* in a closure would hide it), and three renderer mutants — one that drops
|
|
12
|
+
* a class ref, one that drops `break`, one that swaps `at start of` and
|
|
13
|
+
* `at end of` — are each caught on every case they change.
|
|
14
|
+
*
|
|
15
|
+
* @vitest-environment node
|
|
16
|
+
* Required: under happy-dom the engines bind happy-dom's DOM constructors, not
|
|
17
|
+
* the jsdom window's (see shipped-examples-execution.test.ts).
|
|
18
|
+
*/
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
import { pathToFileURL } from 'node:url';
|
|
21
|
+
import { JSDOM } from 'jsdom';
|
|
22
|
+
import { beforeAll, describe, expect, it } from 'vitest';
|
|
23
|
+
import {
|
|
24
|
+
EQUIVALENCES,
|
|
25
|
+
judge,
|
|
26
|
+
loadCommandShapeCases,
|
|
27
|
+
loadEngineReaders,
|
|
28
|
+
normalize,
|
|
29
|
+
plainParse,
|
|
30
|
+
readerOf,
|
|
31
|
+
type EngineParser,
|
|
32
|
+
type Reader,
|
|
33
|
+
} from './command-shapes';
|
|
34
|
+
import { installGlobals } from './shipped-examples-execution';
|
|
35
|
+
|
|
36
|
+
interface Host {
|
|
37
|
+
processNode(element: Element): void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface Token {
|
|
41
|
+
type: string;
|
|
42
|
+
value: string;
|
|
43
|
+
start: number;
|
|
44
|
+
end: number;
|
|
45
|
+
template?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const { cases } = loadCommandShapeCases();
|
|
49
|
+
|
|
50
|
+
let dom: JSDOM;
|
|
51
|
+
let hosts: Record<'upstream' | 'engine', Host>;
|
|
52
|
+
let readers: Record<Reader, EngineParser>;
|
|
53
|
+
let tokenize: (source: string) => Token[];
|
|
54
|
+
|
|
55
|
+
beforeAll(async () => {
|
|
56
|
+
dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost/' });
|
|
57
|
+
installGlobals(dom);
|
|
58
|
+
const require = createRequire(import.meta.url);
|
|
59
|
+
const esm = require.resolve('hyperscript.org').replace(/[^/\\]+$/, '_hyperscript.esm.js');
|
|
60
|
+
const upstream = (await import(pathToFileURL(esm).href)).default as Host;
|
|
61
|
+
const engineModule = await import('@hyperfixi/engine');
|
|
62
|
+
engineModule.register(...engineModule.everything);
|
|
63
|
+
hosts = { upstream, engine: engineModule.api as unknown as Host };
|
|
64
|
+
readers = await loadEngineReaders();
|
|
65
|
+
tokenize = engineModule.tokenize as (source: string) => Token[];
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/** Run one click handler on a host in a fresh body; return what it observably did. */
|
|
69
|
+
async function observe(host: Host, source: string): Promise<string> {
|
|
70
|
+
const window = dom.window;
|
|
71
|
+
const document = window.document;
|
|
72
|
+
document.body.innerHTML = '<button id="b"></button><div id="o"></div><div id="t"></div>';
|
|
73
|
+
window.location.hash = '';
|
|
74
|
+
const fetches: string[] = [];
|
|
75
|
+
const globals: Record<string, unknown> = {
|
|
76
|
+
fetch: async (url: unknown, init?: { method?: string }) => {
|
|
77
|
+
fetches.push(`${String(url)} ${init?.method ?? 'GET'}`);
|
|
78
|
+
return {
|
|
79
|
+
ok: true,
|
|
80
|
+
status: 200,
|
|
81
|
+
headers: new Map(),
|
|
82
|
+
text: async () => '',
|
|
83
|
+
json: async () => ({}),
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
getText: () => 'T',
|
|
87
|
+
};
|
|
88
|
+
for (const [name, value] of Object.entries(globals)) {
|
|
89
|
+
Reflect.set(window, name, value);
|
|
90
|
+
Reflect.set(globalThis, name, value);
|
|
91
|
+
}
|
|
92
|
+
const button = document.getElementById('b') as HTMLElement;
|
|
93
|
+
button.setAttribute('_', source);
|
|
94
|
+
host.processNode(button);
|
|
95
|
+
button.dispatchEvent(new window.MouseEvent('click', { bubbles: true }));
|
|
96
|
+
await new Promise(resolve => setTimeout(resolve, 30));
|
|
97
|
+
return JSON.stringify({
|
|
98
|
+
o: document.getElementById('o')?.textContent,
|
|
99
|
+
color: (document.getElementById('t') as HTMLElement).style.color,
|
|
100
|
+
hash: window.location.hash,
|
|
101
|
+
fetches,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const EMPTY = JSON.stringify({ o: '', color: '', hash: '', fetches: [] });
|
|
106
|
+
|
|
107
|
+
/** A pin is a handler: read as a page's script. */
|
|
108
|
+
const shape = (source: string, equivalences = EQUIVALENCES): string =>
|
|
109
|
+
JSON.stringify(normalize(plainParse(readers.program.parse(source)), equivalences));
|
|
110
|
+
|
|
111
|
+
describe('command-shape equivalences', () => {
|
|
112
|
+
it('have distinct names', () => {
|
|
113
|
+
expect(new Set(EQUIVALENCES.map(e => e.name)).size).toBe(EQUIVALENCES.length);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe.each(EQUIVALENCES.map(e => [e.name, e] as const))('%s', (name, equivalence) => {
|
|
117
|
+
it('makes each pin pair parse the same, and is needed for it', () => {
|
|
118
|
+
const others = EQUIVALENCES.filter(e => e.name !== name);
|
|
119
|
+
for (const [a, b] of equivalence.pins) {
|
|
120
|
+
expect(shape(a), `${a} | ${b}`).toEqual(shape(b));
|
|
121
|
+
expect(shape(a, others), `${a} | ${b} without ${name}`).not.toEqual(shape(b, others));
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('is the same program on the engine and on upstream', async () => {
|
|
126
|
+
for (const [a, b] of equivalence.pins) {
|
|
127
|
+
const runs = {
|
|
128
|
+
'upstream, a': await observe(hosts.upstream, a),
|
|
129
|
+
'upstream, b': await observe(hosts.upstream, b),
|
|
130
|
+
'engine, a': await observe(hosts.engine, a),
|
|
131
|
+
'engine, b': await observe(hosts.engine, b),
|
|
132
|
+
};
|
|
133
|
+
expect(runs['upstream, a'], `${a}: the pin observes nothing`).not.toEqual(EMPTY);
|
|
134
|
+
expect(new Set(Object.values(runs)).size, `${a} | ${b}: ${JSON.stringify(runs)}`).toBe(1);
|
|
135
|
+
}
|
|
136
|
+
}, 10_000);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('the oracle sees a loss', () => {
|
|
141
|
+
/** Does `judge` flag the mutated source against the original? */
|
|
142
|
+
const caught = (engine: EngineParser, source: string, mutated: string): boolean => {
|
|
143
|
+
const plain = normalize(plainParse(engine.parse(source)));
|
|
144
|
+
return judge(engine, JSON.stringify(plain), plain, mutated).outcome !== 'pass';
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
it('a changed value token changes the parse, in every case', () => {
|
|
148
|
+
const blind: string[] = [];
|
|
149
|
+
let checked = 0;
|
|
150
|
+
for (const c of cases) {
|
|
151
|
+
const engine = readers[readerOf(c)];
|
|
152
|
+
const base = JSON.stringify(plainParse(engine.parse(c.source)));
|
|
153
|
+
for (const t of tokenize(c.source)) {
|
|
154
|
+
const text = c.source.slice(t.start, t.end);
|
|
155
|
+
let changed: string | undefined;
|
|
156
|
+
if (t.type === 'STRING') changed = text.replace(/^(.)/s, '$1zz');
|
|
157
|
+
else if (t.type === 'NUMBER') changed = String(Number(t.value) + 7);
|
|
158
|
+
else if ((t.type === 'CLASS_REF' || t.type === 'ID_REF') && !t.template)
|
|
159
|
+
changed = `${text}zz`;
|
|
160
|
+
else if (t.type === 'ATTRIBUTE_REF' || t.type === 'STYLE_REF') {
|
|
161
|
+
changed = text.replace(/([@*])([\w-]+)/, '$1zz$2');
|
|
162
|
+
} else if (t.type === 'IDENTIFIER') changed = `${text}zz`;
|
|
163
|
+
if (changed === undefined) continue;
|
|
164
|
+
const mutated = c.source.slice(0, t.start) + changed + c.source.slice(t.end);
|
|
165
|
+
let after: string;
|
|
166
|
+
try {
|
|
167
|
+
after = JSON.stringify(plainParse(engine.parse(mutated)));
|
|
168
|
+
} catch {
|
|
169
|
+
continue; // the engine rejects it: nothing for the oracle to miss
|
|
170
|
+
}
|
|
171
|
+
checked++;
|
|
172
|
+
if (after === base)
|
|
173
|
+
blind.push(`${t.value} in ${c.source.replace(/\s+/g, ' ').slice(0, 80)}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
expect(checked).toBeGreaterThan(4000);
|
|
177
|
+
expect(blind).toEqual([]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const MUTANTS: ReadonlyArray<{ name: string; mutate(source: string): string }> = [
|
|
181
|
+
{ name: 'drops a class ref', mutate: s => s.replace(/(^|\s)\.[A-Za-z][\w-]*(?=\s|$)/, '') },
|
|
182
|
+
{ name: 'drops `break`', mutate: s => s.replace(/\bbreak\b/, '') },
|
|
183
|
+
{
|
|
184
|
+
name: 'swaps `at start of` and `at end of`',
|
|
185
|
+
mutate: s =>
|
|
186
|
+
s.replace(/\bat (start|end) of\b/, (_m, edge: string) =>
|
|
187
|
+
edge === 'start' ? 'at end of' : 'at start of'
|
|
188
|
+
),
|
|
189
|
+
},
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
it.each(MUTANTS.map(m => [m.name, m] as const))('a renderer that %s', (_name, mutant) => {
|
|
193
|
+
let changed = 0;
|
|
194
|
+
const missed: string[] = [];
|
|
195
|
+
for (const c of cases) {
|
|
196
|
+
const mutated = mutant.mutate(c.source);
|
|
197
|
+
if (mutated === c.source) continue;
|
|
198
|
+
changed++;
|
|
199
|
+
if (!caught(readers[readerOf(c)], c.source, mutated)) missed.push(`${c.source} ⇒ ${mutated}`);
|
|
200
|
+
}
|
|
201
|
+
expect(changed).toBeGreaterThan(0);
|
|
202
|
+
expect(missed).toEqual([]);
|
|
203
|
+
});
|
|
204
|
+
});
|