Files
ng-eventually/packages/polyfill/e2e/orm-live-read.ts
T
2026-09-21 11:41:31 +02:00

265 lines
11 KiB
TypeScript

/**
* Does a live `useShape` read reach the DEPLOYED broker, and does deleting from the set it
* returns reach the document?
*
* Standalone, and deliberately outside `e2e/run.ts`: what it measures is the PUBLISHED
* `useShape` - the bare `DeepSignalSet` the ORM hook hands an application, and a
* `.delete()` on it reaching the document. `e2e/run.ts` exercises the other shape read,
* `useShapeQuery`, which since `1.0.0-dev.6` reads through the SDK's shape query too. Two
* surfaces, two runs.
*
* Run: `bun run e2e/orm-live-read.ts` from `packages/polyfill`.
*
* -- What it decides ----------------------------------------
* The verdict is readable in one number: does `readyPromise` settle. It does on one path
* only — the arrival of an initial payload from the engine
* (`GraphOrmSubscription.ts:handleInitialResponse`) — so a refused start leaves it pending
* for ever, which is exactly why the failure is silent and why a bound, not an await, is
* what turns it into a report. Nothing here infers a verdict from a version string.
*
* -- What it found, and the row that went with the answer ----------------
* On 2026-09-16 the read did not start: the engine answered `Deserialization error of
* config`, and a sixth row of this run established WHERE that `config` sits - position 4,
* before the callback, where the five-argument client of the day landed its callback. The
* deployment was AHEAD of the pinned client packages, not behind them.
*
* Updating them answered it: `@ng-org/web@0.1.2-alpha.14` declares six arguments with
* `config` at index 4 and `@ng-org/orm@0.1.2-alpha.21` sends six to match, and the run
* measured `6 passed, 0 failed` with the read ready in 16ms and the delete reaching the
* document. The characterisation row was retired with the question it answered: there is
* nothing left for it to find out, and a row that always prints the same sentence teaches
* whoever reads this report to skip it.
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { execSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import {
armSuiteDeadline,
closeContext,
closeQuietly,
launchWatchedContext,
newPage,
serveOnEphemeralPort,
setupBrokerPage,
within,
type BrowserContext,
type Frame,
type Page,
} from "ng-e2e-helpers";
import { WALLET, mintBatchWallet } from "./harness-page";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ENTRY = path.resolve(__dirname, "orm-live-entry.ts");
const BUNDLE_OUT = path.resolve(__dirname, ".dist", "orm-live-entry.js");
// ── bounds ──────────────────────────────────────────────────────────────────
/** `bun build` is a local bundle; a minute is already ten times what it takes. */
const BUILD_MS = 60_000;
/**
* The whole measurement. Five checks against one fresh wallet, where the expensive parts
* are the broker login and one document creation — the 42-check suite pays both and
* measures 3.4 min in total, so eight minutes here is the point past which this has
* stopped being a measurement of anything.
*/
const SUITE_DEADLINE_MS = 8 * 60 * 1000;
/** One `__sdk` bridge call. `frame.evaluate()` has no deadline of its own. */
const BRIDGE_MS = 2 * 60 * 1000;
/**
* How long a live read may take to deliver its initial payload.
*
* NOT yet computed from a measured latency of this path, and it cannot be while the start
* is refused: nothing has ever settled here to measure. It is sized against the duration
* the step before it is allowed — the broker login, bounded at 45s in `ng-e2e-helpers` and
* measured at a few seconds — so a read that has not answered in 60s has not answered
* because it was slow. The first run that settles prints `ready in Nms`; set this to a
* generous multiple of that figure then, and say so here.
*/
const READY_MS = 60_000;
/**
* How long the delete may take to reach the document. Same status, and the write it waits
* on is the one `e2e/run.ts` measures elsewhere at a second or two against this broker.
*/
const DELETE_MS = 30_000;
// ── what this run reports ───────────────────────────────────────────────────
/**
* Every check, declared here so the run's total is known BEFORE the browser launches — the
* convention `e2e/notebook.ts` states at length: a run that dies in its setup still has a
* total to be compared against, so a difference between two runs is always a real one.
*/
const CHECKS: readonly string[] = [
"the document accepted the seed (two triples for one subject)",
"the live read STARTS: `useShape` returns a set and the subscription reports ready",
"the initial payload carries the seeded object, with its `@id` and `@graph`",
"`.delete()` on that set is accepted",
"the deletion reached the DOCUMENT: the seeded subject's triples are gone",
];
type Check = { name: string; ok: boolean; detail?: string };
const results: Check[] = [];
function check(name: string, ok: boolean, detail?: string): void {
results.push({ name, ok, detail });
console.log(` [${ok ? "PASS" : "FAIL"}] ${name}${detail ? " — " + detail : ""}`);
}
/** The bridge as the browser side of `frame.evaluate` sees it — no `any`, and no import
* the serialized callback could not carry across. */
type Bridge = Record<string, (...a: unknown[]) => unknown>;
declare global {
interface Window {
__sdk?: Bridge;
}
}
function sdk<T>(frame: Frame, method: string, ...args: unknown[]): Promise<T> {
return within(`__sdk.${method}() in the broker iframe`, BRIDGE_MS, () =>
frame.evaluate(
([m, a]) => window.__sdk![m as string]!(...(a as unknown[])),
[method, args] as const,
),
) as Promise<T>;
}
function buildBundle(): void {
fs.mkdirSync(path.dirname(BUNDLE_OUT), { recursive: true });
execSync(`bun build ${ENTRY} --outfile ${BUNDLE_OUT} --bundle --format=esm`, {
stdio: "pipe",
cwd: path.resolve(__dirname, ".."),
timeout: BUILD_MS,
});
}
function serve(): Promise<{ url: string; close: () => void }> {
const bundle = fs.readFileSync(BUNDLE_OUT, "utf-8");
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>ng-eventually ORM live read</title></head><body><div id="root"></div><script type="module" src="/orm-live-entry.js"></script></body></html>`;
return serveOnEphemeralPort((req, res) => {
if (req.url === "/orm-live-entry.js") {
res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" });
res.end(bundle);
} else {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(html);
}
});
}
async function main(): Promise<void> {
armSuiteDeadline("the ORM live-read measurement", SUITE_DEADLINE_MS);
const started = Date.now();
console.log("[orm-live] building the page bundle...");
try {
buildBundle();
} catch (e) {
console.error(
"[orm-live] the bundle did not build. `@ng-org/orm` is an OPTIONAL PEER of this " +
"package and `bun install` does not fetch it — install it in packages/polyfill " +
"before running this measurement. NOTHING was measured, so nothing is concluded.",
);
console.error(String((e as { stderr?: Buffer })?.stderr ?? e));
process.exit(1);
}
console.log("[orm-live] minting this run's wallet...");
const wallet = await mintBatchWallet("the ORM live-read measurement (e2e/orm-live-read.ts)");
const { url, close: closeServer } = await serve();
console.log(`[orm-live] page served at ${url}`);
let ctx: BrowserContext | null = null;
let page: Page | null = null;
try {
ctx = await launchWatchedContext("orm-live", wallet.dir);
page = await newPage("the ORM live-read page", ctx);
page.on("pageerror", (e) => console.error("[iframe error]", e.message));
page.on("console", (m) => {
if (m.type() === "error") console.error("[iframe console]", m.text());
});
const frame = await setupBrokerPage(page, url, WALLET.password);
await frame.waitForFunction(() => window.__sdk !== undefined, { timeout: 30_000 });
await frame.waitForFunction(() => window.__sdk!.status!() === "connected", {
timeout: 60_000,
});
const start = await sdk<{
doc: string;
subject: string;
seeded: number;
startError: string | null;
}>(frame, "liveStart");
check(CHECKS[0]!, start.seeded === 2, `doc=${start.doc} triples=${start.seeded}`);
// The verdict. A refused start never settles `readyPromise`, so this wait IS the
// measurement — and the bound is what turns "for ever" into a reported failure.
let readyMs: number | null = null;
try {
await frame.waitForFunction(() => window.__sdk!.liveReadyMs!() !== null, {
timeout: READY_MS,
});
readyMs = await sdk<number | null>(frame, "liveReadyMs");
} catch {
readyMs = null;
}
check(
CHECKS[1]!,
start.startError === null && readyMs !== null,
start.startError !== null
? `useShape threw: ${start.startError}`
: readyMs === null
? `readyPromise still pending after ${READY_MS / 1000}s — the engine refused the ` +
"start; the next row says what it expected instead"
: `ready in ${readyMs}ms`,
);
const items = await sdk<Array<{ id: string; graph: string; mark: string }>>(frame, "liveItems");
const seen = items[0];
check(
CHECKS[2]!,
items.length === 1 && seen?.id === start.subject && seen?.graph === start.doc,
`items=${JSON.stringify(items)}`,
);
const del = await sdk<{ deleted: boolean; error: string | null }>(frame, "liveDelete");
check(CHECKS[3]!, del.deleted === true && del.error === null, `deleted=${del.deleted} error=${del.error}`);
let remaining = await sdk<number>(frame, "liveSeededTriples");
try {
await within("the deletion reaching the document", DELETE_MS, async () => {
const deadline = Date.now() + DELETE_MS;
while (remaining > 0 && Date.now() < deadline) {
await page!.waitForTimeout(1000);
remaining = await sdk<number>(frame, "liveSeededTriples");
}
});
} catch {
/* the count below is the verdict either way */
}
check(CHECKS[4]!, remaining === 0, `triples left for the seeded subject: ${remaining}`);
} finally {
if (page) await closeQuietly("the ORM live-read page", () => page!.close());
if (ctx) await closeContext("orm-live", ctx);
closeServer();
wallet.discard();
}
const passed = results.filter((r) => r.ok).length;
const failed = results.length - passed;
console.log(
`\n══ ORM live-read summary: ${passed} passed, ${failed} failed, ${CHECKS.length} declared ` +
`— run ${((Date.now() - started) / 60000).toFixed(1)} min ══`,
);
if (failed > 0 || results.length !== CHECKS.length) {
for (const r of results.filter((x) => !x.ok)) console.log(` - ${r.name}: ${r.detail ?? ""}`);
process.exit(1);
}
}
main().catch((e) => {
console.error("[orm-live] fatal:", e);
process.exit(1);
});