@hyperfixi/testing-framework 3.2.0 → 4.0.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 +203 -1
- package/package.json +9 -9
- package/src/agent-bench/README.md +48 -1
- package/src/agent-bench/agent-bench.test.ts +7 -0
- package/src/agent-bench/harness.ts +39 -50
- package/src/multilingual/README.md +30 -11
- package/src/multilingual/bare-render-fidelity.ts +1 -1
- package/src/multilingual/cli.ts +5 -3
- package/src/multilingual/direct-path-shapes-gate.ts +56 -0
- package/src/multilingual/direct-path-shapes.1.test.ts +12 -0
- package/src/multilingual/direct-path-shapes.2.test.ts +12 -0
- package/src/multilingual/direct-path-shapes.3.test.ts +12 -0
- package/src/multilingual/direct-path-shapes.cases.json +2621 -0
- package/src/multilingual/direct-path-shapes.ts +459 -0
- package/src/multilingual/engine-parser-parity.test.ts +67 -0
- package/src/multilingual/orchestrator.ts +2 -1
- package/src/multilingual/render-fidelity.ts +1 -1
- package/src/multilingual/shipped-examples-execution.test.ts +44 -33
- package/src/multilingual/shipped-examples-execution.ts +55 -75
- package/src/multilingual/shipped-sources-engine.test.ts +106 -0
- package/src/multilingual/shipped-sources-localized.test.ts +87 -0
- package/src/multilingual/shipped-sources-validity.ts +127 -74
- package/src/multilingual/validators/execution-validator.test.ts +81 -23
- package/src/multilingual/validators/execution-validator.ts +140 -94
- package/src/multilingual/validators/parse-validator.ts +7 -20
- package/src/multilingual/value-matrix.accepted.test.ts +8 -11
- package/src/multilingual/value-matrix.isolation.test.ts +7 -6
- package/src/multilingual/value-matrix.ts +72 -86
- package/src/multilingual/shipped-sources-validity.test.ts +0 -95
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct-path shapes on the text path.
|
|
3
|
+
*
|
|
4
|
+
* Core's `src/multilingual/*-direct-path.test.ts` files each pin a shape core's
|
|
5
|
+
* multilingual DIRECT path ran: an English source rendered into the 23
|
|
6
|
+
* languages, compiled with `{ language }` on core (`parseSemantic → buildAST`),
|
|
7
|
+
* installed in a fixture and triggered. That path retires with core's parser
|
|
8
|
+
* (Phase C6 of the engine cutover); text is the interchange. This gate runs the
|
|
9
|
+
* same cases — extracted once into `direct-path-shapes.cases.json` (287 of the
|
|
10
|
+
* 316; the other 29 assert only on an AST) — the way the product now runs a
|
|
11
|
+
* translation: the foreign text AS WRITTEN on `@hyperfixi/engine`, with
|
|
12
|
+
* `@lokascript/hyperscript-adapter`'s plugin installed and `lang` on <html>.
|
|
13
|
+
*
|
|
14
|
+
* The oracle is upstream `_hyperscript` running the English source. Where
|
|
15
|
+
* upstream rejects the English (a core-only form the reader still accepts),
|
|
16
|
+
* the reference is upstream running the English the adapter wrote, which
|
|
17
|
+
* holds the renderer to upstream's spelling (`tell #m to show` is written
|
|
18
|
+
* `tell #m show end`).
|
|
19
|
+
*
|
|
20
|
+
* A run's signature is the effect-signature diff of the body, with text nodes
|
|
21
|
+
* normalized first (each `append` adds one, and the signature reads leaf text
|
|
22
|
+
* only for an element with a single text child), plus a log of the calls a
|
|
23
|
+
* DOM diff cannot show: `fetch`, `history.back/forward`, `window.open`,
|
|
24
|
+
* `scrollIntoView`, `scrollTo`, and `location.hash`.
|
|
25
|
+
*
|
|
26
|
+
* One jsdom window for the whole run, as in a browser: upstream resolves
|
|
27
|
+
* `document` through the window it saw when it loaded, so a window per run
|
|
28
|
+
* would point its `from document` at a stale one. Node owns a global
|
|
29
|
+
* `EventTarget`, which the globals bootstrap leaves alone; upstream's
|
|
30
|
+
* `wait for` checks `instanceof EventTarget`, so it is pointed at jsdom's for
|
|
31
|
+
* the run.
|
|
32
|
+
*
|
|
33
|
+
* KNOWN lists the cases that fail, each with why. The gate fails on a case
|
|
34
|
+
* that fails and is not listed, and on a listed case that passes: the list
|
|
35
|
+
* only shrinks.
|
|
36
|
+
*
|
|
37
|
+
* Node-only (the node vitest environment; see the shipped-examples gate).
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { readFileSync } from 'node:fs';
|
|
41
|
+
import { createRequire } from 'node:module';
|
|
42
|
+
import path from 'node:path';
|
|
43
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
44
|
+
import { JSDOM } from 'jsdom';
|
|
45
|
+
import { snapshot, diffSnapshots } from './effect-signature';
|
|
46
|
+
import { installGlobals } from './shipped-examples-execution';
|
|
47
|
+
import { FOREIGN_LANGUAGES } from './value-matrix';
|
|
48
|
+
|
|
49
|
+
export interface DirectPathCase {
|
|
50
|
+
/** `<core test file stem>#<n>`. */
|
|
51
|
+
id: string;
|
|
52
|
+
/** The English source the core test rendered into each language. */
|
|
53
|
+
source: string;
|
|
54
|
+
fixture: string;
|
|
55
|
+
/** The element the handler is installed on (default `button`). */
|
|
56
|
+
installOn: string | null;
|
|
57
|
+
event: string | null;
|
|
58
|
+
/** The languages the core test ran; `[]` = English only. */
|
|
59
|
+
languages: 'all23' | string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const CASES_PATH = path.resolve(
|
|
63
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
64
|
+
'direct-path-shapes.cases.json'
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
export function loadDirectPathCases(): DirectPathCase[] {
|
|
68
|
+
return JSON.parse(readFileSync(CASES_PATH, 'utf8')) as DirectPathCase[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The cases that fail, and why. `core-only`: upstream rejects the English and
|
|
73
|
+
* nothing re-renders it (an English source reaches the engine as written).
|
|
74
|
+
* `renderer`: the render back to English loses the shape. `vacuous`: neither
|
|
75
|
+
* engine does anything, so there is nothing to compare. The nine `core-only`
|
|
76
|
+
* translations and the one `renderer` loss listed when the gate landed (C2b)
|
|
77
|
+
* were closed by English writing upstream's spelling
|
|
78
|
+
* (`semantic/src/explicit/upstream-spelling.ts`, C2c).
|
|
79
|
+
*/
|
|
80
|
+
export const KNOWN: Readonly<
|
|
81
|
+
Record<string, { family: 'core-only' | 'renderer' | 'vacuous'; reason: string }>
|
|
82
|
+
> = {
|
|
83
|
+
'condition-phrases-direct-path#97': {
|
|
84
|
+
family: 'core-only',
|
|
85
|
+
reason: '`#d1 have .x`, English only: an English source reaches the engine as written',
|
|
86
|
+
},
|
|
87
|
+
'condition-phrases-direct-path#98': {
|
|
88
|
+
family: 'core-only',
|
|
89
|
+
reason: '`#d1 have .y`, English only: an English source reaches the engine as written',
|
|
90
|
+
},
|
|
91
|
+
'go-direct-path#2': {
|
|
92
|
+
family: 'vacuous',
|
|
93
|
+
reason: '`go forward` calls nothing on either engine (core-only)',
|
|
94
|
+
},
|
|
95
|
+
'class-query-scope-direct-path#4': {
|
|
96
|
+
family: 'vacuous',
|
|
97
|
+
reason: '`if .item is in #list` changes nothing on upstream; English only',
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
type Step =
|
|
102
|
+
| {
|
|
103
|
+
ev: string;
|
|
104
|
+
init?: Record<string, unknown>;
|
|
105
|
+
/** Dispatch on the document instead of the install target. */
|
|
106
|
+
onDocument?: boolean;
|
|
107
|
+
ctor?: 'CustomEvent' | 'KeyboardEvent' | 'MouseEvent';
|
|
108
|
+
}
|
|
109
|
+
| { settle: number };
|
|
110
|
+
|
|
111
|
+
const settle = (ms: number): Step => ({ settle: ms });
|
|
112
|
+
const click: Step = { ev: 'click', ctor: 'MouseEvent', init: { bubbles: true } };
|
|
113
|
+
const key = (ev: string, init: Record<string, unknown> = {}): Step => ({
|
|
114
|
+
ev,
|
|
115
|
+
ctor: 'KeyboardEvent',
|
|
116
|
+
init: { bubbles: true, ...init },
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
/** How long a plain case settles after its event (ms). */
|
|
120
|
+
const SETTLE_MS = 15;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Each case's events, as its core test sends them. A case with several
|
|
124
|
+
* variants runs each on a fresh install. Default: the case's event, bubbling,
|
|
125
|
+
* then SETTLE_MS.
|
|
126
|
+
*/
|
|
127
|
+
const SCRIPTS: Readonly<Record<string, Step[][]>> = {
|
|
128
|
+
'custom-or-event-direct-path#1': [
|
|
129
|
+
[{ ev: 'myEvent', ctor: 'CustomEvent', init: { bubbles: true } }, settle(SETTLE_MS)],
|
|
130
|
+
],
|
|
131
|
+
'fetch-do-not-throw-direct-path#1': [[click, settle(30)]],
|
|
132
|
+
'fetch-response-type-direct-path#1': [[click, settle(30)]],
|
|
133
|
+
'naked-url-interpolation-direct-path#1': [[{ ev: 'input', init: { bubbles: true } }, settle(30)]],
|
|
134
|
+
'handler-head-direct-path#1': [
|
|
135
|
+
[click, settle(SETTLE_MS)],
|
|
136
|
+
[key('keydown'), settle(SETTLE_MS)],
|
|
137
|
+
],
|
|
138
|
+
'handler-head-direct-path#2': [
|
|
139
|
+
[key('keydown', { key: 'Escape' }), settle(SETTLE_MS)],
|
|
140
|
+
[key('keydown', { key: 'a' }), settle(SETTLE_MS)],
|
|
141
|
+
],
|
|
142
|
+
'handler-head-direct-path#3': [
|
|
143
|
+
[{ ev: 'click', ctor: 'MouseEvent', init: { clientX: 42, bubbles: true } }, settle(SETTLE_MS)],
|
|
144
|
+
],
|
|
145
|
+
'handler-head-direct-path#4': [
|
|
146
|
+
[
|
|
147
|
+
{ ev: 'myEvent', ctor: 'CustomEvent', init: { detail: 'D', bubbles: true } },
|
|
148
|
+
settle(SETTLE_MS),
|
|
149
|
+
],
|
|
150
|
+
],
|
|
151
|
+
'handler-head-direct-path#5': [
|
|
152
|
+
[
|
|
153
|
+
{ ev: 'mousemove', ctor: 'MouseEvent', init: { clientX: 4, bubbles: true } },
|
|
154
|
+
settle(SETTLE_MS),
|
|
155
|
+
],
|
|
156
|
+
],
|
|
157
|
+
'handler-head-direct-path#6': [[key('keydown', { key: 'q' }), settle(SETTLE_MS)]],
|
|
158
|
+
'on-first-click-direct-path#1': [[click, settle(SETTLE_MS), click, settle(SETTLE_MS)]],
|
|
159
|
+
'until-loop-direct-path#1': [[click, settle(120)]],
|
|
160
|
+
'until-loop-direct-path#2': [[click, settle(120)]],
|
|
161
|
+
'wait-direct-path#1': [[click, settle(20), key('keyup'), settle(20)]],
|
|
162
|
+
'wait-direct-path#2': [[click, settle(40)]],
|
|
163
|
+
'wait-direct-path#3': [
|
|
164
|
+
[
|
|
165
|
+
click,
|
|
166
|
+
settle(20),
|
|
167
|
+
{ ev: 'pointermove', ctor: 'MouseEvent', onDocument: true, init: { clientX: 7 } },
|
|
168
|
+
settle(20),
|
|
169
|
+
],
|
|
170
|
+
],
|
|
171
|
+
'wait-direct-path#4': [[click, settle(60)]],
|
|
172
|
+
'wait-direct-path#5': [
|
|
173
|
+
[
|
|
174
|
+
{ ev: 'pointerdown', ctor: 'MouseEvent', init: { clientX: 3 } },
|
|
175
|
+
settle(20),
|
|
176
|
+
{ ev: 'pointerdown', ctor: 'MouseEvent', init: { clientX: 5 } },
|
|
177
|
+
settle(20),
|
|
178
|
+
],
|
|
179
|
+
],
|
|
180
|
+
'wait-direct-path#6': [
|
|
181
|
+
[
|
|
182
|
+
click,
|
|
183
|
+
settle(20),
|
|
184
|
+
{ ev: 'mousemove', ctor: 'MouseEvent', init: { bubbles: true } },
|
|
185
|
+
settle(20),
|
|
186
|
+
],
|
|
187
|
+
],
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
function scriptOf(c: DirectPathCase): Step[][] {
|
|
191
|
+
return (
|
|
192
|
+
SCRIPTS[c.id] ?? [[{ ev: c.event ?? 'click', init: { bubbles: true } }, settle(SETTLE_MS)]]
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** What this needs of a host that reads scripts off attributes. */
|
|
197
|
+
interface ScriptHost {
|
|
198
|
+
parse(source: string): { errors?: Array<{ message: string }> } | undefined;
|
|
199
|
+
processNode(element: Element): void;
|
|
200
|
+
evaluate(source: string): unknown;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface PairResult {
|
|
204
|
+
lang: string;
|
|
205
|
+
/** The translation, as the core test wrote it: `render(parse_en(source), lang)`. */
|
|
206
|
+
written: string | null;
|
|
207
|
+
/** The adapter's English for it. */
|
|
208
|
+
english: string;
|
|
209
|
+
/** The translation on the engine. */
|
|
210
|
+
got: string;
|
|
211
|
+
/** What it must equal: upstream's result for the English (or for `english`). */
|
|
212
|
+
ref: string;
|
|
213
|
+
ok: boolean;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export interface DirectPathResult {
|
|
217
|
+
id: string;
|
|
218
|
+
/** Upstream on the English source (the oracle), or why there is none. */
|
|
219
|
+
want: string;
|
|
220
|
+
/** The engine on the English source. */
|
|
221
|
+
eng: string;
|
|
222
|
+
pairs: PairResult[];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const EMPTY = /^∅( ‖ ∅)*$/;
|
|
226
|
+
|
|
227
|
+
/** Whether a case fails: no usable oracle, the engine's English differs, or a pair differs. */
|
|
228
|
+
export function caseFails(r: DirectPathResult): boolean {
|
|
229
|
+
if (r.want.startsWith('✗') || EMPTY.test(r.want)) {
|
|
230
|
+
return r.pairs.length === 0 || r.pairs.some(p => !p.ok);
|
|
231
|
+
}
|
|
232
|
+
return r.eng !== r.want || r.pairs.some(p => !p.ok);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export interface DirectPathHosts {
|
|
236
|
+
runCase(c: DirectPathCase): Promise<DirectPathResult>;
|
|
237
|
+
/** Restore the console, the rejection listeners and EventTarget. */
|
|
238
|
+
close(): Promise<void>;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Load upstream and the engine (with the plugin) on one jsdom window. Until
|
|
243
|
+
* `close()` the console is silenced and unhandled rejections are trapped:
|
|
244
|
+
* failing lanes fail by design and both engines report on the console.
|
|
245
|
+
*/
|
|
246
|
+
export async function initDirectPathHosts(): Promise<DirectPathHosts> {
|
|
247
|
+
const dom = new JSDOM('<!doctype html><html><head></head><body></body></html>', {
|
|
248
|
+
url: 'http://localhost/',
|
|
249
|
+
});
|
|
250
|
+
installGlobals(dom);
|
|
251
|
+
const g = globalThis as Record<string, unknown>;
|
|
252
|
+
const savedEventTarget = g.EventTarget;
|
|
253
|
+
const window = dom.window;
|
|
254
|
+
g.EventTarget = window.EventTarget;
|
|
255
|
+
|
|
256
|
+
const saved = {
|
|
257
|
+
log: console.log,
|
|
258
|
+
warn: console.warn,
|
|
259
|
+
error: console.error,
|
|
260
|
+
info: console.info,
|
|
261
|
+
};
|
|
262
|
+
const quiet = (): void => {};
|
|
263
|
+
console.log = console.warn = console.error = console.info = quiet;
|
|
264
|
+
const rejectionListeners = process.listeners('unhandledRejection');
|
|
265
|
+
process.removeAllListeners('unhandledRejection');
|
|
266
|
+
process.on('unhandledRejection', quiet);
|
|
267
|
+
|
|
268
|
+
// The calls a DOM diff cannot show, logged per run.
|
|
269
|
+
const calls: string[] = [];
|
|
270
|
+
let fetchStatus = 200;
|
|
271
|
+
const fetchStub = async (url: unknown): Promise<Response> => {
|
|
272
|
+
calls.push(`fetch ${String(url)}`);
|
|
273
|
+
return fetchStatus === 200
|
|
274
|
+
? new Response('found')
|
|
275
|
+
: new Response('gone', { status: fetchStatus, statusText: 'Not Found' });
|
|
276
|
+
};
|
|
277
|
+
Reflect.set(window, 'fetch', fetchStub);
|
|
278
|
+
g.fetch = fetchStub;
|
|
279
|
+
Object.defineProperty(window.history, 'back', { value: () => calls.push('history.back') });
|
|
280
|
+
Object.defineProperty(window.history, 'forward', {
|
|
281
|
+
value: () => calls.push('history.forward'),
|
|
282
|
+
});
|
|
283
|
+
Reflect.set(window, 'open', (...args: unknown[]) => {
|
|
284
|
+
calls.push(`open ${args.map(String).join(',')}`);
|
|
285
|
+
return null;
|
|
286
|
+
});
|
|
287
|
+
Reflect.set(window, 'scrollTo', (...args: unknown[]) => {
|
|
288
|
+
calls.push(`scrollTo ${JSON.stringify(args)}`);
|
|
289
|
+
});
|
|
290
|
+
window.Element.prototype.scrollIntoView = function (this: Element, options?: unknown) {
|
|
291
|
+
calls.push(
|
|
292
|
+
`scrollIntoView ${this.id || this.tagName.toLowerCase()} ${JSON.stringify(options ?? null)}`
|
|
293
|
+
);
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const require = createRequire(import.meta.url);
|
|
297
|
+
const esm = require.resolve('hyperscript.org').replace(/[^/\\]+$/, '_hyperscript.esm.js');
|
|
298
|
+
const upstream = (await import(pathToFileURL(esm).href)).default as ScriptHost;
|
|
299
|
+
const engineModule = await import('@hyperfixi/engine');
|
|
300
|
+
engineModule.register(...engineModule.everything);
|
|
301
|
+
const { hyperscriptI18n, preprocess } = await import('@lokascript/hyperscript-adapter');
|
|
302
|
+
engineModule.api.use(hyperscriptI18n());
|
|
303
|
+
const engine: ScriptHost = engineModule.api;
|
|
304
|
+
const { parseSemantic, render } = await import('@lokascript/semantic');
|
|
305
|
+
|
|
306
|
+
const document = window.document;
|
|
307
|
+
let behaviorSerial = 0;
|
|
308
|
+
const letter = (n: number): string => String.fromCharCode(65 + (n % 26));
|
|
309
|
+
|
|
310
|
+
/** One run: a fresh body, the handler installed, the steps sent; the signature. */
|
|
311
|
+
const run = async (
|
|
312
|
+
host: ScriptHost,
|
|
313
|
+
c: DirectPathCase,
|
|
314
|
+
code: string,
|
|
315
|
+
lang: string,
|
|
316
|
+
steps: Step[]
|
|
317
|
+
): Promise<string> => {
|
|
318
|
+
calls.length = 0;
|
|
319
|
+
fetchStatus = c.id.startsWith('fetch-do-not-throw') ? 404 : 200;
|
|
320
|
+
window.history.replaceState(null, '', '/');
|
|
321
|
+
document.documentElement.removeAttribute('lang');
|
|
322
|
+
try {
|
|
323
|
+
let install = code;
|
|
324
|
+
if (c.id.startsWith('behavior-handlers')) {
|
|
325
|
+
// The source defines a behavior: define it (under a fresh name), then
|
|
326
|
+
// install it in English, as the core test does.
|
|
327
|
+
const name = `Demo${letter(behaviorSerial)}${letter(Math.floor(behaviorSerial / 26))}`;
|
|
328
|
+
behaviorSerial++;
|
|
329
|
+
const definition = code.replace(/Demo[A-Z]+/g, name);
|
|
330
|
+
host.evaluate(lang === 'en' ? definition : preprocess(definition, lang));
|
|
331
|
+
install = `install ${name}(h: 1)`;
|
|
332
|
+
lang = 'en';
|
|
333
|
+
}
|
|
334
|
+
document.body.innerHTML = c.fixture;
|
|
335
|
+
if (c.id === 'variable-targets-direct-path#5') {
|
|
336
|
+
document
|
|
337
|
+
.querySelectorAll('.x')
|
|
338
|
+
.forEach((el: Element) => el.addEventListener('foo', () => el.classList.add('got')));
|
|
339
|
+
}
|
|
340
|
+
if (lang !== 'en') document.documentElement.setAttribute('lang', lang);
|
|
341
|
+
const target = document.querySelector(c.installOn ?? 'button');
|
|
342
|
+
if (!target) return '✗no install target';
|
|
343
|
+
target.setAttribute('_', install);
|
|
344
|
+
document.body
|
|
345
|
+
.querySelectorAll('*')
|
|
346
|
+
.forEach((el: Element, i: number) => el.setAttribute('data-exec-key', String(i)));
|
|
347
|
+
host.processNode(target);
|
|
348
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
349
|
+
document.body.normalize();
|
|
350
|
+
const before = snapshot(document);
|
|
351
|
+
for (const step of steps) {
|
|
352
|
+
if ('settle' in step) {
|
|
353
|
+
await new Promise(resolve => setTimeout(resolve, step.settle));
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
const Ctor: typeof Event = step.ctor ? window[step.ctor] : window.Event;
|
|
357
|
+
(step.onDocument ? document : target).dispatchEvent(new Ctor(step.ev, step.init ?? {}));
|
|
358
|
+
}
|
|
359
|
+
document.body.normalize();
|
|
360
|
+
const out = diffSnapshots(before, snapshot(document));
|
|
361
|
+
if (window.location.hash) out.push(`hash ${window.location.hash}`);
|
|
362
|
+
out.push(...calls);
|
|
363
|
+
return out.join(' | ') || '∅';
|
|
364
|
+
} catch (e) {
|
|
365
|
+
return `✗threw ${(e as Error).message.split('\n')[0] ?? ''}`;
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const runAll = async (
|
|
370
|
+
host: ScriptHost,
|
|
371
|
+
c: DirectPathCase,
|
|
372
|
+
code: string,
|
|
373
|
+
lang: string
|
|
374
|
+
): Promise<string> => {
|
|
375
|
+
const parts: string[] = [];
|
|
376
|
+
for (const steps of scriptOf(c)) parts.push(await run(host, c, code, lang, steps));
|
|
377
|
+
return parts.join(' ‖ ');
|
|
378
|
+
};
|
|
379
|
+
const rejects = (host: ScriptHost, code: string): string | undefined =>
|
|
380
|
+
host.parse(code)?.errors?.[0]?.message.split('\n')[0];
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
async runCase(c) {
|
|
384
|
+
// A behavior definition is not a handler: it is checked by running it.
|
|
385
|
+
const rejected = c.id.startsWith('behavior-handlers')
|
|
386
|
+
? undefined
|
|
387
|
+
: rejects(upstream, c.source);
|
|
388
|
+
const want = rejected
|
|
389
|
+
? `✗upstream rejects: ${rejected}`
|
|
390
|
+
: await runAll(upstream, c, c.source, 'en');
|
|
391
|
+
const eng = await runAll(engine, c, c.source, 'en');
|
|
392
|
+
const languages = c.languages === 'all23' ? [...FOREIGN_LANGUAGES] : c.languages;
|
|
393
|
+
const node = parseSemantic(c.source, 'en').node;
|
|
394
|
+
const pairs: PairResult[] = [];
|
|
395
|
+
for (const lang of languages) {
|
|
396
|
+
let written: string | null = null;
|
|
397
|
+
try {
|
|
398
|
+
written = node ? render(node, lang) : null;
|
|
399
|
+
} catch {
|
|
400
|
+
written = null;
|
|
401
|
+
}
|
|
402
|
+
if (written === null) {
|
|
403
|
+
pairs.push({ lang, written, english: '', got: '✗untranslatable', ref: want, ok: false });
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
let english: string;
|
|
407
|
+
try {
|
|
408
|
+
english = preprocess(written, lang);
|
|
409
|
+
} catch (e) {
|
|
410
|
+
english = `✗${(e as Error).message}`;
|
|
411
|
+
}
|
|
412
|
+
const got = await runAll(engine, c, written, lang);
|
|
413
|
+
let ref = want;
|
|
414
|
+
if (rejected && !english.startsWith('✗') && !rejects(upstream, english)) {
|
|
415
|
+
ref = await runAll(upstream, c, english, 'en');
|
|
416
|
+
}
|
|
417
|
+
const ok = got === ref && !EMPTY.test(ref) && !ref.startsWith('✗');
|
|
418
|
+
pairs.push({ lang, written, english, got, ref, ok });
|
|
419
|
+
}
|
|
420
|
+
return { id: c.id, want, eng, pairs };
|
|
421
|
+
},
|
|
422
|
+
async close() {
|
|
423
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
424
|
+
process.off('unhandledRejection', quiet);
|
|
425
|
+
for (const listener of rejectionListeners) process.on('unhandledRejection', listener);
|
|
426
|
+
Object.assign(console, saved);
|
|
427
|
+
g.EventTarget = savedEventTarget;
|
|
428
|
+
},
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** A failing case, for a report line. */
|
|
433
|
+
export function describeFailure(r: DirectPathResult): string {
|
|
434
|
+
const head = `${r.id}: want ${r.want.slice(0, 120)}`;
|
|
435
|
+
if (!r.want.startsWith('✗') && !EMPTY.test(r.want) && r.eng !== r.want) {
|
|
436
|
+
return `${head}\n engine English: ${r.eng.slice(0, 120)}`;
|
|
437
|
+
}
|
|
438
|
+
const bad = r.pairs.filter(p => !p.ok);
|
|
439
|
+
const first = bad[0];
|
|
440
|
+
return first
|
|
441
|
+
? `${head}\n ${bad.length} language(s), e.g. ${first.lang}: ${first.written}\n → ${first.english}\n got ${first.got.slice(0, 120)}`
|
|
442
|
+
: head;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* One shard of the gate: every case whose index ≡ `shard` (mod `of`), so the
|
|
447
|
+
* shards run in parallel. Returns its results; the caller asserts.
|
|
448
|
+
*/
|
|
449
|
+
export async function runDirectPathShard(shard: number, of: number): Promise<DirectPathResult[]> {
|
|
450
|
+
const cases = loadDirectPathCases().filter((_, i) => i % of === shard);
|
|
451
|
+
const hosts = await initDirectPathHosts();
|
|
452
|
+
try {
|
|
453
|
+
const results: DirectPathResult[] = [];
|
|
454
|
+
for (const c of cases) results.push(await hosts.runCase(c));
|
|
455
|
+
return results;
|
|
456
|
+
} finally {
|
|
457
|
+
await hosts.close();
|
|
458
|
+
}
|
|
459
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine parser parity: the canonical-validity gates' strings on `@hyperfixi/engine`.
|
|
3
|
+
*
|
|
4
|
+
* Every English string those gates put to upstream `hyperscript.org`'s parser — each corpus
|
|
5
|
+
* row, its English re-render, and the English render of every authored translation — is put
|
|
6
|
+
* to the new engine's parser as well. The two must agree on whether it parses. The engine is
|
|
7
|
+
* meant to replace core's, and the multilingual product reaches an engine as English text, so
|
|
8
|
+
* a string upstream reads and the engine rejects (or the reverse) is a translation that works
|
|
9
|
+
* on one host and not the other.
|
|
10
|
+
*
|
|
11
|
+
* Unlike the foreign canonical-validity gate, this needs no fresh `populate`: it compares two
|
|
12
|
+
* parsers on the SAME strings, so a stale patterns.db changes which strings are asked, not
|
|
13
|
+
* whether the parsers agree. It always runs.
|
|
14
|
+
*
|
|
15
|
+
* On a failure, run one string on both engines:
|
|
16
|
+
* `npx tsx packages/engine/tools/probe.mts '<source>'`.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
19
|
+
import {
|
|
20
|
+
checkCorpusRenderValidity,
|
|
21
|
+
loadCanonicalParser,
|
|
22
|
+
type CanonicalValidate,
|
|
23
|
+
} from './canonical-validity';
|
|
24
|
+
import { checkForeignRenderValidity } from './foreign-canonical-validity';
|
|
25
|
+
|
|
26
|
+
describe('engine parser parity (R4 strings on @hyperfixi/engine)', () => {
|
|
27
|
+
const asked = new Set<string>();
|
|
28
|
+
const disagreements: string[] = [];
|
|
29
|
+
|
|
30
|
+
beforeAll(async () => {
|
|
31
|
+
const { api, everything, register } = await import('@hyperfixi/engine');
|
|
32
|
+
register(...everything);
|
|
33
|
+
const upstream = await loadCanonicalParser();
|
|
34
|
+
const engine: CanonicalValidate = source => {
|
|
35
|
+
try {
|
|
36
|
+
return api.parse(source).errors.map(error => error.message);
|
|
37
|
+
} catch (e) {
|
|
38
|
+
return ['threw: ' + (e instanceof Error ? e.message.split('\n')[0] : String(e))];
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
// Upstream's verdict is the one handed back, so the gates' own logic is unchanged.
|
|
42
|
+
const both: CanonicalValidate = source => {
|
|
43
|
+
const up = upstream(source);
|
|
44
|
+
if (!asked.has(source)) {
|
|
45
|
+
asked.add(source);
|
|
46
|
+
const mine = engine(source);
|
|
47
|
+
if ((up.length === 0) !== (mine.length === 0)) {
|
|
48
|
+
disagreements.push(
|
|
49
|
+
`${JSON.stringify(source)}\n upstream: ${up[0]?.split('\n')[0] ?? 'accepts'}` +
|
|
50
|
+
`\n engine: ${mine[0]?.split('\n')[0] ?? 'accepts'}`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return up;
|
|
55
|
+
};
|
|
56
|
+
await checkCorpusRenderValidity({ validate: both });
|
|
57
|
+
await checkForeignRenderValidity({ validate: both });
|
|
58
|
+
}, 300_000);
|
|
59
|
+
|
|
60
|
+
it('asks about a real corpus (sanity: patterns.db has rows)', () => {
|
|
61
|
+
expect(asked.size).toBeGreaterThan(100);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('the two parsers agree on every string', () => {
|
|
65
|
+
expect(disagreements).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -398,9 +398,10 @@ export class TestOrchestrator {
|
|
|
398
398
|
await validator.initialize();
|
|
399
399
|
|
|
400
400
|
// codeExampleId -> en effect signature (only clean, effectful references).
|
|
401
|
+
// The reference is upstream's: the oracle, not the engine under test.
|
|
401
402
|
const reference = new Map<string, string[]>();
|
|
402
403
|
for (const [id, code] of sources.get('en') ?? []) {
|
|
403
|
-
const res = await validator.execute(id, code, 'en');
|
|
404
|
+
const res = await validator.execute(id, code, 'en', 'upstream');
|
|
404
405
|
if (!res.error && res.effects.length > 0) reference.set(id, res.effects);
|
|
405
406
|
}
|
|
406
407
|
if (reference.size === 0) return;
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* writes — it never calls `render(node, L)` at all. `canonical-validity` is
|
|
9
9
|
* en→en and `foreign-canonical-validity` is foreign→en, both of which pass.
|
|
10
10
|
* So `render(parse(en), L)` — the function behind MCP `translate_code`,
|
|
11
|
-
* `hyperfixi.translate`, `getAllTranslations`, core's `
|
|
11
|
+
* `hyperfixi.translate`, `getAllTranslations`, core's `multilingual` entry
|
|
12
12
|
* and the VS Code "Show in my language" badge — was measured by nothing.
|
|
13
13
|
*
|
|
14
14
|
* When it was finally measured (2026-08-26) it was 73.3% structurally clean
|