308 lines
14 KiB
TypeScript
308 lines
14 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`: this is the one measurement that needs
|
|
* `@ng-org/orm`, an OPTIONAL PEER this package does not install (`bun.lock`,
|
|
* `optionalPeers`). Folding it into the 42-check suite would make that suite unbuildable on
|
|
* a checkout without it. Here, a missing ORM fails the build of this runner alone, with the
|
|
* reason said out loud — which is the honest outcome, because a measurement that did not
|
|
* run must not be reported as a verdict either way.
|
|
*
|
|
* 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, 2026-09-16, and why the last row exists ─────────────────
|
|
* The read does NOT start, and the reason is not the one
|
|
* `bug_useshape-live-read-needs-a-newer-broker` names. That leaf's mechanism — a broker
|
|
* built before `orm_start` was renamed `orm_start_graph` — is REFUTED here: the method
|
|
* resolves, and the call fails INSIDE it, on an argument. The engine answers
|
|
* `Deserialization error of config Error: invalid type: JsValue(Function), expected any
|
|
* valid JSON value`.
|
|
*
|
|
* The last row establishes where that `config` sits, because the position is the whole
|
|
* diagnosis: calling the SDK member directly with `{}` in position 4 is ACCEPTED, and the
|
|
* engine then streams back. So the deployed `orm_start_graph` is
|
|
* `(graph_scope, subject_scope, shapeType, session_id, config, callback)`, and the
|
|
* five-argument call the published `@ng-org/orm` makes lands its callback in `config`.
|
|
* That arity is in no commit of `nextgraph-rs` at the read revision — the one window that
|
|
* had a `config` at all (2026-04-12 to 2026-04-21) put it LAST, after the callback. The
|
|
* deployment is therefore AHEAD of the client packages this repository pins, not behind
|
|
* them, which inverts what the leaf says to do about it.
|
|
*
|
|
* The run is EXPECTED TO EXIT NON-ZERO while that holds: it is a measurement of a
|
|
* deployment, not a gate on this package. Green again means the two ends agree, and the
|
|
* bug leaf can close.
|
|
*/
|
|
|
|
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",
|
|
"[finding] the ARITY the deployed `orm_start_graph` expects",
|
|
];
|
|
|
|
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 : ""}`);
|
|
}
|
|
/**
|
|
* A row that REPORTS rather than judges — `e2e/run.ts`'s convention for characterising the
|
|
* broker rather than asserting about this package. The last row is one: whichever arity the
|
|
* deployment expects, it is upstream's to change, so failing on it would only teach whoever
|
|
* reads the report to ignore this file.
|
|
*/
|
|
function record(name: string, detail: string): void {
|
|
results.push({ name, ok: true, detail });
|
|
console.log(` [INFO] ${name} — ${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}`);
|
|
|
|
// Only worth asking when the published call did NOT start: it is the diagnosis of
|
|
// that failure, and a deployment that answers the published arity has nothing to
|
|
// characterise. Since `@ng-org/web@0.1.2-alpha.14` the published call is itself the
|
|
// six-argument one, so this asks whether the engine refuses a start the client got
|
|
// right - no longer whether the client was sending the wrong number.
|
|
if (readyMs === null) {
|
|
const withConfig = await sdk<{ doc: string; error: string | null }>(
|
|
frame,
|
|
"liveStartWithConfig",
|
|
);
|
|
record(
|
|
CHECKS[5]!,
|
|
withConfig.error === null
|
|
? "six arguments with `{}` in position 4 are ACCEPTED — the deployed " +
|
|
"`orm_start_graph` takes a `config` BEFORE the callback, which is what the " +
|
|
"published client now sends, so the refusal is not an arity mismatch"
|
|
: `six arguments were refused too: ${withConfig.error}`,
|
|
);
|
|
} else {
|
|
record(CHECKS[5]!, "not asked — the published six-argument call started");
|
|
}
|
|
} 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);
|
|
});
|