memlnaut-nisps/manifold/tests/e2e/helpers.ts
monkey-w1n5t0n 469a38ae16 test(manifold): port probe-API contract + spine-invariant e2e from playground
Migrate the specs worth keeping from playground/tests/e2e (retired in P1) into
manifold/tests/e2e, adapted to Manifold's probe surface:

- probe-api.spec.ts: the window.__nisps debug-probe contract (ready, bounded
  outputs, example count, randomise, setInputs inference, thumbsUp/Down,
  addExample, train loss non-increasing, async train, clearExamples, evalLoss,
  inferBatch, getLayerStats, getWeights). Retargeted to MLP<32,10,14,18,126>
  (weight_count 3148) and Manifold's direct addExample/routedOutputs surface;
  dropped the playground's __init/iml-poke escape hatches and stream-pending
  skips.
- spine.spec.ts: the spine invariant — setInputs -> processed -> ml -> routed
  yields bounded, consistent routed outputs; the probe stays alive across dock
  output-mode switches (driven via the real selector UI, replacing the
  playground's localStorage-reload mode cycling).
- helpers.ts: loadProbe(?debug=1 + cleared storage + __ready wait), settleInputs
  for EMA convergence, bounded/changed assertions.

Dropped playground UI specs (ui-interactions, persistence, mode-registry list)
that die with the playground chrome. No probe.ts changes needed.
2026-07-13 23:26:00 +02:00

101 lines
3.3 KiB
TypeScript

/**
* Playwright helpers for the Manifold app.
*
* Ported from `playground/tests/e2e/helpers.ts` and adapted to Manifold's
* probe surface. Differences from the playground probe:
* - Manifold's probe has NO `__init()` — it is installed by `App.tsx` only
* AFTER the engine (and its WASM) are live, and `__ready` is a live getter
* over the spine state. So "ready" == `window.__nisps.__ready === true`.
* - The probe is gated behind `?debug=1` (see `src/debug/probe.ts`), not a
* route. We always navigate to `/?debug=1`.
* - `routedOutputs()` (plural) is the post-output-pipeline vector.
*/
import type { Page } from '@playwright/test';
import type { DebugProbe } from '../../src/debug/probe';
declare global {
interface Window {
__nisps?: DebugProbe;
}
}
const READY_TIMEOUT = 20_000;
/**
* Navigate to the Manifold app with the `?debug=1` probe installed and
* localStorage cleared (fresh default weights, not a prior test's state), then
* wait until `window.__nisps` reports the WASM engine is ready.
*
* `extraQuery` is appended after `debug=1` (leading `&` optional).
*/
export async function loadProbe(page: Page, extraQuery = ''): Promise<void> {
// Clear persisted ML/settings state before the SPA boots so initial
// inference uses default weights.
await page.addInitScript(() => {
try {
localStorage.clear();
sessionStorage.clear();
} catch {
/* private mode etc — ignore */
}
});
let q = extraQuery.trim();
if (q && !q.startsWith('&')) q = '&' + q;
await page.goto(`/?debug=1${q}`);
await page.waitForFunction(
() => {
const n = window.__nisps;
return !!n && n.__ready === true && n.getOutputs().length > 0;
},
undefined,
{ timeout: READY_TIMEOUT },
);
}
/** Read the live post-ML output vector as a JSON-safe number[]. */
export async function getOutputs(page: Page): Promise<number[]> {
return page.evaluate(() => Array.from(window.__nisps!.getOutputs()));
}
/** Read the live routed (post output-pipeline) vector as a JSON-safe number[]. */
export async function getRouted(page: Page): Promise<number[]> {
return page.evaluate(() => Array.from(window.__nisps!.routedOutputs()));
}
/** Push the same raw XY input `n` times so the input/output EMA smoothing
* settles, then return the converged post-ML outputs. */
export async function settleInputs(page: Page, x: number, y: number, n = 40): Promise<number[]> {
return page.evaluate(
([px, py, count]) => {
const probe = window.__nisps!;
for (let i = 0; i < count; i++) probe.setInputs(px, py);
return Array.from(probe.getOutputs());
},
[x, y, n] as const,
);
}
/**
* How many values differ by more than `eps` between two snapshots. Length
* mismatch counts as the absolute size difference.
*/
export function countChanged(a: number[], b: number[], eps = 1e-3): number {
if (a.length !== b.length) return Math.abs(a.length - b.length);
let n = 0;
for (let i = 0; i < a.length; ++i) {
if (Math.abs(a[i]! - b[i]!) > eps) ++n;
}
return n;
}
/** True iff every value is within [lo, hi] (with a tiny float tolerance). */
export function allWithin(xs: number[], lo = 0, hi = 1, tol = 1e-6): boolean {
for (const v of xs) {
if (!(v >= lo - tol && v <= hi + tol)) return false;
}
return true;
}
export type Probe = DebugProbe;