@specific.dev/spectest 0.19.1 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/browser.ts +62 -18
- package/src/daemon.ts +71 -2
- package/src/ids.ts +89 -0
- package/src/index.ts +1 -6
- package/src/mobile.ts +9 -5
package/package.json
CHANGED
package/src/browser.ts
CHANGED
|
@@ -33,6 +33,7 @@ import { readFileSync } from "node:fs";
|
|
|
33
33
|
import path from "node:path";
|
|
34
34
|
import { fileURLToPath } from "node:url";
|
|
35
35
|
|
|
36
|
+
import { generateId } from "./ids.js";
|
|
36
37
|
import { recordBrowser, reserveEvent, truncateUtf8 } from "./recorder.js";
|
|
37
38
|
import { wrap } from "./inspect.js";
|
|
38
39
|
import type { Wrapped } from "./inspect.js";
|
|
@@ -76,13 +77,12 @@ export interface BrowserOptions {
|
|
|
76
77
|
recorder?: BrowserSessionRecorder | null;
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
quality?: number;
|
|
80
|
+
/** Decoded byte count of a base64 string, without decoding it. */
|
|
81
|
+
function base64ByteLength(b64: string): number {
|
|
82
|
+
let padding = 0;
|
|
83
|
+
if (b64.endsWith("==")) padding = 2;
|
|
84
|
+
else if (b64.endsWith("=")) padding = 1;
|
|
85
|
+
return (b64.length * 3) / 4 - padding;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
/** One Browser-action's worth of rrweb events, in the order rrweb emitted them. */
|
|
@@ -114,6 +114,20 @@ export interface BrowserSessionRecorder {
|
|
|
114
114
|
recordStep(step: BrowserSessionStep): void;
|
|
115
115
|
/** Optional: called whenever `Browser.navigate(url)` is invoked. */
|
|
116
116
|
noteNavigation?(url: string): void;
|
|
117
|
+
/**
|
|
118
|
+
* Optional: register a captured artifact (screenshot bytes) for upload.
|
|
119
|
+
* Present only in eval context — the daemon wires it to the per-eval
|
|
120
|
+
* collector, and its absence is what makes `screenshot()` throw during
|
|
121
|
+
* test runs (artifacts are eval-only for now). Throws when the eval's
|
|
122
|
+
* artifact byte budget is exhausted.
|
|
123
|
+
*/
|
|
124
|
+
registerArtifact?(artifact: {
|
|
125
|
+
id: string;
|
|
126
|
+
kind: "screenshot";
|
|
127
|
+
contentType: string;
|
|
128
|
+
sizeBytes: number;
|
|
129
|
+
bytesBase64: string;
|
|
130
|
+
}): void;
|
|
117
131
|
}
|
|
118
132
|
|
|
119
133
|
/**
|
|
@@ -200,8 +214,14 @@ export interface Browser {
|
|
|
200
214
|
forward(): Promise<void>;
|
|
201
215
|
/** Reload the current page. */
|
|
202
216
|
reload(): Promise<void>;
|
|
203
|
-
/**
|
|
204
|
-
|
|
217
|
+
/**
|
|
218
|
+
* Capture a PNG screenshot of the viewport and upload it as a
|
|
219
|
+
* downloadable **artifact**. Resolves to the artifact's `art_…` id —
|
|
220
|
+
* fetch the image locally with `spectest artifact download <id>`.
|
|
221
|
+
* Only available inside an eval (`spectest_eval` / `spectest env eval`);
|
|
222
|
+
* throws with a clear message during test runs.
|
|
223
|
+
*/
|
|
224
|
+
screenshot(): Promise<string>;
|
|
205
225
|
/**
|
|
206
226
|
* Destroy the underlying view. Idempotent; drains any pending rrweb
|
|
207
227
|
* events first. For the persistent session behind `ctx.browser()` /
|
|
@@ -1521,16 +1541,39 @@ function buildBackend(
|
|
|
1521
1541
|
await holder.page.reload();
|
|
1522
1542
|
});
|
|
1523
1543
|
},
|
|
1524
|
-
async screenshot(
|
|
1525
|
-
const
|
|
1526
|
-
return instrumented("screenshot",
|
|
1527
|
-
//
|
|
1528
|
-
//
|
|
1544
|
+
async screenshot() {
|
|
1545
|
+
const fields: Partial<RecordableFields> = { format: "png" };
|
|
1546
|
+
return instrumented("screenshot", fields, async () => {
|
|
1547
|
+
// Artifacts are eval-only for now: the daemon wires a
|
|
1548
|
+
// registerArtifact sink onto eval sessions' recorders and nothing
|
|
1549
|
+
// else's, so its absence means "test run / recorder-less browser".
|
|
1550
|
+
const register = recorder?.registerArtifact;
|
|
1551
|
+
if (!register) {
|
|
1552
|
+
throw new Error(
|
|
1553
|
+
"screenshot() uploads the capture as a downloadable artifact and is " +
|
|
1554
|
+
"currently only available inside an eval (spectest_eval / `spectest env eval`) — " +
|
|
1555
|
+
"it cannot be used in test runs.",
|
|
1556
|
+
);
|
|
1557
|
+
}
|
|
1558
|
+
// Raw CDP rather than page.screenshot(): captures the viewport
|
|
1559
|
+
// exactly like the pre-Playwright backend.
|
|
1529
1560
|
const res = (await holder.cdp.send("Page.captureScreenshot", {
|
|
1530
|
-
format,
|
|
1531
|
-
...(format === "png" ? {} : { quality: options?.quality ?? 90 }),
|
|
1561
|
+
format: "png",
|
|
1532
1562
|
} as never)) as { data: string };
|
|
1533
|
-
|
|
1563
|
+
const id = generateId("art");
|
|
1564
|
+
register({
|
|
1565
|
+
id,
|
|
1566
|
+
kind: "screenshot",
|
|
1567
|
+
contentType: "image/png",
|
|
1568
|
+
// CDP already hands us base64 — pass it through un-recoded and
|
|
1569
|
+
// derive the raw byte count from the encoding.
|
|
1570
|
+
sizeBytes: base64ByteLength(res.data),
|
|
1571
|
+
bytesBase64: res.data,
|
|
1572
|
+
});
|
|
1573
|
+
// Stamped onto the browser event before instrumented() records it —
|
|
1574
|
+
// same mutate-fields-inside-fn() precedent as waitFor's `attempts`.
|
|
1575
|
+
fields.artifactId = id;
|
|
1576
|
+
return id;
|
|
1534
1577
|
});
|
|
1535
1578
|
},
|
|
1536
1579
|
async close() {
|
|
@@ -1627,6 +1670,7 @@ interface RecordableFields {
|
|
|
1627
1670
|
dy: number;
|
|
1628
1671
|
x: number;
|
|
1629
1672
|
y: number;
|
|
1630
|
-
format:
|
|
1673
|
+
format: string;
|
|
1631
1674
|
attempts: number;
|
|
1675
|
+
artifactId: string;
|
|
1632
1676
|
}
|
package/src/daemon.ts
CHANGED
|
@@ -2994,15 +2994,71 @@ function newSessionId(idScope: string): string {
|
|
|
2994
2994
|
return idScope ? `${idScope}:${randomUUID()}` : randomUUID();
|
|
2995
2995
|
}
|
|
2996
2996
|
|
|
2997
|
+
/**
|
|
2998
|
+
* One artifact captured during an eval (screenshot bytes), shipped inline
|
|
2999
|
+
* (base64) on the `/eval` reply's `artifacts` array — parallel to
|
|
3000
|
+
* `browserSessions`. The control plane uploads each to S3, records the
|
|
3001
|
+
* metadata row, and strips `bytesBase64` before the reply reaches the
|
|
3002
|
+
* caller. The id is minted in-VM by `ids.ts` (see its fork-frozen-RNG
|
|
3003
|
+
* caveat) in the control plane's `art_0…` format.
|
|
3004
|
+
*/
|
|
3005
|
+
interface CapturedArtifact {
|
|
3006
|
+
id: string;
|
|
3007
|
+
kind: "screenshot";
|
|
3008
|
+
contentType: string;
|
|
3009
|
+
sizeBytes: number;
|
|
3010
|
+
bytesBase64: string;
|
|
3011
|
+
}
|
|
3012
|
+
|
|
3013
|
+
/**
|
|
3014
|
+
* Cumulative raw-byte cap on one eval's artifacts. The bytes ride the
|
|
3015
|
+
* `/eval` JSON reply base64'd (~1.33x), and the vm-agent hard-errors on
|
|
3016
|
+
* proxied daemon responses over 16 MB (the same cap that pushed /run's
|
|
3017
|
+
* replay bundles out-of-band — see REPLAY_BUNDLES). 8 MiB raw ≈ 10.7 MB
|
|
3018
|
+
* encoded leaves headroom for the reply's sessions/log; screenshots are
|
|
3019
|
+
* typically well under 2 MiB each. If artifacts ever need to grow past
|
|
3020
|
+
* this, move them to the parked-chunk side channel instead of raising it.
|
|
3021
|
+
*/
|
|
3022
|
+
const MAX_EVAL_ARTIFACT_BYTES = 8 * 1024 * 1024;
|
|
3023
|
+
|
|
3024
|
+
/**
|
|
3025
|
+
* Per-eval artifact sink. `register` throws (failing the screenshot() call,
|
|
3026
|
+
* never the eval) once the byte budget is exhausted.
|
|
3027
|
+
*/
|
|
3028
|
+
function newArtifactCollector(): {
|
|
3029
|
+
artifacts: CapturedArtifact[];
|
|
3030
|
+
register(artifact: CapturedArtifact): void;
|
|
3031
|
+
} {
|
|
3032
|
+
const artifacts: CapturedArtifact[] = [];
|
|
3033
|
+
let total = 0;
|
|
3034
|
+
return {
|
|
3035
|
+
artifacts,
|
|
3036
|
+
register(artifact) {
|
|
3037
|
+
total += artifact.sizeBytes;
|
|
3038
|
+
if (total > MAX_EVAL_ARTIFACT_BYTES) {
|
|
3039
|
+
throw new Error(
|
|
3040
|
+
`artifact byte budget for this eval exceeded (${MAX_EVAL_ARTIFACT_BYTES / (1024 * 1024)} MiB) — ` +
|
|
3041
|
+
"capture fewer screenshots per eval",
|
|
3042
|
+
);
|
|
3043
|
+
}
|
|
3044
|
+
artifacts.push(artifact);
|
|
3045
|
+
},
|
|
3046
|
+
};
|
|
3047
|
+
}
|
|
3048
|
+
|
|
2997
3049
|
/**
|
|
2998
3050
|
* Build the recorder sink + bookkeeping for a single Browser session.
|
|
2999
3051
|
* The returned `recorder` is what `openBrowser` writes into; the
|
|
3000
3052
|
* returned `record` is the in-flight session object the daemon owns.
|
|
3053
|
+
* `artifacts` (eval-only) wires `screenshot()`'s artifact registration —
|
|
3054
|
+
* test-run sessions don't pass it, which is exactly what makes
|
|
3055
|
+
* `screenshot()` throw outside eval.
|
|
3001
3056
|
*/
|
|
3002
3057
|
function newBrowserSession(
|
|
3003
3058
|
testStart: number,
|
|
3004
3059
|
idScope: string,
|
|
3005
3060
|
frame: "browser" | "mobile" = "browser",
|
|
3061
|
+
artifacts?: ReturnType<typeof newArtifactCollector>,
|
|
3006
3062
|
): {
|
|
3007
3063
|
recorder: BrowserSessionRecorder;
|
|
3008
3064
|
record: BrowserSessionRecord;
|
|
@@ -3027,6 +3083,9 @@ function newBrowserSession(
|
|
|
3027
3083
|
if (closed) return;
|
|
3028
3084
|
if (record.initialUrl === undefined) record.initialUrl = url;
|
|
3029
3085
|
},
|
|
3086
|
+
...(artifacts
|
|
3087
|
+
? { registerArtifact: (a: CapturedArtifact) => artifacts.register(a) }
|
|
3088
|
+
: {}),
|
|
3030
3089
|
},
|
|
3031
3090
|
markClosed() {
|
|
3032
3091
|
if (closed) return;
|
|
@@ -4001,6 +4060,9 @@ interface EvalResult {
|
|
|
4001
4060
|
browserSessions: BrowserSessionRecord[];
|
|
4002
4061
|
/** asciicast sessions for any ctx.terminal() call during the eval. */
|
|
4003
4062
|
terminalSessions: TerminalSessionRecord[];
|
|
4063
|
+
/** Artifacts captured during the eval (screenshot bytes, base64 inline —
|
|
4064
|
+
* stripped by the control plane before the reply reaches the caller). */
|
|
4065
|
+
artifacts: CapturedArtifact[];
|
|
4004
4066
|
error?: { message: string; stack?: string };
|
|
4005
4067
|
}
|
|
4006
4068
|
|
|
@@ -4125,11 +4187,14 @@ async function evalCode(
|
|
|
4125
4187
|
// live session — and a snapshot taken afterwards carries it.
|
|
4126
4188
|
const browserDetaches: Array<() => Promise<void>> = [];
|
|
4127
4189
|
const sessions: Array<ReturnType<typeof newBrowserSession>> = [];
|
|
4190
|
+
// Eval-only artifact sink — wiring it here (and nowhere in runOne) is
|
|
4191
|
+
// what gates screenshot() to eval context.
|
|
4192
|
+
const artifactCollector = newArtifactCollector();
|
|
4128
4193
|
let sharedBrowser: Browser | null = null;
|
|
4129
4194
|
const sharedMobiles = new Map<string, Mobile>();
|
|
4130
4195
|
const trackedOpenBrowser = async (opts?: BrowserOptions): Promise<Browser> => {
|
|
4131
4196
|
if (sharedBrowser) return sharedBrowser;
|
|
4132
|
-
const session = newBrowserSession(start, "eval");
|
|
4197
|
+
const session = newBrowserSession(start, "eval", "browser", artifactCollector);
|
|
4133
4198
|
sessions.push(session);
|
|
4134
4199
|
const { browser, attached, detach } = await acquirePersistentBrowser({
|
|
4135
4200
|
...(opts ?? {}),
|
|
@@ -4161,7 +4226,7 @@ async function evalCode(
|
|
|
4161
4226
|
}
|
|
4162
4227
|
const existing = sharedMobiles.get(app.url);
|
|
4163
4228
|
if (existing) return existing;
|
|
4164
|
-
const session = newBrowserSession(start, "eval", "mobile");
|
|
4229
|
+
const session = newBrowserSession(start, "eval", "mobile", artifactCollector);
|
|
4165
4230
|
sessions.push(session);
|
|
4166
4231
|
const { mobile, attached, detach, safeAreaInsets } = await openPersistentMobile({
|
|
4167
4232
|
url: app.url,
|
|
@@ -4310,6 +4375,8 @@ async function evalCode(
|
|
|
4310
4375
|
});
|
|
4311
4376
|
}
|
|
4312
4377
|
}
|
|
4378
|
+
// `artifacts` ships on the error branch too — a screenshot captured
|
|
4379
|
+
// before a snippet crash is exactly the evidence the caller wants.
|
|
4313
4380
|
return outcome.ok
|
|
4314
4381
|
? {
|
|
4315
4382
|
ok: true,
|
|
@@ -4319,6 +4386,7 @@ async function evalCode(
|
|
|
4319
4386
|
result: outcome.result,
|
|
4320
4387
|
browserSessions: sessions.map((s) => s.record),
|
|
4321
4388
|
terminalSessions,
|
|
4389
|
+
artifacts: artifactCollector.artifacts,
|
|
4322
4390
|
}
|
|
4323
4391
|
: {
|
|
4324
4392
|
ok: false,
|
|
@@ -4327,6 +4395,7 @@ async function evalCode(
|
|
|
4327
4395
|
installed,
|
|
4328
4396
|
browserSessions: sessions.map((s) => s.record),
|
|
4329
4397
|
terminalSessions,
|
|
4398
|
+
artifacts: artifactCollector.artifacts,
|
|
4330
4399
|
error: outcome.error,
|
|
4331
4400
|
};
|
|
4332
4401
|
}
|
package/src/ids.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Stripe-style resource ids minted in-VM — format-compatible with the
|
|
2
|
+
// control plane's `crates/control-plane/src/ids.rs` (`generate`):
|
|
3
|
+
// `<prefix>_0<20 chars>` where the chars are Crockford base32 (lowercase,
|
|
4
|
+
// minus the ambiguous i/l/o/u) and the `0` after the underscore is the
|
|
5
|
+
// format-version digit. If the scheme changes there, change it here in
|
|
6
|
+
// lockstep (`env.rs::valid_artifact_id` checks this exact shape at the
|
|
7
|
+
// trust boundary).
|
|
8
|
+
//
|
|
9
|
+
// LANDMINE — why the entropy here is NOT just `crypto.getRandomValues`:
|
|
10
|
+
// Bun serves those from a userspace pool that is FROZEN into snapshots, so
|
|
11
|
+
// sibling forks of one snapshot draw identical bytes (verified 2026-07-14:
|
|
12
|
+
// two forks' first screenshot() minted the same id). We therefore hash
|
|
13
|
+
// together, per mint:
|
|
14
|
+
// - 16 bytes read straight off /dev/urandom (bypasses Bun's pool; the
|
|
15
|
+
// guest kernel CRNG reseeds across restores — see the VM-determinism
|
|
16
|
+
// notes — so forks diverge),
|
|
17
|
+
// - Date.now() (wall clock is re-synced per post-mortem fork start) and
|
|
18
|
+
// performance.now() (sub-ms), which de-collide even if the kernel pool
|
|
19
|
+
// hasn't diverged yet,
|
|
20
|
+
// - a process-local counter (multiple mints in one tick).
|
|
21
|
+
// This makes duplicates practically impossible, but the control plane
|
|
22
|
+
// still treats an id here as untrusted: shape-validated, and a duplicate
|
|
23
|
+
// insert is flagged per-artifact rather than clobbering anything.
|
|
24
|
+
|
|
25
|
+
import { createHash } from "node:crypto";
|
|
26
|
+
import { closeSync, openSync, readSync } from "node:fs";
|
|
27
|
+
|
|
28
|
+
/** Crockford base32, lowercased, minus `i`/`l`/`o`/`u` — mirrors ids.rs. */
|
|
29
|
+
const ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
|
|
30
|
+
|
|
31
|
+
/** Random base32 chars after the version digit (100 bits of entropy). */
|
|
32
|
+
const RANDOM_LEN = 20;
|
|
33
|
+
|
|
34
|
+
let mintCounter = 0;
|
|
35
|
+
|
|
36
|
+
/** Mint a fresh id, e.g. `generateId("art")` → `art_0c3k9mpq…`. */
|
|
37
|
+
export function generateId(prefix: string): string {
|
|
38
|
+
const digest = createHash("sha256")
|
|
39
|
+
.update(urandom(16))
|
|
40
|
+
.update(String(Date.now()))
|
|
41
|
+
.update(String(performance.now()))
|
|
42
|
+
.update(String(mintCounter++))
|
|
43
|
+
.digest();
|
|
44
|
+
return `${prefix}_0${base32Crockford(digest.subarray(0, 16)).slice(0, RANDOM_LEN)}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Read `n` bytes directly from /dev/urandom (NOT Bun's cached pool). */
|
|
48
|
+
function urandom(n: number): Uint8Array {
|
|
49
|
+
const buf = new Uint8Array(n);
|
|
50
|
+
try {
|
|
51
|
+
const fd = openSync("/dev/urandom", "r");
|
|
52
|
+
try {
|
|
53
|
+
let off = 0;
|
|
54
|
+
while (off < n) {
|
|
55
|
+
const read = readSync(fd, buf, off, n - off, null);
|
|
56
|
+
if (read <= 0) break;
|
|
57
|
+
off += read;
|
|
58
|
+
}
|
|
59
|
+
} finally {
|
|
60
|
+
closeSync(fd);
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
// Non-Linux host (SDK types compiled locally) or exotic failure — the
|
|
64
|
+
// clock + counter inputs still make the hash unique in practice.
|
|
65
|
+
crypto.getRandomValues(buf);
|
|
66
|
+
}
|
|
67
|
+
return buf;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Encode bytes as Crockford base32 (no padding). 16 bytes → 26 chars. */
|
|
71
|
+
function base32Crockford(bytes: Uint8Array): string {
|
|
72
|
+
let out = "";
|
|
73
|
+
let acc = 0;
|
|
74
|
+
let bits = 0;
|
|
75
|
+
for (const b of bytes) {
|
|
76
|
+
acc = (acc << 8) | b;
|
|
77
|
+
bits += 8;
|
|
78
|
+
while (bits >= 5) {
|
|
79
|
+
bits -= 5;
|
|
80
|
+
out += ALPHABET[(acc >> bits) & 0x1f];
|
|
81
|
+
}
|
|
82
|
+
// Keep the accumulator within 32-bit safe range for the `<<` operator.
|
|
83
|
+
acc &= (1 << bits) - 1;
|
|
84
|
+
}
|
|
85
|
+
if (bits > 0) {
|
|
86
|
+
out += ALPHABET[(acc << (5 - bits)) & 0x1f];
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -44,12 +44,7 @@ export { SQL, type SqlClient, type SqlOptions } from "./sql.js";
|
|
|
44
44
|
export { RedisClient, type RedisClientLike, type RedisOptions } from "./redis.js";
|
|
45
45
|
export { S3Client, type S3ClientLike, type S3File, type S3ClientOptions } from "./s3.js";
|
|
46
46
|
|
|
47
|
-
export type {
|
|
48
|
-
Browser,
|
|
49
|
-
BrowserOptions,
|
|
50
|
-
ScreenshotFormat,
|
|
51
|
-
ScreenshotOptions,
|
|
52
|
-
} from "./browser.js";
|
|
47
|
+
export type { Browser, BrowserOptions } from "./browser.js";
|
|
53
48
|
|
|
54
49
|
import type { Browser, BrowserOptions } from "./browser.js";
|
|
55
50
|
|
package/src/mobile.ts
CHANGED
|
@@ -23,7 +23,6 @@ import type {
|
|
|
23
23
|
BrowserSessionRecorder,
|
|
24
24
|
MobileBackend,
|
|
25
25
|
SafeAreaInsets,
|
|
26
|
-
ScreenshotOptions,
|
|
27
26
|
} from "./browser.js";
|
|
28
27
|
import type { Locator, Page } from "playwright-core";
|
|
29
28
|
import type { Wrapped } from "./inspect.js";
|
|
@@ -312,8 +311,13 @@ export interface Mobile {
|
|
|
312
311
|
expression: string,
|
|
313
312
|
options?: { timeoutMs?: number; intervalMs?: number },
|
|
314
313
|
): Promise<Wrapped<T>>;
|
|
315
|
-
/**
|
|
316
|
-
|
|
314
|
+
/**
|
|
315
|
+
* Capture a PNG screenshot of the viewport and upload it as a
|
|
316
|
+
* downloadable artifact. Resolves to the artifact's `art_…` id
|
|
317
|
+
* (`spectest artifact download <id>`). Eval-only for now — throws with a
|
|
318
|
+
* clear message during test runs.
|
|
319
|
+
*/
|
|
320
|
+
screenshot(): Promise<string>;
|
|
317
321
|
/** Close the session. Idempotent; drains pending rrweb events. */
|
|
318
322
|
close(): Promise<void>;
|
|
319
323
|
}
|
|
@@ -386,8 +390,8 @@ function wrapMobile(backend: MobileBackend): Mobile {
|
|
|
386
390
|
waitFor(description, expression, options) {
|
|
387
391
|
return backend.waitFor(description, expression, options);
|
|
388
392
|
},
|
|
389
|
-
screenshot(
|
|
390
|
-
return backend.screenshot(
|
|
393
|
+
screenshot() {
|
|
394
|
+
return backend.screenshot();
|
|
391
395
|
},
|
|
392
396
|
close() {
|
|
393
397
|
return backend.close();
|