1825d4d72f
Défaut visé : sur une session verifier fraîche (reconnexion), la lecture ancrée tape des repos pas encore ouverts dans self.repos → 0 ligne. Nouveau module `open-repo.ts` : `ensureReposOpen`/`ensureRepoOpen` ouvrent/souscrivent un repo via la primitive existante `subscribeDoc` (doc_subscribe) et attendent le push d'état initial (borné, sans polling) AVANT la lecture ancrée. Câblé en amont de `readUnion` (read-model) et `readScopeIndex` (store-registry). Idempotent par session (Set des NURI ouverts + Map in-flight ; ré-ouverture si la session injectée change). No-op si le `ng` injecté n'a pas doc_subscribe (fake unitaire). État — NON MERGÉ, incomplet : - AIDE le PROTECTED : la participation remonte au cold-start (mesuré app, timing un peu bruité). - N'ADRESSE PAS l'accueil PUBLIC : `readScopeIndex` de l'index public rend 0 même côté écrivain même-session — le fix ouvre le repo mais l'index reste vide. Le code d'index lib est prouvé scope-symétrique, donc la cause du public est ailleurs (broker/store ou chemin app), À MESURER SOUS BROKER (actuellement injoignable). Nécessité du fix elle-même non prouvée en e2e lib (broker down). gate broker-indépendant : tsc --noEmit propre ; bun test 91 pass (worker). Inclut le scaffolding e2e de repro reconnexion (broker/run/sdk-entry). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
383 lines
21 KiB
TypeScript
383 lines
21 KiB
TypeScript
/**
|
|
* Real-broker e2e runner for `@ng-eventually/client` — the polyfill's OWN suite,
|
|
* in the SDK domain (no application concepts), with a DEDICATED wallet.
|
|
*
|
|
* Standalone (NOT `bun test`), so it never mixes into the fake-ng unit suite.
|
|
* Run: `bun run e2e/run.ts` (or `bun run test:e2e` from packages/client).
|
|
*
|
|
* It: builds the SDK page bundle, creates/reuses the dedicated lib wallet, opens
|
|
* the broker iframe on the real broker with that wallet, waits for `window.__sdk`
|
|
* to connect, then drives every polyfill behavior through the bridge and asserts
|
|
* the real-broker outcomes. Each check is event-driven where reactivity matters
|
|
* (waitForFunction on a counter), never a blind sleep.
|
|
*/
|
|
|
|
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import type { Frame, Page, BrowserContext } from "playwright";
|
|
import {
|
|
buildBundle,
|
|
serveHarness,
|
|
ensureWallet,
|
|
launchWalletContext,
|
|
launchCleanProfileContext,
|
|
importWalletViaFile,
|
|
setupBrokerPage,
|
|
} from "./broker";
|
|
|
|
type Check = { name: string; ok: boolean; detail?: string };
|
|
const results: Check[] = [];
|
|
function record(name: string, ok: boolean, detail?: string): void {
|
|
results.push({ name, ok, detail });
|
|
const tag = ok ? "PASS" : "FAIL";
|
|
console.log(` [${tag}] ${name}${detail ? " — " + detail : ""}`);
|
|
}
|
|
function check(name: string, cond: boolean, detail?: string): void {
|
|
record(name, !!cond, detail);
|
|
}
|
|
async function step(name: string, fn: () => Promise<void>): Promise<void> {
|
|
try {
|
|
await fn();
|
|
} catch (e: any) {
|
|
record(name, false, "threw: " + String(e?.message ?? e));
|
|
}
|
|
}
|
|
|
|
// A short helper: call a bridge method inside the iframe.
|
|
function sdk<T>(frame: Frame, method: string, ...args: unknown[]): Promise<T> {
|
|
return frame.evaluate(
|
|
([m, a]) => (window as any).__sdk[m as string](...(a as unknown[])),
|
|
[method, args] as const,
|
|
) as Promise<T>;
|
|
}
|
|
function sdkGet<T>(frame: Frame, method: string, ...args: unknown[]): Promise<T> {
|
|
// Same as sdk() but for synchronous getters (no await inside the bridge).
|
|
return frame.evaluate(
|
|
([m, a]) => (window as any).__sdk[m as string](...(a as unknown[])),
|
|
[method, args] as const,
|
|
) as Promise<T>;
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
console.log("[e2e] building SDK page bundle...");
|
|
buildBundle();
|
|
console.log("[e2e] ensuring dedicated lib wallet...");
|
|
await ensureWallet();
|
|
const { url, close: closeServer } = await serveHarness();
|
|
console.log(`[e2e] harness served at ${url}`);
|
|
|
|
let ctx: BrowserContext | null = null;
|
|
let page: Page | null = null;
|
|
try {
|
|
ctx = await launchWalletContext();
|
|
page = await ctx.newPage();
|
|
page.on("pageerror", (e) => console.error("[iframe error]", e.message));
|
|
page.on("console", (m) => {
|
|
if (m.type() === "error") console.error("[iframe console]", m.text());
|
|
});
|
|
|
|
console.log("[e2e] loading SDK page in broker iframe...");
|
|
const frame = await setupBrokerPage(page, url);
|
|
|
|
// Wait for the bridge to exist + the broker session to connect.
|
|
await frame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 });
|
|
await frame.waitForFunction(() => (window as any).__sdk.status() === "connected", {
|
|
timeout: 60000,
|
|
});
|
|
const info = await sdkGet<any>(frame, "sessionInfo");
|
|
check("broker session connected", info?.session_id !== undefined && info?.session_id !== null, `session=${JSON.stringify(info)}`);
|
|
|
|
// ── docs primitives ─────────────────────────────────────────────────────
|
|
console.log("\n── docs primitives ──");
|
|
await step("docCreate returns a usable NURI", async () => {
|
|
const nuri = await sdk<string>(frame, "docCreate");
|
|
check("docCreate returns a usable NURI", typeof nuri === "string" && nuri.length > 0, nuri);
|
|
});
|
|
await step("SPARQL graph-behavior characterization (a/b/c)", async () => {
|
|
const rt = await sdk<any>(frame, "docRoundTrip");
|
|
// (a) THE load-bearing assertion fake-ng cannot verify: the anchored default-
|
|
// graph write (no GRAPH clause) round-trips through the real broker's
|
|
// repo_graph_name overlay. This is the canonical shape the lib writes, and
|
|
// what read-model / inbox / store-registry all rely on.
|
|
check(
|
|
"(a) anchored default-graph write (no GRAPH) ROUND-TRIPS",
|
|
rt.anchoredPresent === true,
|
|
`predicates=${JSON.stringify(rt.predicates)}`,
|
|
);
|
|
// (b) FINDING (reported, not gating): on THIS broker version an explicit
|
|
// `INSERT DATA { GRAPH <plainNuri> {…} }` ANCHORED to the same doc ALSO
|
|
// round-trips — readable both via the anchored default-graph read
|
|
// (explicitGraphPresent) AND via an explicit `GRAPH <plainNuri>` read
|
|
// (explicitViaNamedGraph). i.e. when anchored, the plain NURI resolves to the
|
|
// SAME repo graph — there is NO "phantom graph" here. The lib still writes the
|
|
// no-GRAPH default-graph shape as the always-safe canonical convention; this
|
|
// records what the broker actually does so the "phantom graph" comments can be
|
|
// re-checked against this broker version by re-running this harness.
|
|
record(
|
|
"(b) [finding] explicit GRAPH <plainNuri> ANCHORED resolves to the same repo (no phantom graph)",
|
|
true,
|
|
`defaultGraphRead=${rt.explicitGraphPresent} namedGraphRead=${rt.explicitViaNamedGraph} (informational)`,
|
|
);
|
|
// (c) FINDING: the ANCHORLESS `GRAPH ?g { … }` union scan spans EVERY named
|
|
// graph in the session store (it saw BOTH doc A's and doc B's graphs). This is
|
|
// the O(wallet-size) cost the read path avoids by reading each doc with its own
|
|
// anchored default-graph query. Reported, not gating (it is a perf property of
|
|
// the union, not a correctness assertion of the lib's write/read shape).
|
|
const us = rt.unionSpan ?? {};
|
|
record(
|
|
"(c) [finding] anchorless GRAPH ?g scan spans ALL named graphs (O(wallet) union)",
|
|
true,
|
|
us.graphCount === -1
|
|
? `anchorless scan errored: ${us.error} (union claim NOT re-verified here)`
|
|
: `sawDocA=${us.sawDocA} sawDocB=${us.sawDocB} graphCount=${us.graphCount} (both ⇒ union spans all graphs)`,
|
|
);
|
|
});
|
|
|
|
// ── read-model ──────────────────────────────────────────────────────────
|
|
console.log("\n── read-model ──");
|
|
await step("readUnion over N docs → per-doc subjects", async () => {
|
|
const r = await sdk<any>(frame, "readUnionOverDocs", 3, false);
|
|
check("readUnion returns one subject per doc", r.subjectCount === 3, `subjects=${r.subjectCount}/3`);
|
|
});
|
|
await step("readUnion per-doc tolerance (bad NURI skipped)", async () => {
|
|
const r = await sdk<any>(frame, "readUnionOverDocs", 2, true);
|
|
check("bad NURI does not abort the batch", r.subjectCount === 2, `subjects=${r.subjectCount}/2 (+1 bad)`);
|
|
});
|
|
await step("readUnion cap gate", async () => {
|
|
const r = await sdk<any>(frame, "readUnionCapGate");
|
|
check("cap gate drops doc for stranger, keeps for owner", r.strangerCount === 0 && r.ownerCount === 1, `stranger=${r.strangerCount} owner=${r.ownerCount}`);
|
|
});
|
|
|
|
// ── reactivity (doc_subscribe) ──────────────────────────────────────────
|
|
console.log("\n── reactivity (doc_subscribe) ──");
|
|
await step("subscribeDoc initial + on-write", async () => {
|
|
const { doc } = await sdk<any>(frame, "subscribeDocStart", "h1");
|
|
// initial state push (event-driven wait)
|
|
await frame.waitForFunction(() => (window as any).__sdk.subscribeCount("h1") >= 1, { timeout: 20000 });
|
|
const initial = await sdkGet<number>(frame, "subscribeCount", "h1");
|
|
check("subscribeDoc fires on initial state", initial >= 1, `count=${initial}`);
|
|
// subsequent real write → another push
|
|
await sdk(frame, "writeTo", doc, "w1");
|
|
await frame.waitForFunction(
|
|
(base) => (window as any).__sdk.subscribeCount("h1") > (base as number),
|
|
initial,
|
|
{ timeout: 20000 },
|
|
);
|
|
const afterWrite = await sdkGet<number>(frame, "subscribeCount", "h1");
|
|
check("subscribeDoc fires on a subsequent write", afterWrite > initial, `count=${afterWrite} (>${initial})`);
|
|
// unsubscribe stops callbacks
|
|
await sdk(frame, "subscribeStop", "h1");
|
|
const frozen = await sdkGet<number>(frame, "subscribeCount", "h1");
|
|
await sdk(frame, "writeTo", doc, "w2");
|
|
await page!.waitForTimeout(3000);
|
|
const afterUnsub = await sdkGet<number>(frame, "subscribeCount", "h1");
|
|
check("unsubscribe stops callbacks", afterUnsub === frozen, `count stayed ${afterUnsub}`);
|
|
});
|
|
await step("subscribeDocs per-doc isolation", async () => {
|
|
const { good } = await sdk<any>(frame, "subscribeDocsStart");
|
|
await frame.waitForFunction(() => (window as any).__sdk.multiSubCounts().good >= 1, { timeout: 20000 });
|
|
const base = await sdkGet<any>(frame, "multiSubCounts");
|
|
await sdk(frame, "writeTo", good, "mw1");
|
|
await frame.waitForFunction(
|
|
(b) => (window as any).__sdk.multiSubCounts().good > (b as number),
|
|
base.good,
|
|
{ timeout: 20000 },
|
|
);
|
|
const after = await sdkGet<any>(frame, "multiSubCounts");
|
|
check("good doc fires despite a dead doc in the set", after.good > base.good, `good=${after.good} bad=${after.bad}`);
|
|
await sdk(frame, "multiSubStop");
|
|
});
|
|
|
|
// ── inbox ───────────────────────────────────────────────────────────────
|
|
console.log("\n── inbox ──");
|
|
await step("inbox post → read round-trip", async () => {
|
|
const r = await sdk<any>(frame, "inboxPostRead", { k: "a" }, { k: "b" });
|
|
const payloads = (r.deposits || []).map((d: any) => JSON.stringify(d.payload));
|
|
check(
|
|
"post then read returns both deposits (sorted)",
|
|
r.deposits.length === 2 && payloads.includes('{"k":"a"}') && payloads.includes('{"k":"b"}'),
|
|
`deposits=${r.deposits.length}`,
|
|
);
|
|
});
|
|
await step("inbox watch fires on deposit", async () => {
|
|
await sdk(frame, "inboxWatchStart");
|
|
await frame.waitForFunction(() => (window as any).__sdk.inboxWatchState().fires >= 1, { timeout: 20000 });
|
|
const base = await sdkGet<any>(frame, "inboxWatchState");
|
|
await sdk(frame, "inboxWatchDeposit", { landed: true });
|
|
await frame.waitForFunction(
|
|
(b) => (window as any).__sdk.inboxWatchState().fires > (b as number),
|
|
base.fires,
|
|
{ timeout: 20000 },
|
|
);
|
|
const after = await sdkGet<any>(frame, "inboxWatchState");
|
|
check("watch fires when a deposit lands", after.fires > base.fires && after.lastLen >= 1, `fires=${after.fires} lastLen=${after.lastLen}`);
|
|
await sdk(frame, "inboxWatchStop");
|
|
});
|
|
await step("inbox spoof guard", async () => {
|
|
const r = await sdk<any>(frame, "inboxSpoofGuard");
|
|
check("post as another principal is rejected; self + anon allowed", r.spoofRejected && r.selfOk && r.anonOk, `spoof=${r.spoofRejected} self=${r.selfOk} anon=${r.anonOk}`);
|
|
});
|
|
|
|
// ── discovery index ─────────────────────────────────────────────────────
|
|
console.log("\n── discovery index ──");
|
|
await step("discovery submit → read", async () => {
|
|
const ref = { doc: "did:ng:o:some-public-doc", title: "t" };
|
|
const r = await sdk<any>(frame, "discoverySubmitRead", ref);
|
|
const refs = (r.entries || []).map((e: any) => JSON.stringify(e.ref));
|
|
check("submitToIndex then readIndex returns the entry", refs.includes(JSON.stringify(ref)), `entries=${r.entries.length}`);
|
|
});
|
|
await step("discovery watchIndex fires reactively", async () => {
|
|
await sdk(frame, "discoveryWatchStart");
|
|
await frame.waitForFunction(() => (window as any).__sdk.discoveryWatchState().fires >= 1, { timeout: 20000 });
|
|
const base = await sdkGet<any>(frame, "discoveryWatchState");
|
|
await sdk(frame, "discoverySubmit", { doc: "did:ng:o:doc2", title: "t2", n: Date.now() });
|
|
await frame.waitForFunction(
|
|
(b) => (window as any).__sdk.discoveryWatchState().fires > (b as number),
|
|
base.fires,
|
|
{ timeout: 20000 },
|
|
);
|
|
const after = await sdkGet<any>(frame, "discoveryWatchState");
|
|
check("watchIndex fires on a new submission", after.fires > base.fires, `fires=${after.fires}`);
|
|
await sdk(frame, "discoveryWatchStop");
|
|
});
|
|
await step("reserved @index account isolation", async () => {
|
|
const r = await sdk<any>(frame, "discoveryIndexIsolation");
|
|
check("user '@index' resolves disjoint from the reserved index owner", r.disjoint === true, `disjoint=${r.disjoint}`);
|
|
});
|
|
|
|
// ── store-registry ──────────────────────────────────────────────────────
|
|
console.log("\n── store-registry ──");
|
|
await step("ensureAccount idempotent", async () => {
|
|
const r = await sdk<any>(frame, "ensureAccountIdempotent", "@alice-" + Date.now());
|
|
check("ensureAccount returns the same 3 docs on repeat", r.same === true, `same=${r.same}`);
|
|
});
|
|
await step("createEntityDoc + listMyEntityDocs bounded to one account", async () => {
|
|
const t = Date.now();
|
|
const r = await sdk<any>(frame, "entityDocsBounded", "@ea-" + t, "@eb-" + t);
|
|
check("listMyEntityDocs lists A's docs and does NOT leak B's", r.hasA1 && r.hasA2 && !r.leaksB, `A1=${r.hasA1} A2=${r.hasA2} leaksB=${r.leaksB} listA=${r.listA.length}`);
|
|
});
|
|
await step("scope resolvers", async () => {
|
|
const r = await sdk<any>(frame, "scopeResolvers");
|
|
check("scope resolvers return NURIs (private distinct from protected/public)", !!r.priv && !!r.prot && !!r.pub && r.priv !== r.prot, `priv=${r.priv?.slice(0,16)}… prot=${r.prot?.slice(0,16)}…`);
|
|
});
|
|
|
|
// ── caps / read-filter (in-memory cap model) ────────────────────────────
|
|
console.log("\n── caps / read-filter (in-memory cap model) ──");
|
|
await step("read-filter: protected hidden from stranger", async () => {
|
|
const r = await sdk<any>(frame, "capsReadFilter");
|
|
const ownerSeesProt = r.ownerView.includes("protected-item");
|
|
const strangerHiddenProt = !r.strangerView.includes("protected-item");
|
|
const bothSeePublic = r.ownerView.includes("public-item") && r.strangerView.includes("public-item");
|
|
const bothSeeUngoverned = r.ownerView.includes("ungoverned-item") && r.strangerView.includes("ungoverned-item");
|
|
check("owner reads protected; stranger does not; public+ungoverned visible to both", ownerSeesProt && strangerHiddenProt && bothSeePublic && bothSeeUngoverned, `owner=${JSON.stringify(r.ownerView)} stranger=${JSON.stringify(r.strangerView)}`);
|
|
});
|
|
await step("read-filter: directed grant reveals the doc", async () => {
|
|
const r = await sdk<any>(frame, "capsDirectedGrant");
|
|
check("grantRead reveals the protected doc to the grantee", r.before === 0 && r.after === 1, `before=${r.before} after=${r.after}`);
|
|
});
|
|
|
|
// ── accounts (IdentityStore) ────────────────────────────────────────────
|
|
console.log("\n── accounts (IdentityStore) ──");
|
|
await step("IdentityStore set/get/clear", async () => {
|
|
const setr = await sdk<string>(frame, "identitySet", "@ident-user");
|
|
const got = await sdkGet<string>(frame, "identityGet");
|
|
const cleared = await sdk<string | null>(frame, "identityClear");
|
|
check("IdentityStore set→get→clear", setr === "@ident-user" && got === "@ident-user" && cleared === null, `set=${setr} get=${got} cleared=${cleared}`);
|
|
});
|
|
|
|
// ── reconnection cold-start (real-broker regression) ─────────────────────
|
|
// Phase 1 (THIS session): seed — create a per-entity doc under (id, protected)
|
|
// and write a marker triple (`protected` carries participations). Also EXPORT the
|
|
// wallet `.ngw` so phase 2 can re-import the SAME wallet into a CLEAN browser
|
|
// profile.
|
|
//
|
|
// Phase 2 (the faithful reconnect): import the wallet into a BRAND-NEW empty
|
|
// profile dir (no local IndexedDB copy of the repos) and open a fresh SDK session
|
|
// over it — the repos exist on the BROKER but NOT in this profile's local cache,
|
|
// so this is a true reconnect (not a same-profile relaunch, which masks the gap by
|
|
// eagerly rehydrating repos from local storage). Then re-read purely from the
|
|
// wallet and assert the marker comes back.
|
|
//
|
|
// FINDING (recorded, see the digest): on THIS SDK/broker version the marker also
|
|
// comes back WITHOUT the open-before-read heal — the broker-login bootstrap opens
|
|
// the user's repos before the read (the `rawAnchoredNoOpen` detail below shows the
|
|
// bare anchored query already resolves rows). So this test is a real-broker
|
|
// REGRESSION guard for reconnection reads, NOT a fail-without-the-fix proof; the
|
|
// cold-start the fix targets was diagnosed in the app and does not reproduce
|
|
// through this harness's login path.
|
|
console.log("\n── reconnection cold-start (real-broker regression) ──");
|
|
await step("fresh session (clean profile, same wallet) re-reads a persisted entity doc", async () => {
|
|
const reconId = "@recon-" + Date.now();
|
|
const scope = "protected";
|
|
const seed = await sdk<any>(frame, "reconnectSeed", reconId, scope);
|
|
check(
|
|
"seed: entity doc created + listed in the seeding session",
|
|
seed.listedInSeed.includes(seed.entityNuri),
|
|
`entity=${String(seed.entityNuri).slice(0, 24)}… listedInSeed=${seed.listedInSeed.length} origSession=${info?.session_id}`,
|
|
);
|
|
|
|
// Export the wallet file and materialize it for the clean-profile import.
|
|
const exp = await sdk<any>(frame, "exportWalletFile");
|
|
const ngwPath = path.join(os.tmpdir(), `ng-eventually-recon-${Date.now()}.ngw`);
|
|
fs.writeFileSync(ngwPath, Buffer.from(exp.b64, "base64"));
|
|
|
|
let cleanCtx: BrowserContext | null = null;
|
|
let cleanDir: string | null = null;
|
|
let cleanPage: Page | null = null;
|
|
try {
|
|
const launched = await launchCleanProfileContext();
|
|
cleanCtx = launched.ctx;
|
|
cleanDir = launched.dir;
|
|
cleanPage = await cleanCtx.newPage();
|
|
cleanPage.on("pageerror", (e) => console.error("[iframe error:clean]", e.message));
|
|
cleanPage.on("console", (m) => { if (m.type() === "error") console.error("[iframe console:clean]", m.text()); });
|
|
|
|
// Import the SAME wallet into the empty profile (broker-only repos), then open
|
|
// the SDK page in a fresh broker session over it.
|
|
await importWalletViaFile(cleanPage, ngwPath);
|
|
const cleanFrame = await setupBrokerPage(cleanPage, url);
|
|
await cleanFrame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 });
|
|
await cleanFrame.waitForFunction(() => (window as any).__sdk.status() === "connected", { timeout: 60000 });
|
|
const cleanInfo = await sdkGet<any>(cleanFrame, "sessionInfo");
|
|
check(
|
|
"clean-profile session connected (fresh verifier, broker-only repos)",
|
|
cleanInfo?.session_id !== undefined && cleanInfo?.session_id !== null,
|
|
`session=${cleanInfo?.session_id}`,
|
|
);
|
|
|
|
const r = await sdk<any>(cleanFrame, "reconnectRead", reconId, scope, seed.entityNuri, seed.marker);
|
|
check(
|
|
"fresh clean-profile session re-reads the persisted marker (reconnection regression)",
|
|
r.markerPresent === true,
|
|
`rawAnchoredNoOpen=${r.rawRowCount} listed=${r.listedCount} foundEntity=${r.foundEntity} subjects=${r.subjectCount} markerPresent=${r.markerPresent}`,
|
|
);
|
|
} finally {
|
|
try { if (cleanPage) await cleanPage.close(); } catch { /* ignore */ }
|
|
try { if (cleanCtx) await cleanCtx.close(); } catch { /* ignore */ }
|
|
try { if (cleanDir) fs.rmSync(cleanDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
try { fs.rmSync(ngwPath, { force: true }); } catch { /* ignore */ }
|
|
}
|
|
});
|
|
} finally {
|
|
try { if (page) await page.close(); } catch { /* ignore */ }
|
|
try { if (ctx) await ctx.close(); } catch { /* ignore */ }
|
|
closeServer();
|
|
}
|
|
|
|
// ── Summary ───────────────────────────────────────────────────────────────
|
|
const passed = results.filter((r) => r.ok).length;
|
|
const failed = results.length - passed;
|
|
console.log(`\n══ SDK e2e summary: ${passed} passed, ${failed} failed, ${results.length} total ══`);
|
|
if (failed > 0) {
|
|
console.log("Failures:");
|
|
for (const r of results.filter((x) => !x.ok)) console.log(` - ${r.name}: ${r.detail ?? ""}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error("[e2e] fatal:", e);
|
|
process.exit(1);
|
|
});
|