3a8941159f
Le `useShape` publie enveloppait le resultat du hook dans le filtre ecrit pour une COLLECTION. Depuis que `@ng-org/orm` renvoie un `UseShapeResult` autour du set, `[...resultat]` et `.size` levaient, et `data`, `promise` et `subscription` revenaient BRUTS - chacun tous les objets de toutes les identites, sans que rien ne casse. Aucun faux de ce depot ne renvoyait la forme livree, donc aucune garde ne pouvait le voir. Deux vues au lieu d-une : celle de la collection, et celle de l-enveloppe que `surface/use-shape.ts` applique. `data` et `promise` sont filtres, `subscription` est refuse (c-est le handle de l-ORM, que cette surface ne publie pas), les deux commandes de page passent. Le defaut par TYPE et non par une liste de noms : un primitif ne peut atteindre aucun objet et passe, un objet ou une fonction refuse tant qu-il n-existe pas de forme filtree ici. C-est pourquoi `isLoading` n-est nomme nulle part, et pourquoi un membre qu-une version suivante ajoutera est deja tranche. `size` et `length` comptent ce que le porteur peut lire. `UseShapeLike` etait `(...args: any[]) => any`, ce qui est la raison pour laquelle les deux typechecks restaient a 0 pendant que la fuite etait ouverte : aucune declaration ne disait ce que le hook rend. Il rend `ShapeResult`, releve sur le `.d.ts` installe, et le vrai `reactUseShape` y est assignable sans cast. Le faux partage `test/use-shape-fake.ts` renvoie la forme livree et c-est celui que les deux gardes injectent - unitaire et e2e. La garantie du contrat et le test qui l-epingle arrivent ensemble ; `1.0.0-dev.11`, majeur.
267 lines
11 KiB
TypeScript
267 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 collection an application reads off the `data` member of what the ORM
|
|
* hook hands it, and a `.delete()` on that collection 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` answers 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, so an application that does not name it gets nothing - but THIS " +
|
|
"checkout declares it in packages/polyfill devDependencies, and `pnpm install` " +
|
|
"does fetch that. So the ORM is not missing by design here: run `pnpm install` " +
|
|
"at the repository root. 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);
|
|
});
|