@frockbot/plugin-fly-sprite 0.0.0 → 0.1.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.
@@ -0,0 +1,618 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { ComputerError } from "@frockbot/computer-core";
3
+ import {
4
+ COMPUTER_HOST_ROUTES,
5
+ COMPUTER_HOST_STREAM_MEDIA_TYPE,
6
+ COMPUTER_HOST_TOKEN_HEADER,
7
+ computerHostProblemV1,
8
+ encodeComputerHostExecFrameV1,
9
+ type ComputerHostExecFrameV1,
10
+ } from "@frockbot/computer-host-protocol";
11
+ import {
12
+ ComputerHostClient,
13
+ type ComputerHostFetcherV1,
14
+ } from "./host-client.ts";
15
+
16
+ interface RecordedCall {
17
+ pathname: string;
18
+ token: string | null;
19
+ body: Record<string, unknown>;
20
+ }
21
+
22
+ function base64(text: string): string {
23
+ return Buffer.from(text, "utf8").toString("base64");
24
+ }
25
+
26
+ function text(bytes: Uint8Array): string {
27
+ return new TextDecoder().decode(bytes);
28
+ }
29
+
30
+ /** A fetcher that records what it was asked and answers from a queue. */
31
+ function recorder(
32
+ answer: (call: RecordedCall) => Response | Promise<Response>,
33
+ ): { fetcher: ComputerHostFetcherV1; calls: RecordedCall[] } {
34
+ const calls: RecordedCall[] = [];
35
+ return {
36
+ calls,
37
+ fetcher: {
38
+ async fetch(request: Request): Promise<Response> {
39
+ const call: RecordedCall = {
40
+ pathname: new URL(request.url).pathname,
41
+ token: request.headers.get(COMPUTER_HOST_TOKEN_HEADER),
42
+ body: (await request.json()) as Record<string, unknown>,
43
+ };
44
+ calls.push(call);
45
+ return answer(call);
46
+ },
47
+ },
48
+ };
49
+ }
50
+
51
+ /** Never answers; rejects the way `fetch` does when its signal aborts. */
52
+ function hanging(calls: RecordedCall[] = []): ComputerHostFetcherV1 {
53
+ return {
54
+ async fetch(request: Request): Promise<Response> {
55
+ calls.push({
56
+ pathname: new URL(request.url).pathname,
57
+ token: request.headers.get(COMPUTER_HOST_TOKEN_HEADER),
58
+ body: (await request.json()) as Record<string, unknown>,
59
+ });
60
+ return new Promise<Response>((_resolve, reject) => {
61
+ request.signal.addEventListener(
62
+ "abort",
63
+ () => reject(new Error("aborted by signal")),
64
+ { once: true },
65
+ );
66
+ });
67
+ },
68
+ };
69
+ }
70
+
71
+ /**
72
+ * An NDJSON body whose chunk boundaries are deliberately wrong: every chunk is
73
+ * `size` bytes regardless of where a frame ends. A client that reads a chunk
74
+ * as a frame fails here and only here.
75
+ */
76
+ function ndjson(
77
+ frames: readonly ComputerHostExecFrameV1[],
78
+ size: number,
79
+ ): Response {
80
+ const bytes = new TextEncoder().encode(
81
+ frames.map(encodeComputerHostExecFrameV1).join(""),
82
+ );
83
+ let offset = 0;
84
+ return new Response(
85
+ new ReadableStream<Uint8Array>({
86
+ pull(controller) {
87
+ if (offset >= bytes.byteLength) {
88
+ controller.close();
89
+ return;
90
+ }
91
+ controller.enqueue(bytes.subarray(offset, offset + size));
92
+ offset += size;
93
+ },
94
+ }),
95
+ { headers: { "content-type": COMPUTER_HOST_STREAM_MEDIA_TYPE } },
96
+ );
97
+ }
98
+
99
+ function client(
100
+ fetcher: ComputerHostFetcherV1,
101
+ overrides: Partial<ConstructorParameters<typeof ComputerHostClient>[0]> = {},
102
+ ): ComputerHostClient {
103
+ let counter = 0;
104
+ return new ComputerHostClient({
105
+ fetcher,
106
+ hostToken: "host-token",
107
+ identity: { userId: "user-1" },
108
+ tenant: { botId: "bot-1" },
109
+ newEffectId: () => `effect-${(counter += 1)}`,
110
+ ...overrides,
111
+ });
112
+ }
113
+
114
+ function exitFrames(
115
+ stdout: string,
116
+ stderr = "",
117
+ ): readonly ComputerHostExecFrameV1[] {
118
+ return [
119
+ { type: "stdout", dataBase64: base64(stdout) },
120
+ ...(stderr
121
+ ? [{ type: "stderr" as const, dataBase64: base64(stderr) }]
122
+ : []),
123
+ { type: "exit", exitCode: 0, outputTruncated: false },
124
+ ];
125
+ }
126
+
127
+ describe("ComputerHostClient envelope", () => {
128
+ test("carries version, effect, identity, tenant, and credential reference", async () => {
129
+ const { fetcher, calls } = recorder(() =>
130
+ Response.json({
131
+ version: 1,
132
+ effectId: "effect-1",
133
+ spriteName: "frockbot-abc",
134
+ directory: "/home/box/agent-data/agents/bot-1",
135
+ generation: 3,
136
+ }),
137
+ );
138
+ const result = await client(fetcher).open();
139
+
140
+ expect(calls).toHaveLength(1);
141
+ expect(calls[0]?.pathname).toBe(COMPUTER_HOST_ROUTES.open);
142
+ expect(calls[0]?.token).toBe("host-token");
143
+ expect(calls[0]?.body).toEqual({
144
+ version: 1,
145
+ effectId: "effect-1",
146
+ identity: { userId: "user-1" },
147
+ tenant: { botId: "bot-1" },
148
+ // The Durable Object never holds SPRITES_TOKEN: what crosses the seam is
149
+ // a reference the host resolves.
150
+ credentialRef: "sprites:user:user-1",
151
+ });
152
+ expect(result.spriteName).toBe("frockbot-abc");
153
+ expect(result.generation).toBe(3);
154
+ });
155
+
156
+ test("uses a caller's effect identifier when it has recorded one", async () => {
157
+ const { fetcher, calls } = recorder(() =>
158
+ Response.json({
159
+ version: 1,
160
+ effectId: "turn-7-exec-2",
161
+ cancelled: true,
162
+ }),
163
+ );
164
+ await client(fetcher).cancel("turn-7-exec-2");
165
+ expect(calls[0]?.body.effectId).toBe("turn-7-exec-2");
166
+ });
167
+
168
+ test("credentialRef is overridable without a protocol change", async () => {
169
+ const { fetcher, calls } = recorder(() =>
170
+ Response.json({ version: 1, effectId: "effect-1", cancelled: false }),
171
+ );
172
+ await client(fetcher, { credentialRef: "broker:lease:xyz" }).cancel(
173
+ "effect-1",
174
+ );
175
+ expect(calls[0]?.body.credentialRef).toBe("broker:lease:xyz");
176
+ });
177
+
178
+ test("forTenant keeps the Computer and changes only the Bot", async () => {
179
+ const { fetcher, calls } = recorder(() =>
180
+ Response.json({ version: 1, effectId: "effect-1", cancelled: false }),
181
+ );
182
+ await client(fetcher).forTenant("bot-2").cancel("effect-1");
183
+ expect(calls[0]?.body.identity).toEqual({ userId: "user-1" });
184
+ expect(calls[0]?.body.tenant).toEqual({ botId: "bot-2" });
185
+ });
186
+ });
187
+
188
+ describe("ComputerHostClient exec", () => {
189
+ test("ships the script in the body and never on an argv", async () => {
190
+ const script = "echo hello\n".repeat(400);
191
+ const { fetcher, calls } = recorder(() => ndjson(exitFrames("hello\n"), 8));
192
+ await client(fetcher).exec({ script, cwd: "/home/box", env: { A: "b" } });
193
+
194
+ expect(calls[0]?.pathname).toBe(COMPUTER_HOST_ROUTES.exec);
195
+ expect(calls[0]?.body.script).toBe(script);
196
+ expect(calls[0]?.body.cwd).toBe("/home/box");
197
+ expect(calls[0]?.body.env).toEqual({ A: "b" });
198
+ expect(calls[0]?.body.stream).toBe(true);
199
+ });
200
+
201
+ for (const size of [1, 2, 3, 7, 64, 4096]) {
202
+ test(`reassembles NDJSON frames split every ${size} bytes`, async () => {
203
+ const { fetcher } = recorder(() =>
204
+ ndjson(
205
+ [
206
+ { type: "stdout", dataBase64: base64("first line\n") },
207
+ { type: "stderr", dataBase64: base64("a warning\n") },
208
+ { type: "stdout", dataBase64: base64("second line\n") },
209
+ { type: "exit", exitCode: 7, outputTruncated: false },
210
+ ],
211
+ size,
212
+ ),
213
+ );
214
+ const outcome = await client(fetcher).exec({ script: "true" });
215
+ expect(text(outcome.stdout)).toBe("first line\nsecond line\n");
216
+ expect(text(outcome.stderr)).toBe("a warning\n");
217
+ expect(outcome.exitCode).toBe(7);
218
+ expect(outcome.outputTruncated).toBe(false);
219
+ });
220
+ }
221
+
222
+ test("reads a final frame that arrived without its trailing newline", async () => {
223
+ const body = `${encodeComputerHostExecFrameV1({
224
+ type: "stdout",
225
+ dataBase64: base64("done"),
226
+ })}${JSON.stringify({
227
+ type: "exit",
228
+ exitCode: 0,
229
+ outputTruncated: false,
230
+ })}`;
231
+ const { fetcher } = recorder(
232
+ () =>
233
+ new Response(body, {
234
+ headers: { "content-type": COMPUTER_HOST_STREAM_MEDIA_TYPE },
235
+ }),
236
+ );
237
+ const outcome = await client(fetcher).exec({ script: "true" });
238
+ expect(text(outcome.stdout)).toBe("done");
239
+ expect(outcome.exitCode).toBe(0);
240
+ });
241
+
242
+ test("bounds output at maxOutputBytes and says it truncated", async () => {
243
+ const { fetcher } = recorder(() =>
244
+ ndjson(
245
+ [
246
+ { type: "stdout", dataBase64: base64("0123456789") },
247
+ { type: "stdout", dataBase64: base64("abcdefghij") },
248
+ { type: "exit", exitCode: 0, outputTruncated: false },
249
+ ],
250
+ 5,
251
+ ),
252
+ );
253
+ const outcome = await client(fetcher).exec({
254
+ script: "true",
255
+ maxOutputBytes: 12,
256
+ });
257
+ expect(text(outcome.stdout)).toBe("0123456789ab");
258
+ expect(outcome.outputTruncated).toBe(true);
259
+ // The exit frame still arrived: the read is bounded, not abandoned.
260
+ expect(outcome.exitCode).toBe(0);
261
+ });
262
+
263
+ test("carries the host's own truncation through", async () => {
264
+ const { fetcher } = recorder(() =>
265
+ ndjson(
266
+ [
267
+ { type: "stdout", dataBase64: base64("x") },
268
+ { type: "exit", exitCode: 0, outputTruncated: true },
269
+ ],
270
+ 3,
271
+ ),
272
+ );
273
+ const outcome = await client(fetcher).exec({ script: "true" });
274
+ expect(outcome.outputTruncated).toBe(true);
275
+ });
276
+
277
+ test("an error frame becomes the ComputerError it declares", async () => {
278
+ const { fetcher } = recorder(() =>
279
+ ndjson(
280
+ [
281
+ { type: "stdout", dataBase64: base64("partial") },
282
+ {
283
+ type: "error",
284
+ code: "human-control-active",
285
+ message: "a human holds this Computer",
286
+ retryable: true,
287
+ },
288
+ ],
289
+ 6,
290
+ ),
291
+ );
292
+ const error = await client(fetcher)
293
+ .exec({ script: "true" })
294
+ .catch((thrown: unknown) => thrown);
295
+ expect(error).toBeInstanceOf(ComputerError);
296
+ expect((error as ComputerError).code).toBe("human-control-active");
297
+ expect((error as ComputerError).retryable).toBe(true);
298
+ });
299
+
300
+ test("a stream that ends before the exit frame is unavailable, not failed", async () => {
301
+ const { fetcher } = recorder(() =>
302
+ ndjson([{ type: "stdout", dataBase64: base64("half") }], 4),
303
+ );
304
+ const error = await client(fetcher)
305
+ .exec({ script: "true" })
306
+ .catch((thrown: unknown) => thrown);
307
+ expect((error as ComputerError).code).toBe("provider-unavailable");
308
+ expect((error as ComputerError).retryable).toBe(true);
309
+ });
310
+
311
+ test("a buffered exec decodes one result", async () => {
312
+ const { fetcher, calls } = recorder(() =>
313
+ Response.json({
314
+ version: 1,
315
+ effectId: "effect-1",
316
+ exitCode: 0,
317
+ stdoutBase64: base64("buffered"),
318
+ stderrBase64: "",
319
+ outputTruncated: false,
320
+ }),
321
+ );
322
+ const outcome = await client(fetcher).exec({
323
+ script: "true",
324
+ stream: false,
325
+ });
326
+ expect(calls[0]?.body.stream).toBe(false);
327
+ expect(text(outcome.stdout)).toBe("buffered");
328
+ expect(outcome.stderr).toHaveLength(0);
329
+ });
330
+
331
+ test("extra stdin travels base64 in the body", async () => {
332
+ const { fetcher, calls } = recorder(() => ndjson(exitFrames(""), 32));
333
+ await client(fetcher).exec({
334
+ script: "cat",
335
+ stdin: new TextEncoder().encode("payload"),
336
+ });
337
+ expect(calls[0]?.body.stdinBase64).toBe(base64("payload"));
338
+ });
339
+ });
340
+
341
+ describe("ComputerHostClient failures", () => {
342
+ test("computer-updating is provider-neutral updating and retryable", async () => {
343
+ const { fetcher } = recorder(() =>
344
+ Response.json(
345
+ computerHostProblemV1(
346
+ "computer-updating",
347
+ "Updating the Computer runtime",
348
+ ),
349
+ { status: 409 },
350
+ ),
351
+ );
352
+ const error = await client(fetcher)
353
+ .exec({ script: "true" })
354
+ .catch((thrown: unknown) => thrown);
355
+ expect(error).toBeInstanceOf(ComputerError);
356
+ expect((error as ComputerError).code).toBe("updating");
357
+ expect((error as ComputerError).retryable).toBe(true);
358
+ expect((error as ComputerError).message).toBe(
359
+ "Updating the Computer runtime",
360
+ );
361
+ });
362
+
363
+ test("429 is limit-exceeded and retryable", async () => {
364
+ const { fetcher } = recorder(() =>
365
+ Response.json(
366
+ computerHostProblemV1(
367
+ "limit-exceeded",
368
+ "This Computer is already running 4 effects",
369
+ ),
370
+ { status: 429 },
371
+ ),
372
+ );
373
+ const error = await client(fetcher)
374
+ .exec({ script: "true" })
375
+ .catch((thrown: unknown) => thrown);
376
+ expect(error).toBeInstanceOf(ComputerError);
377
+ expect((error as ComputerError).code).toBe("limit-exceeded");
378
+ expect((error as ComputerError).retryable).toBe(true);
379
+ });
380
+
381
+ test("a 429 with an undecodable body is still limit-exceeded", async () => {
382
+ const { fetcher } = recorder(
383
+ () => new Response("Too Many Requests", { status: 429 }),
384
+ );
385
+ const error = await client(fetcher)
386
+ .exec({ script: "true" })
387
+ .catch((thrown: unknown) => thrown);
388
+ expect((error as ComputerError).code).toBe("limit-exceeded");
389
+ });
390
+
391
+ test("a wrong token is a provider failure and not retried", async () => {
392
+ const { fetcher } = recorder(() =>
393
+ Response.json(
394
+ computerHostProblemV1("not-authorized", "token is missing or wrong"),
395
+ { status: 401 },
396
+ ),
397
+ );
398
+ const error = await client(fetcher)
399
+ .exec({ script: "true" })
400
+ .catch((thrown: unknown) => thrown);
401
+ expect((error as ComputerError).code).toBe("provider-failure");
402
+ expect((error as ComputerError).retryable).toBe(false);
403
+ });
404
+
405
+ test("a host timeout is provider-unavailable", async () => {
406
+ const { fetcher } = recorder(() =>
407
+ Response.json(computerHostProblemV1("timeout", "exec exceeded 120s"), {
408
+ status: 504,
409
+ }),
410
+ );
411
+ const error = await client(fetcher)
412
+ .exec({ script: "true" })
413
+ .catch((thrown: unknown) => thrown);
414
+ expect((error as ComputerError).code).toBe("provider-unavailable");
415
+ });
416
+
417
+ test("an unreachable host is provider-unavailable and retryable", async () => {
418
+ const error = await client({
419
+ fetch: () => Promise.reject(new Error("no such service binding")),
420
+ })
421
+ .exec({ script: "true" })
422
+ .catch((thrown: unknown) => thrown);
423
+ expect((error as ComputerError).code).toBe("provider-unavailable");
424
+ expect((error as ComputerError).retryable).toBe(true);
425
+ expect((error as ComputerError).message).toContain(
426
+ "no such service binding",
427
+ );
428
+ });
429
+
430
+ test("the client's own deadline expiring is provider-unavailable", async () => {
431
+ const error = await client(hanging())
432
+ .exec({ script: "sleep 600", timeoutMs: 5 }, { timeoutMs: 5 })
433
+ .catch((thrown: unknown) => thrown);
434
+ expect((error as ComputerError).code).toBe("provider-unavailable");
435
+ expect((error as ComputerError).retryable).toBe(true);
436
+ }, 10_000);
437
+
438
+ test("a caller's abort is aborted, and posts a cancel for the same effect", async () => {
439
+ const calls: RecordedCall[] = [];
440
+ const hangs = hanging(calls);
441
+ const cancels: RecordedCall[] = [];
442
+ const fetcher: ComputerHostFetcherV1 = {
443
+ async fetch(request: Request): Promise<Response> {
444
+ if (new URL(request.url).pathname === COMPUTER_HOST_ROUTES.cancel) {
445
+ cancels.push({
446
+ pathname: new URL(request.url).pathname,
447
+ token: request.headers.get(COMPUTER_HOST_TOKEN_HEADER),
448
+ body: (await request.json()) as Record<string, unknown>,
449
+ });
450
+ return Response.json({
451
+ version: 1,
452
+ effectId: "effect-1",
453
+ cancelled: true,
454
+ });
455
+ }
456
+ return hangs.fetch(request);
457
+ },
458
+ };
459
+ const controller = new AbortController();
460
+ const running = client(fetcher)
461
+ .exec({ script: "sleep 600" }, { signal: controller.signal })
462
+ .catch((thrown: unknown) => thrown);
463
+ await Bun.sleep(10);
464
+ controller.abort();
465
+ const error = await running;
466
+
467
+ expect((error as ComputerError).code).toBe("aborted");
468
+ expect((error as ComputerError).retryable).toBe(false);
469
+ await Bun.sleep(10);
470
+ // "a dropped connection is an outcome, not a failure": the abort alone
471
+ // leaves the process running on the Computer, so the cancel names it.
472
+ expect(cancels).toHaveLength(1);
473
+ expect(cancels[0]?.body.effectId).toBe(calls[0]?.body.effectId);
474
+ }, 10_000);
475
+
476
+ test("a result the decoder refuses is not silently accepted", async () => {
477
+ const { fetcher } = recorder(() =>
478
+ Response.json({
479
+ version: 1,
480
+ effectId: "effect-1",
481
+ spriteName: "frockbot-abc",
482
+ directory: "/home/box",
483
+ generation: 1,
484
+ // Not in the schema. A caller must not be able to smuggle a field
485
+ // through the seam.
486
+ smuggled: "value",
487
+ }),
488
+ );
489
+ await expect(client(fetcher).open()).rejects.toThrow(/unknown field/);
490
+ });
491
+
492
+ test("a result whose version is not 1 is refused", async () => {
493
+ const { fetcher } = recorder(() =>
494
+ Response.json({
495
+ version: 2,
496
+ effectId: "effect-1",
497
+ spriteName: "frockbot-abc",
498
+ directory: "/home/box",
499
+ generation: 1,
500
+ }),
501
+ );
502
+ await expect(client(fetcher).open()).rejects.toThrow(/version is not 1/);
503
+ });
504
+ });
505
+
506
+ describe("ComputerHostClient operations", () => {
507
+ test("file bytes round-trip as base64 and never as text", async () => {
508
+ const bytes = Uint8Array.from([0, 1, 250, 255, 10]);
509
+ const { fetcher, calls } = recorder((call) =>
510
+ call.pathname === COMPUTER_HOST_ROUTES["file/write"]
511
+ ? Response.json({
512
+ version: 1,
513
+ effectId: "effect-1",
514
+ entry: {
515
+ path: "/home/box/x.bin",
516
+ kind: "file",
517
+ size: 5,
518
+ mode: 0o644,
519
+ },
520
+ })
521
+ : Response.json({
522
+ version: 1,
523
+ effectId: "effect-2",
524
+ entry: {
525
+ path: "/home/box/x.bin",
526
+ kind: "file",
527
+ size: 5,
528
+ mode: 0o644,
529
+ },
530
+ bytesBase64: Buffer.from(bytes).toString("base64"),
531
+ }),
532
+ );
533
+ const host = client(fetcher);
534
+ const written = await host.fileWrite("/home/box/x.bin", bytes);
535
+ expect(written.entry.size).toBe(5);
536
+ expect(calls[0]?.body.bytesBase64).toBe(
537
+ Buffer.from(bytes).toString("base64"),
538
+ );
539
+ const read = await host.fileRead("/home/box/x.bin");
540
+ expect([...Buffer.from(read.bytesBase64, "base64")]).toEqual([...bytes]);
541
+ });
542
+
543
+ test("control and viewer are reachable from the Durable Object path", async () => {
544
+ const { fetcher, calls } = recorder((call) =>
545
+ call.pathname === COMPUTER_HOST_ROUTES.control
546
+ ? Response.json({
547
+ version: 1,
548
+ effectId: "effect-1",
549
+ action: "acquire",
550
+ ownerId: "owner-1",
551
+ expiresAt: "2026-08-31T00:00:00.000Z",
552
+ })
553
+ : Response.json({
554
+ version: 1,
555
+ effectId: "effect-2",
556
+ session: {
557
+ id: "token-1",
558
+ url: "https://sprite.example/vnc.html",
559
+ expiresAt: "2026-08-31T00:00:00.000Z",
560
+ },
561
+ }),
562
+ );
563
+ const host = client(fetcher);
564
+ const lease = await host.control("acquire", "owner-1", 900);
565
+ expect(lease.expiresAt).toBe("2026-08-31T00:00:00.000Z");
566
+ expect(calls[0]?.body.maxAgeSeconds).toBe(900);
567
+
568
+ const viewer = await host.viewer("open");
569
+ expect(viewer.session?.url).toBe("https://sprite.example/vnc.html");
570
+ await host.viewer("renew", { sessionId: "token-1" });
571
+ expect(calls.at(-1)?.body).toMatchObject({
572
+ action: "renew",
573
+ sessionId: "token-1",
574
+ });
575
+ });
576
+
577
+ test("a declared service reattach reports its status", async () => {
578
+ const { fetcher } = recorder(() =>
579
+ Response.json({
580
+ version: 1,
581
+ effectId: "effect-1",
582
+ name: "frockbot-desktop",
583
+ status: "unavailable",
584
+ }),
585
+ );
586
+ const result = await client(fetcher).service("frockbot-desktop");
587
+ expect(result.status).toBe("unavailable");
588
+ });
589
+
590
+ test("file list and delete decode at the seam", async () => {
591
+ const { fetcher } = recorder((call) =>
592
+ call.pathname === COMPUTER_HOST_ROUTES["file/list"]
593
+ ? Response.json({
594
+ version: 1,
595
+ effectId: "effect-1",
596
+ entries: [
597
+ { path: "/home/box/a", kind: "file", size: 1, mode: 0o644 },
598
+ { path: "/home/box/b", kind: "directory", size: 0, mode: 0o755 },
599
+ ],
600
+ truncated: false,
601
+ })
602
+ : Response.json({
603
+ version: 1,
604
+ effectId: "effect-2",
605
+ path: "/home/box/a",
606
+ deleted: true,
607
+ }),
608
+ );
609
+ const host = client(fetcher);
610
+ const listed = await host.fileList("/home/box", { recursive: true });
611
+ expect(listed.entries.map((entry) => entry.kind)).toEqual([
612
+ "file",
613
+ "directory",
614
+ ]);
615
+ const deleted = await host.fileDelete("/home/box/a");
616
+ expect(deleted.deleted).toBe(true);
617
+ });
618
+ });